1282 lines
54 KiB
Markdown
1282 lines
54 KiB
Markdown
# CannaManage — Sprint 6 Implementation Plan
|
||
|
||
**Date:** 2026-06-12
|
||
**Author:** Patrick Plate / Lumen (Planner)
|
||
**Status:** Draft v3 (post-review)
|
||
**Base Branch:** `main`
|
||
**Sprint Branch:** `sprint/6-production`
|
||
**Sprint Goal:** Production readiness — deploy, DSGVO compliance, Stripe payments, immutable audit log, grow calendar, notifications, launch
|
||
|
||
> **Sprint Structure:** Sprint 6 is the "go-live" sprint, split into 7 phases:
|
||
> - **Phase 1** — Production deployment (IONOS VPS + Docker Compose + TLS)
|
||
> - **Phase 2** — DSGVO consent management
|
||
> - **Phase 3** — Stripe integration (SEPA + PayPal + Card)
|
||
> - **Phase 4** — Immutable audit log
|
||
> - **Phase 5** — Grow calendar (FULL — sensors, photo log, feeding schedule)
|
||
> - **Phase 6** — Notifications + PWA
|
||
> - **Phase 7** — Launch checklist + documentation
|
||
>
|
||
> Estimated effort: ~23–24 days (single worker, sequential). Phases are prioritized — Phases 1–4 are hard requirements for launch, Phases 5–7 are value-adds.
|
||
|
||
---
|
||
|
||
## 0. Decisions (Confirmed ✅)
|
||
|
||
| # | Decision | Detail | Status |
|
||
|---|----------|--------|--------|
|
||
| D1 | Payment provider | **Stripe** (fresh account for CannaManage) — full-featured API for SEPA, PayPal, Card. Handles mandates, invoicing, dunning (Mahnwesen), billing portal. Cost: €0.35/SEPA tx. | ✅ Confirmed |
|
||
| D2 | Primary payment method | **SEPA Lastschrift** (direct debit) — standard for German SaaS recurring billing. PayPal + Card as fallback via Stripe Payment Element. | ✅ Confirmed |
|
||
| D3 | Deploy target | **IONOS VPS** (existing plate-software.de server, 8 GB RAM, ~2 cores) — deploy alongside existing services. Docker Compose, Nginx reverse proxy, Let's Encrypt TLS. Already running, no new provisioning needed. | ✅ Confirmed |
|
||
| D4 | Domain | **Subdomain on plate-software.de** (e.g., `cannamanage.plate-software.de` or `app.plate-software.de/cannamanage`) — IONOS DNS, no new domain registration needed | ✅ Confirmed |
|
||
| D5 | Backup strategy | **PostgreSQL pg_dump** — 7 days daily + 4 weeks weekly retention, stored on IONOS server | ✅ Confirmed |
|
||
| D6 | CI/CD | **Gitea Actions** (self-hosted on plate-software.de) → build Docker images → deploy locally | ✅ Confirmed |
|
||
| D7 | Monitoring | **Uptime Kuma** (self-hosted) or simple cron + curl health check with Telegram/email alerts | ✅ Confirmed |
|
||
| D8 | DSGVO approach | **Consent-first** — users must accept before using the app. Full Art. 15 (data export) + Art. 17 (erasure) compliance. | ✅ Confirmed |
|
||
| D9 | Audit log | **Immutable append-only** table with server-generated timestamps (Europe/Berlin). No UPDATE/DELETE on audit records. | ✅ Confirmed |
|
||
| D10 | Grow calendar | **FullCalendar** (React) — FULL scope: sensors (temp/humidity), photo log, feeding schedule, stage tracking with batch linking. | ✅ Confirmed |
|
||
| D11 | Notifications | **WebSocket (SockJS/STOMP)** for real-time + **Web Push API** for background push (opt-in) | ✅ Confirmed |
|
||
| D12 | Pricing model | **3-month free trial** → Tiered: Starter (≤30 members, €19/mo), Pro (≤100 members, €49/mo), Enterprise (custom) | ✅ Confirmed |
|
||
| D13 | PWA scope | **Basic in Sprint 6** — manifest + offline page + install prompt. Full offline sync deferred. | ✅ Confirmed |
|
||
|
||
---
|
||
|
||
## 1. Sprint 5 Recap (Context)
|
||
|
||
| Delivered | Status |
|
||
|-----------|--------|
|
||
| Full-stack integration (React Query ↔ Spring Boot API) | ✅ |
|
||
| Docker Compose (PostgreSQL + backend + frontend, health checks) | ✅ |
|
||
| Next.js 15.5.18 upgrade (8+ CVEs resolved) | ✅ |
|
||
| Staff management UI (list, invite, edit permissions, revoke) | ✅ |
|
||
| System test harness (Docker Compose test profile, Playwright E2E) | ✅ |
|
||
| Per-component loading, offline resilience, error boundary | ✅ |
|
||
| CORS configuration, seed data strategy (SQL + API-driven) | ✅ |
|
||
|
||
**Critical gap from Sprint 5:** App runs locally with Docker Compose but has no production deployment, no payment system, no DSGVO compliance, and no audit trail. These are hard blockers for going live.
|
||
|
||
---
|
||
|
||
## 2. Sprint 6 Scope
|
||
|
||
### ✅ IN Scope — Hard Requirements (Phases 1–4)
|
||
|
||
| # | Feature | Priority | Effort |
|
||
|---|---------|----------|--------|
|
||
| 1 | **Production deployment** — IONOS VPS (existing plate-software.de), Nginx subdomain, TLS, Docker Compose prod, backups, Gitea Actions CI/CD, monitoring | P0 | 3 days |
|
||
| 2 | **DSGVO consent management** — consent entity, banner, audit log, data export (Art. 15), data deletion (Art. 17) | P0 | 3 days |
|
||
| 3 | **Stripe integration** — fresh account, SEPA + PayPal + Card, 3-month free trial, tiered subscriptions (Starter €19/Pro €49/Enterprise), billing portal, webhooks | P0 | 4 days |
|
||
| 4 | **Immutable audit log** — event entity, AuditService, admin UI, PDF export | P0 | 2 days |
|
||
|
||
**Phases 1–4 effort:** ~12 days
|
||
|
||
### ✅ IN Scope — Value Adds (Phases 5–7)
|
||
|
||
| # | Feature | Priority | Effort |
|
||
|---|---------|----------|--------|
|
||
| 5 | **Grow calendar (FULL)** — entity, CRUD API, FullCalendar UI, batch linking, sensor integration (temp/humidity), photo uploads, feeding schedules | P1 | 5–6 days |
|
||
| 6 | **Notifications + PWA** — WebSocket, bell icon, push notifications, service worker, manifest | P1 | 3 days |
|
||
| 7 | **Launch checklist + documentation** — security scan, perf test, ToS/Datenschutz pages, pricing, landing page | P2 | 3 days |
|
||
|
||
**Phases 5–7 effort:** ~11–12 days
|
||
|
||
**Total estimated effort:** ~23–24 days (single worker, sequential)
|
||
|
||
### ❌ OUT of Scope (Sprint 7+)
|
||
|
||
- 2FA (TOTP) for members and staff
|
||
- Inspector read-only mode (Behörde officials)
|
||
- Monthly report auto-sealing (SHA-256 hash)
|
||
- Cryptographic hash chain on distributions
|
||
- Mobile app (React Native or Flutter)
|
||
- Multi-club marketplace
|
||
- Advanced analytics / BI dashboard
|
||
|
||
---
|
||
|
||
## 3. Architecture Decisions
|
||
|
||
### 3.1 Production Infrastructure (Phase 1)
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────┐
|
||
│ IONOS VPS (existing plate-software.de) │
|
||
│ ~2 cores, 8 GB RAM — co-hosted with other services │
|
||
│ │
|
||
│ ┌─────────────────────────────────────────────────┐ │
|
||
│ │ Nginx (reverse proxy) │ │
|
||
│ │ - TLS termination (Let's Encrypt / certbot) │ │
|
||
│ │ - HTTP/2, HSTS, security headers │ │
|
||
│ │ - WebSocket upgrade (ws://) │ │
|
||
│ │ - Rate limiting (limit_req_zone) │ │
|
||
│ └───────────┬──────────────────┬──────────────────┘ │
|
||
│ │ │ │
|
||
│ ┌───────────▼──────┐ ┌───────▼──────────────────┐ │
|
||
│ │ Frontend :3000 │ │ Backend :8080 │ │
|
||
│ │ (Next.js SSR) │ │ (Spring Boot + JPA) │ │
|
||
│ └──────────────────┘ └──────────┬───────────────┘ │
|
||
│ │ │
|
||
│ ┌──────────▼───────────────┐ │
|
||
│ │ PostgreSQL 16 :5432 │ │
|
||
│ │ (pgdata volume) │ │
|
||
│ └──────────────────────────┘ │
|
||
│ │
|
||
│ ┌────────────────┐ ┌───────────────────────────┐ │
|
||
│ │ Uptime Kuma │ │ pg_dump cron (daily) │ │
|
||
│ │ :3001 │ │ → /backup/ (7-day rotate)│ │
|
||
│ └────────────────┘ └───────────────────────────┘ │
|
||
└──────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Nginx configuration (key directives):**
|
||
|
||
```nginx
|
||
# /etc/nginx/sites-available/cannamanage.plate-software.de
|
||
server {
|
||
listen 443 ssl http2;
|
||
server_name cannamanage.plate-software.de;
|
||
|
||
ssl_certificate /etc/letsencrypt/live/cannamanage.plate-software.de/fullchain.pem;
|
||
ssl_certificate_key /etc/letsencrypt/live/cannamanage.plate-software.de/privkey.pem;
|
||
|
||
# Security headers
|
||
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
|
||
add_header X-Content-Type-Options nosniff always;
|
||
add_header X-Frame-Options DENY always;
|
||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline' js.stripe.com; frame-src js.stripe.com hooks.stripe.com; img-src 'self' data: *.stripe.com;" always;
|
||
|
||
# Rate limiting
|
||
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/s;
|
||
|
||
# Frontend (Next.js)
|
||
location / {
|
||
proxy_pass http://127.0.0.1:3000;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
# Backend API
|
||
location /api/ {
|
||
limit_req zone=api burst=50 nodelay;
|
||
proxy_pass http://127.0.0.1:8080;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||
proxy_set_header X-Forwarded-Proto $scheme;
|
||
}
|
||
|
||
# WebSocket
|
||
location /ws/ {
|
||
proxy_pass http://127.0.0.1:8080;
|
||
proxy_http_version 1.1;
|
||
proxy_set_header Upgrade $http_upgrade;
|
||
proxy_set_header Connection "upgrade";
|
||
proxy_set_header Host $host;
|
||
}
|
||
|
||
# Stripe webhook (no rate limit — Stripe needs reliable delivery)
|
||
location /api/v1/webhooks/stripe {
|
||
proxy_pass http://127.0.0.1:8080;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header Stripe-Signature $http_stripe_signature;
|
||
}
|
||
}
|
||
|
||
server {
|
||
listen 80;
|
||
server_name cannamanage.plate-software.de;
|
||
return 301 https://$host$request_uri;
|
||
}
|
||
```
|
||
|
||
**Docker Compose production profile:**
|
||
|
||
```yaml
|
||
# docker-compose.prod.yml
|
||
version: '3.9'
|
||
|
||
services:
|
||
db:
|
||
image: postgres:16-alpine
|
||
container_name: cannamanage-db
|
||
restart: always
|
||
environment:
|
||
POSTGRES_DB: ${DB_NAME}
|
||
POSTGRES_USER: ${DB_USER}
|
||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||
volumes:
|
||
- pgdata:/var/lib/postgresql/data
|
||
- ./backup:/backup
|
||
healthcheck:
|
||
test: ["CMD-SHELL", "pg_isready -U ${DB_USER}"]
|
||
interval: 10s
|
||
timeout: 5s
|
||
retries: 5
|
||
|
||
backend:
|
||
image: ghcr.io/pplate/cannamanage-backend:${TAG:-latest}
|
||
container_name: cannamanage-backend
|
||
restart: always
|
||
environment:
|
||
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/${DB_NAME}
|
||
- SPRING_DATASOURCE_USERNAME=${DB_USER}
|
||
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
|
||
- SPRING_PROFILES_ACTIVE=prod
|
||
- JWT_SECRET=${JWT_SECRET}
|
||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||
- STRIPE_PRICE_STARTER=${STRIPE_PRICE_STARTER}
|
||
- STRIPE_PRICE_PRO=${STRIPE_PRICE_PRO}
|
||
depends_on:
|
||
db:
|
||
condition: service_healthy
|
||
healthcheck:
|
||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||
interval: 15s
|
||
timeout: 5s
|
||
retries: 5
|
||
start_period: 45s
|
||
|
||
frontend:
|
||
image: ghcr.io/pplate/cannamanage-frontend:${TAG:-latest}
|
||
container_name: cannamanage-frontend
|
||
restart: always
|
||
environment:
|
||
- BACKEND_URL=http://backend:8080
|
||
- NEXTAUTH_URL=https://cannamanage.plate-software.de
|
||
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
|
||
- AUTH_TRUST_HOST=true
|
||
- NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY}
|
||
depends_on:
|
||
backend:
|
||
condition: service_healthy
|
||
|
||
volumes:
|
||
pgdata:
|
||
```
|
||
|
||
**Backup cron script:**
|
||
|
||
```bash
|
||
#!/bin/bash
|
||
# /opt/cannamanage/backup.sh — runs daily via cron
|
||
BACKUP_DIR="/opt/cannamanage/backup"
|
||
DAILY_RETENTION=7
|
||
WEEKLY_RETENTION=28
|
||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||
DAY_OF_WEEK=$(date +%u)
|
||
|
||
# GPG-encrypted backup (DSGVO-compliant)
|
||
docker exec cannamanage-db pg_dump -U ${DB_USER} -d ${DB_NAME} \
|
||
| gpg --encrypt --recipient cannamanage-backup \
|
||
> "${BACKUP_DIR}/cannamanage_daily_${TIMESTAMP}.sql.gpg"
|
||
|
||
# Weekly backup on Sundays
|
||
if [ "$DAY_OF_WEEK" -eq 7 ]; then
|
||
cp "${BACKUP_DIR}/cannamanage_daily_${TIMESTAMP}.sql.gz" \
|
||
"${BACKUP_DIR}/cannamanage_weekly_${TIMESTAMP}.sql.gz"
|
||
fi
|
||
|
||
# Rotate: 7 days daily + 4 weeks weekly
|
||
find "${BACKUP_DIR}" -name "*_daily_*.sql.gz" -mtime +${DAILY_RETENTION} -delete
|
||
find "${BACKUP_DIR}" -name "*_weekly_*.sql.gz" -mtime +${WEEKLY_RETENTION} -delete
|
||
```
|
||
|
||
**Gitea Actions CI/CD (self-hosted on plate-software.de):**
|
||
|
||
```yaml
|
||
# .gitea/workflows/deploy.yml
|
||
name: Deploy to Production
|
||
on:
|
||
push:
|
||
branches: [main]
|
||
paths-ignore: ['docs/**', '*.md']
|
||
|
||
jobs:
|
||
build-and-deploy:
|
||
runs-on: ubuntu-latest
|
||
steps:
|
||
- uses: actions/checkout@v4
|
||
|
||
- name: Set up JDK 17
|
||
uses: actions/setup-java@v4
|
||
with:
|
||
java-version: '17'
|
||
distribution: 'temurin'
|
||
|
||
- name: Build backend
|
||
run: mvn -f cannamanage-api/pom.xml package -DskipTests -B
|
||
|
||
- name: Build Docker images
|
||
run: |
|
||
docker build -f Dockerfile.backend -t cannamanage-backend:${{ gitea.sha }} .
|
||
docker build -f cannamanage-frontend/Dockerfile -t cannamanage-frontend:${{ gitea.sha }} ./cannamanage-frontend
|
||
|
||
- name: Deploy locally
|
||
run: |
|
||
cd /opt/cannamanage
|
||
export TAG=${{ gitea.sha }}
|
||
docker compose -f docker-compose.prod.yml up -d --remove-orphans
|
||
docker image prune -f
|
||
```
|
||
|
||
### 3.2 DSGVO Consent Architecture (Phase 2)
|
||
|
||
```
|
||
┌─────────────────────────────────────────────────────────┐
|
||
│ DSGVO Consent Flow │
|
||
│ │
|
||
│ User Login ─→ Check consent_accepted? ─→ YES ─→ App │
|
||
│ │ │
|
||
│ NO │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ Consent Banner (modal) │
|
||
│ - Datenschutzerklärung link │
|
||
│ - AGB link │
|
||
│ - "Akzeptieren" button │
|
||
│ - "Ablehnen" → logout │
|
||
│ │ │
|
||
│ ACCEPT │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ ConsentController.accept() │
|
||
│ → stores Consent entity │
|
||
│ → audit log entry │
|
||
│ → sets consent cookie │
|
||
│ → redirects to app │
|
||
└─────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Entity:**
|
||
|
||
```java
|
||
@Entity
|
||
@Table(name = "consent")
|
||
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
||
public class Consent extends AbstractTenantEntity {
|
||
|
||
@Id
|
||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||
private Long id;
|
||
|
||
@ManyToOne(fetch = FetchType.LAZY)
|
||
@JoinColumn(name = "user_id", nullable = false)
|
||
private User user;
|
||
|
||
@Column(name = "consent_type", nullable = false)
|
||
@Enumerated(EnumType.STRING)
|
||
private ConsentType consentType; // PRIVACY_POLICY, TERMS_OF_SERVICE, DATA_PROCESSING
|
||
|
||
@Column(name = "version", nullable = false)
|
||
private String version; // "2026-06-v1" — tracks which version was accepted
|
||
|
||
@Column(name = "accepted_at", nullable = false)
|
||
private Instant acceptedAt;
|
||
|
||
@Column(name = "revoked_at")
|
||
private Instant revokedAt;
|
||
|
||
@Column(name = "ip_address")
|
||
private String ipAddress;
|
||
|
||
@Column(name = "user_agent")
|
||
private String userAgent;
|
||
}
|
||
```
|
||
|
||
**Flyway migration (`V6__consent_and_audit.sql`):**
|
||
|
||
```sql
|
||
-- Consent table
|
||
CREATE TABLE consent (
|
||
id BIGSERIAL PRIMARY KEY,
|
||
club_id BIGINT NOT NULL REFERENCES club(id),
|
||
user_id BIGINT NOT NULL REFERENCES users(id),
|
||
consent_type VARCHAR(50) NOT NULL,
|
||
version VARCHAR(50) NOT NULL,
|
||
accepted_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
|
||
revoked_at TIMESTAMP WITH TIME ZONE,
|
||
ip_address VARCHAR(45),
|
||
user_agent VARCHAR(500),
|
||
CONSTRAINT uq_consent_user_type UNIQUE (user_id, consent_type, version)
|
||
);
|
||
|
||
CREATE INDEX idx_consent_user ON consent(user_id);
|
||
CREATE INDEX idx_consent_club ON consent(club_id);
|
||
```
|
||
|
||
**Data export endpoint (Art. 15):**
|
||
|
||
```java
|
||
@GetMapping("/api/v1/dsgvo/export")
|
||
@PreAuthorize("isAuthenticated()")
|
||
public ResponseEntity<byte[]> exportMyData(@AuthenticationPrincipal UserDetails user) {
|
||
// Collects: user profile, all distributions, quota history, consent records, login history
|
||
byte[] zipFile = dsgvoService.exportUserData(user.getUsername());
|
||
return ResponseEntity.ok()
|
||
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=meine-daten.zip")
|
||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||
.body(zipFile);
|
||
}
|
||
```
|
||
|
||
**Data deletion endpoint (Art. 17):**
|
||
|
||
```java
|
||
@DeleteMapping("/api/v1/dsgvo/erasure")
|
||
@PreAuthorize("isAuthenticated()")
|
||
public ResponseEntity<Void> requestErasure(@AuthenticationPrincipal UserDetails user) {
|
||
// Soft-delete user data, anonymize distributions (keep aggregates for compliance),
|
||
// revoke all consents, invalidate sessions
|
||
// NOTE: Some data must be retained for legal compliance (tax records, 10-year retention)
|
||
dsgvoService.processErasureRequest(user.getUsername());
|
||
return ResponseEntity.noContent().build();
|
||
}
|
||
```
|
||
|
||
### 3.3 Stripe Payment Architecture (Phase 3)
|
||
|
||
```
|
||
┌───────────────────────────────────────────────────────────────┐
|
||
│ Stripe Integration │
|
||
│ │
|
||
│ Club Onboarding ─→ Stripe Customer creation │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ Billing Page ─→ Stripe Checkout Session │
|
||
│ (/settings/billing) (Payment Element: SEPA/PayPal/Card) │
|
||
│ │ │
|
||
│ ▼ │
|
||
│ Subscription active │
|
||
│ │ │
|
||
│ ├─→ invoice.paid → extend access │
|
||
│ ├─→ invoice.payment_failed → warn user │
|
||
│ └─→ customer.subscription.deleted → lock │
|
||
│ │
|
||
│ Self-service ─→ Stripe Billing Portal │
|
||
│ (update payment method, cancel, download invoices) │
|
||
└───────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
**Pricing tiers:**
|
||
|
||
| Tier | Price | Features | Stripe Price ID |
|
||
|------|-------|----------|-----------------|
|
||
| **Free Trial** | €0 (3 months) | Full features, 1 club, ≤30 members | — (trial period on Starter) |
|
||
| **Starter** | €19/month | 1 club, ≤30 members, email support | `price_starter_monthly` |
|
||
| **Pro** | €49/month | 1 club, ≤100 members, priority support, API access | `price_pro_monthly` |
|
||
| **Enterprise** | Custom | Multiple clubs, unlimited members, dedicated support, SLA | Contact sales |
|
||
|
||
**Backend — StripeService:**
|
||
|
||
```java
|
||
@Service
|
||
@Slf4j
|
||
public class StripeService {
|
||
|
||
@Value("${stripe.secret-key}")
|
||
private String stripeSecretKey;
|
||
|
||
@Value("${stripe.webhook-secret}")
|
||
private String webhookSecret;
|
||
|
||
@PostConstruct
|
||
void init() {
|
||
Stripe.apiKey = stripeSecretKey;
|
||
}
|
||
|
||
public Customer createCustomer(Club club, User owner) {
|
||
CustomerCreateParams params = CustomerCreateParams.builder()
|
||
.setEmail(owner.getEmail())
|
||
.setName(club.getName())
|
||
.putMetadata("club_id", club.getId().toString())
|
||
.putMetadata("user_id", owner.getId().toString())
|
||
.build();
|
||
return Customer.create(params);
|
||
}
|
||
|
||
public Session createCheckoutSession(Club club, String priceId, String successUrl, String cancelUrl) {
|
||
SessionCreateParams params = SessionCreateParams.builder()
|
||
.setCustomer(club.getStripeCustomerId())
|
||
.setMode(SessionCreateParams.Mode.SUBSCRIPTION)
|
||
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.SEPA_DEBIT)
|
||
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.CARD)
|
||
.addPaymentMethodType(SessionCreateParams.PaymentMethodType.PAYPAL)
|
||
.addLineItem(SessionCreateParams.LineItem.builder()
|
||
.setPrice(priceId)
|
||
.setQuantity(1L)
|
||
.build())
|
||
.setSuccessUrl(successUrl + "?session_id={CHECKOUT_SESSION_ID}")
|
||
.setCancelUrl(cancelUrl)
|
||
.setSubscriptionData(SessionCreateParams.SubscriptionData.builder()
|
||
.setTrialPeriodDays(90L) // 3-month free trial
|
||
.build())
|
||
.build();
|
||
return Session.create(params);
|
||
}
|
||
|
||
public Session createBillingPortalSession(Club club, String returnUrl) {
|
||
com.stripe.param.billingportal.SessionCreateParams params =
|
||
com.stripe.param.billingportal.SessionCreateParams.builder()
|
||
.setCustomer(club.getStripeCustomerId())
|
||
.setReturnUrl(returnUrl)
|
||
.build();
|
||
return com.stripe.model.billingportal.Session.create(params);
|
||
}
|
||
|
||
public void handleWebhook(String payload, String sigHeader) {
|
||
Event event = Webhook.constructEvent(payload, sigHeader, webhookSecret);
|
||
|
||
switch (event.getType()) {
|
||
case "invoice.paid" -> handleInvoicePaid(event);
|
||
case "invoice.payment_failed" -> handlePaymentFailed(event);
|
||
case "customer.subscription.deleted" -> handleSubscriptionCanceled(event);
|
||
case "customer.subscription.updated" -> handleSubscriptionUpdated(event);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
**Webhook controller:**
|
||
|
||
```java
|
||
@RestController
|
||
@RequestMapping("/api/v1/webhooks")
|
||
public class StripeWebhookController {
|
||
|
||
@PostMapping("/stripe")
|
||
public ResponseEntity<String> handleStripeWebhook(
|
||
@RequestBody String payload,
|
||
@RequestHeader("Stripe-Signature") String sigHeader) {
|
||
stripeService.handleWebhook(payload, sigHeader);
|
||
return ResponseEntity.ok("OK");
|
||
}
|
||
}
|
||
```
|
||
|
||
**Frontend — billing page (`/settings/billing`):**
|
||
|
||
```typescript
|
||
// src/app/[locale]/(admin)/settings/billing/page.tsx
|
||
export default function BillingPage() {
|
||
const { data: subscription } = useSubscription()
|
||
const createCheckout = useCreateCheckoutSession()
|
||
const openPortal = useOpenBillingPortal()
|
||
|
||
return (
|
||
<div className="space-y-6">
|
||
<h1>Abonnement & Zahlung</h1>
|
||
|
||
{/* Current plan */}
|
||
<Card>
|
||
<CardHeader><CardTitle>Aktueller Plan</CardTitle></CardHeader>
|
||
<CardContent>
|
||
<Badge>{subscription?.plan ?? "Free Trial"}</Badge>
|
||
<p>Nächste Zahlung: {subscription?.nextPaymentDate}</p>
|
||
<p>Zahlungsart: {subscription?.paymentMethod}</p>
|
||
</CardContent>
|
||
<CardFooter>
|
||
<Button onClick={() => openPortal.mutate()}>
|
||
Zahlungsmethode verwalten
|
||
</Button>
|
||
</CardFooter>
|
||
</Card>
|
||
|
||
{/* Upgrade options (if on trial/starter) */}
|
||
{!subscription?.plan && <PricingCards onSelect={(priceId) => createCheckout.mutate(priceId)} />}
|
||
|
||
{/* Invoice history */}
|
||
<InvoiceHistoryTable />
|
||
</div>
|
||
)
|
||
}
|
||
```
|
||
|
||
**Database changes (Club entity):**
|
||
|
||
```java
|
||
// Add to Club entity
|
||
@Column(name = "stripe_customer_id")
|
||
private String stripeCustomerId;
|
||
|
||
@Column(name = "subscription_status")
|
||
@Enumerated(EnumType.STRING)
|
||
private SubscriptionStatus subscriptionStatus; // TRIALING, ACTIVE, PAST_DUE, CANCELED, NONE
|
||
|
||
@Column(name = "subscription_plan")
|
||
private String subscriptionPlan; // "starter", "pro"
|
||
|
||
@Column(name = "trial_ends_at")
|
||
private Instant trialEndsAt;
|
||
```
|
||
|
||
### 3.4 Immutable Audit Log Architecture (Phase 4)
|
||
|
||
```java
|
||
@Entity
|
||
@Table(name = "audit_event")
|
||
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
||
@Immutable // Hibernate: prevents UPDATE/DELETE
|
||
public class AuditEvent {
|
||
|
||
@Id
|
||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||
private Long id;
|
||
|
||
@Column(name = "club_id", nullable = false)
|
||
private Long clubId;
|
||
|
||
@Column(name = "actor_id")
|
||
private Long actorId; // null for system events
|
||
|
||
@Column(name = "actor_name", nullable = false)
|
||
private String actorName; // "Patrick Plate" or "SYSTEM"
|
||
|
||
@Column(name = "event_type", nullable = false)
|
||
@Enumerated(EnumType.STRING)
|
||
private AuditEventType eventType;
|
||
|
||
@Column(name = "entity_type")
|
||
private String entityType; // "Distribution", "Member", "Batch", etc.
|
||
|
||
@Column(name = "entity_id")
|
||
private Long entityId;
|
||
|
||
@Column(name = "description", nullable = false)
|
||
private String description; // Human-readable: "25g Northern Lights an Max Mustermann ausgegeben"
|
||
|
||
@Column(name = "details", columnDefinition = "JSONB")
|
||
private String details; // JSON with before/after values
|
||
|
||
@Column(name = "ip_address")
|
||
private String ipAddress;
|
||
|
||
@Column(name = "created_at", nullable = false, updatable = false)
|
||
private Instant createdAt; // Server-generated, Europe/Berlin
|
||
|
||
@PrePersist
|
||
void prePersist() {
|
||
this.createdAt = Instant.now();
|
||
}
|
||
}
|
||
```
|
||
|
||
**AuditEventType enum:**
|
||
|
||
```java
|
||
public enum AuditEventType {
|
||
// Distributions
|
||
DISTRIBUTION_CREATED,
|
||
DISTRIBUTION_VOIDED,
|
||
|
||
// Members
|
||
MEMBER_CREATED,
|
||
MEMBER_UPDATED,
|
||
MEMBER_DEACTIVATED,
|
||
MEMBER_DELETED,
|
||
|
||
// Batches
|
||
BATCH_CREATED,
|
||
BATCH_RECALLED,
|
||
BATCH_DESTROYED,
|
||
|
||
// Stock
|
||
STOCK_ADJUSTED,
|
||
|
||
// Auth
|
||
USER_LOGIN,
|
||
USER_LOGOUT,
|
||
USER_LOGIN_FAILED,
|
||
|
||
// Staff
|
||
STAFF_INVITED,
|
||
STAFF_PERMISSIONS_CHANGED,
|
||
STAFF_REVOKED,
|
||
|
||
// DSGVO
|
||
CONSENT_ACCEPTED,
|
||
CONSENT_REVOKED,
|
||
DATA_EXPORT_REQUESTED,
|
||
DATA_ERASURE_REQUESTED,
|
||
|
||
// Billing
|
||
SUBSCRIPTION_CREATED,
|
||
SUBSCRIPTION_CANCELED,
|
||
PAYMENT_RECEIVED,
|
||
PAYMENT_FAILED,
|
||
|
||
// Grow
|
||
GROW_ENTRY_CREATED,
|
||
GROW_ENTRY_UPDATED,
|
||
HARVEST_LINKED_TO_BATCH
|
||
}
|
||
```
|
||
|
||
**AuditService (aspect-style):**
|
||
|
||
```java
|
||
@Service
|
||
@Slf4j
|
||
public class AuditService {
|
||
|
||
private final AuditEventRepository repository;
|
||
|
||
public void log(AuditEventType type, Long clubId, Long actorId, String actorName,
|
||
String entityType, Long entityId, String description, Object details) {
|
||
AuditEvent event = AuditEvent.builder()
|
||
.clubId(clubId)
|
||
.actorId(actorId)
|
||
.actorName(actorName)
|
||
.eventType(type)
|
||
.entityType(entityType)
|
||
.entityId(entityId)
|
||
.description(description)
|
||
.details(details != null ? objectMapper.writeValueAsString(details) : null)
|
||
.build();
|
||
repository.save(event);
|
||
log.debug("Audit: {} by {} — {}", type, actorName, description);
|
||
}
|
||
}
|
||
```
|
||
|
||
### 3.5 Grow Calendar Architecture (Phase 5)
|
||
|
||
**Entity:**
|
||
|
||
```java
|
||
@Entity
|
||
@Table(name = "grow_entry")
|
||
@Data @Builder @NoArgsConstructor @AllArgsConstructor
|
||
public class GrowEntry extends AbstractTenantEntity {
|
||
|
||
@Id
|
||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||
private Long id;
|
||
|
||
@ManyToOne(fetch = FetchType.LAZY)
|
||
@JoinColumn(name = "strain_id", nullable = false)
|
||
private Strain strain;
|
||
|
||
@Column(name = "stage", nullable = false)
|
||
@Enumerated(EnumType.STRING)
|
||
private GrowStage stage; // SEEDLING, VEGETATIVE, FLOWERING, HARVEST, DRYING, CURING
|
||
|
||
@Column(name = "start_date", nullable = false)
|
||
private LocalDate startDate;
|
||
|
||
@Column(name = "end_date")
|
||
private LocalDate endDate;
|
||
|
||
@Column(name = "plant_count")
|
||
private Integer plantCount;
|
||
|
||
@Column(name = "notes")
|
||
private String notes;
|
||
|
||
@ManyToOne(fetch = FetchType.LAZY)
|
||
@JoinColumn(name = "batch_id")
|
||
private Batch linkedBatch; // null until harvest → batch linking
|
||
|
||
@Column(name = "expected_yield_grams")
|
||
private BigDecimal expectedYieldGrams;
|
||
|
||
@Column(name = "actual_yield_grams")
|
||
private BigDecimal actualYieldGrams;
|
||
}
|
||
```
|
||
|
||
**GrowStage enum with colors:**
|
||
|
||
```java
|
||
public enum GrowStage {
|
||
SEEDLING("#4ade80"), // green-400
|
||
VEGETATIVE("#22c55e"), // green-500
|
||
FLOWERING("#a855f7"), // purple-500
|
||
HARVEST("#f59e0b"), // amber-500
|
||
DRYING("#78716c"), // stone-500
|
||
CURING("#d97706"); // amber-600
|
||
|
||
private final String color;
|
||
GrowStage(String color) { this.color = color; }
|
||
public String getColor() { return color; }
|
||
}
|
||
```
|
||
|
||
**FullCalendar integration:**
|
||
|
||
```typescript
|
||
// src/app/[locale]/(admin)/grow/page.tsx
|
||
import FullCalendar from "@fullcalendar/react"
|
||
import dayGridPlugin from "@fullcalendar/daygrid"
|
||
import interactionPlugin from "@fullcalendar/interaction"
|
||
|
||
export default function GrowPage() {
|
||
const { data: entries } = useGrowEntries()
|
||
|
||
const events = entries?.map(entry => ({
|
||
id: entry.id.toString(),
|
||
title: `${entry.strain.name} — ${entry.stage}`,
|
||
start: entry.startDate,
|
||
end: entry.endDate,
|
||
backgroundColor: STAGE_COLORS[entry.stage],
|
||
extendedProps: { entry }
|
||
}))
|
||
|
||
return (
|
||
<FullCalendar
|
||
plugins={[dayGridPlugin, interactionPlugin]}
|
||
initialView="dayGridMonth"
|
||
events={events}
|
||
editable={true}
|
||
selectable={true}
|
||
dateClick={handleDateClick}
|
||
eventClick={handleEventClick}
|
||
locale="de"
|
||
/>
|
||
)
|
||
}
|
||
```
|
||
|
||
### 3.6 WebSocket + Notifications Architecture (Phase 6)
|
||
|
||
**Backend (Spring WebSocket + STOMP):**
|
||
|
||
```java
|
||
@Configuration
|
||
@EnableWebSocketMessageBroker
|
||
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
|
||
|
||
@Override
|
||
public void configureMessageBroker(MessageBrokerRegistry config) {
|
||
config.enableSimpleBroker("/topic", "/queue");
|
||
config.setApplicationDestinationPrefixes("/app");
|
||
config.setUserDestinationPrefix("/user");
|
||
}
|
||
|
||
@Override
|
||
public void registerStompEndpoints(StompEndpointRegistry registry) {
|
||
registry.addEndpoint("/ws")
|
||
.setAllowedOriginPatterns("*")
|
||
.withSockJS();
|
||
}
|
||
}
|
||
```
|
||
|
||
**Notification events:**
|
||
|
||
```java
|
||
@Service
|
||
public class NotificationService {
|
||
|
||
private final SimpMessagingTemplate messagingTemplate;
|
||
|
||
public void notifyQuotaWarning(Long userId, int percentUsed) {
|
||
messagingTemplate.convertAndSendToUser(
|
||
userId.toString(),
|
||
"/queue/notifications",
|
||
new NotificationEvent("QUOTA_WARNING",
|
||
String.format("Kontingent zu %d%% ausgeschöpft", percentUsed))
|
||
);
|
||
}
|
||
|
||
public void notifyBatchRecall(Long clubId, String batchName) {
|
||
messagingTemplate.convertAndSend(
|
||
"/topic/club/" + clubId + "/notifications",
|
||
new NotificationEvent("BATCH_RECALL",
|
||
String.format("Charge '%s' wurde zurückgerufen!", batchName))
|
||
);
|
||
}
|
||
|
||
public void notifyNewDistribution(Long memberId, String strainName, BigDecimal amount) {
|
||
messagingTemplate.convertAndSendToUser(
|
||
memberId.toString(),
|
||
"/queue/notifications",
|
||
new NotificationEvent("DISTRIBUTION",
|
||
String.format("%.1fg %s wurde für dich ausgegeben", amount, strainName))
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
**PWA manifest (`public/manifest.json`):**
|
||
|
||
```json
|
||
{
|
||
"name": "CannaManage",
|
||
"short_name": "CannaManage",
|
||
"description": "Cannabis-Anbauvereinigung Management",
|
||
"start_url": "/",
|
||
"display": "standalone",
|
||
"background_color": "#0a0a0a",
|
||
"theme_color": "#16a34a",
|
||
"icons": [
|
||
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||
{ "src": "/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||
]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 4. Phase Details
|
||
|
||
### Phase 1: Production Deployment (IONOS VPS)
|
||
|
||
**Effort:** 3 days
|
||
|
||
| Step | Task | Files |
|
||
|------|------|-------|
|
||
| 1.1 | Configure IONOS VPS (existing plate-software.de), install Docker + Docker Compose + Nginx + Certbot | Server setup (manual) |
|
||
| 1.2 | Register domain, configure DNS A/AAAA records | DNS config (manual) |
|
||
| 1.3 | Create `docker-compose.prod.yml` with env vars, volumes, restart policies | `docker-compose.prod.yml` |
|
||
| 1.4 | Create `.env.prod.example` with all required env vars documented | `.env.prod.example` |
|
||
| 1.5 | Configure Nginx reverse proxy (TLS, HTTP/2, WebSocket, security headers, rate limiting) | Server: `/etc/nginx/sites-available/cannamanage.de` |
|
||
| 1.6 | Set up Let's Encrypt auto-renewal (certbot timer) | Server cron |
|
||
| 1.7 | Create backup script + cron job (daily pg_dump, 7-day rotation) | `scripts/backup.sh` |
|
||
| 1.8 | Set up GitHub Actions CI/CD (build → push GHCR → SSH deploy) | `.github/workflows/deploy.yml` |
|
||
| 1.9 | Install Uptime Kuma for health monitoring + alerting | Docker container on VPS |
|
||
| 1.10 | Add Spring Actuator production config (metrics, health, no info exposure) | `application-prod.properties` |
|
||
| 1.11 | Create `application-prod.properties` with production-safe defaults | `cannamanage-api/src/main/resources/application-prod.properties` |
|
||
|
||
**Acceptance Criteria:**
|
||
|
||
- [ ] `https://cannamanage.de` serves the frontend over HTTPS (A+ SSL Labs rating)
|
||
- [ ] Backend API reachable at `https://cannamanage.de/api/v1/actuator/health`
|
||
- [ ] `docker compose -f docker-compose.prod.yml up -d` starts all services cleanly
|
||
- [ ] Push to `main` triggers automatic deployment via GitHub Actions
|
||
- [ ] Daily backup cron creates compressed pg_dump, old backups rotated
|
||
- [ ] Uptime Kuma shows green status, alerts on downtime within 60s
|
||
- [ ] Security headers present (HSTS, X-Frame-Options, CSP basic)
|
||
- [ ] Rate limiting active (>30 req/s returns 429)
|
||
|
||
---
|
||
|
||
### Phase 2: DSGVO Consent Management
|
||
|
||
**Effort:** 3 days
|
||
|
||
| Step | Task | Files |
|
||
|------|------|-------|
|
||
| 2.1 | Create Flyway migration `V6__consent_and_audit.sql` (consent table + audit_event table) | `db/migration/V6__consent_and_audit.sql` |
|
||
| 2.2 | Create `Consent` entity + `ConsentType` enum + `ConsentRepository` | `cannamanage-domain/` |
|
||
| 2.3 | Create `ConsentService` (accept, revoke, check, list) | `cannamanage-service/` |
|
||
| 2.4 | Create `ConsentController` REST endpoints | `cannamanage-api/` |
|
||
| 2.5 | Create `DsgvoService` (data export ZIP, data erasure with anonymization) | `cannamanage-service/` |
|
||
| 2.6 | Create `DsgvoController` (GET /dsgvo/export, DELETE /dsgvo/erasure) | `cannamanage-api/` |
|
||
| 2.7 | Frontend: Consent modal/banner (shown on first login if not accepted) | `cannamanage-frontend/` |
|
||
| 2.8 | Frontend: /settings/privacy page (revoke consent, download data, request deletion) | `cannamanage-frontend/` |
|
||
| 2.9 | Create `Datenschutzerklärung` (Privacy Policy) static page | `cannamanage-frontend/` |
|
||
| 2.10 | Create `AGB` (Terms of Service) static page | `cannamanage-frontend/` |
|
||
| 2.11 | Integration test: consent flow (accept → access granted, revoke → access blocked) | Tests |
|
||
|
||
**Acceptance Criteria:**
|
||
|
||
- [ ] First login shows consent modal — cannot proceed without accepting
|
||
- [ ] "Ablehnen" logs out the user immediately
|
||
- [ ] Consent entity stores IP address, user agent, timestamp, version
|
||
- [ ] Consent audit log entry created on accept/revoke
|
||
- [ ] GET `/api/v1/dsgvo/export` returns ZIP with all user data (JSON format)
|
||
- [ ] DELETE `/api/v1/dsgvo/erasure` anonymizes user data, retains aggregates for legal compliance
|
||
- [ ] Privacy policy and ToS pages accessible without authentication
|
||
- [ ] Revoking consent blocks further app access until re-accepted
|
||
- [ ] Consent version tracked — new policy version re-prompts acceptance
|
||
|
||
---
|
||
|
||
### Phase 3: Stripe Integration (SEPA + PayPal + Card)
|
||
|
||
**Effort:** 4 days
|
||
|
||
| Step | Task | Files |
|
||
|------|------|-------|
|
||
| 3.1 | Add Stripe Java SDK dependency to `pom.xml` | `cannamanage-service/pom.xml` |
|
||
| 3.2 | Create `StripeService` (customer creation, checkout session, billing portal, webhook handling) | `cannamanage-service/` |
|
||
| 3.3 | Create `SubscriptionController` (create checkout, get status, open portal) | `cannamanage-api/` |
|
||
| 3.4 | Create `StripeWebhookController` (signature verification + event dispatch) | `cannamanage-api/` |
|
||
| 3.5 | Flyway migration: add Stripe columns to `club` table | `db/migration/V7__stripe_columns.sql` |
|
||
| 3.6 | Create Stripe Customer on club creation (onboarding hook) | `ClubService.java` modification |
|
||
| 3.7 | Implement subscription status enforcement (middleware: block API if subscription expired) | `SubscriptionFilter.java` |
|
||
| 3.8 | Frontend: `/settings/billing` page (plan display, payment method, invoices) | `cannamanage-frontend/` |
|
||
| 3.9 | Frontend: pricing cards component (Starter vs Pro comparison) | `cannamanage-frontend/` |
|
||
| 3.10 | Frontend: checkout redirect + success/cancel handling | `cannamanage-frontend/` |
|
||
| 3.11 | Configure Stripe webhook endpoint in Stripe Dashboard (test + live modes) | Manual (Stripe Dashboard) |
|
||
| 3.12 | Test mode E2E: create subscription, trigger webhook, verify access | Integration test |
|
||
|
||
**Acceptance Criteria:**
|
||
|
||
- [ ] Club owner can subscribe via SEPA Lastschrift (direct debit)
|
||
- [ ] Club owner can subscribe via PayPal or Card as alternatives
|
||
- [ ] 3-month free trial starts automatically for new clubs
|
||
- [ ] After trial expiry without payment → club access locked with "Please subscribe" message
|
||
- [ ] `invoice.paid` webhook extends access, stores payment in audit log
|
||
- [ ] `invoice.payment_failed` webhook shows warning banner to club owner
|
||
- [ ] `customer.subscription.deleted` webhook locks club access
|
||
- [ ] Billing portal link opens Stripe-hosted management (update card, cancel, invoices)
|
||
- [ ] `/settings/billing` shows current plan, next payment date, payment method
|
||
- [ ] Webhook endpoint validates Stripe signature (rejects spoofed events)
|
||
- [ ] All payment events logged in audit log
|
||
|
||
**Voraussetzung:** Auftragsverarbeitungsvertrag (AVV) mit Stripe abschließen (DSGVO Art. 28). Stripe bietet standardisiertes DPA unter stripe.com/de/legal/dpa.
|
||
|
||
---
|
||
|
||
### Phase 4: Immutable Audit Log
|
||
|
||
**Effort:** 2 days
|
||
|
||
| Step | Task | Files |
|
||
|------|------|-------|
|
||
| 4.1 | Create `AuditEvent` entity (with `@Immutable`) + `AuditEventType` enum | `cannamanage-domain/` |
|
||
| 4.2 | Create `AuditEventRepository` (read-only, no delete methods) | `cannamanage-service/` |
|
||
| 4.3 | Create `AuditService` (log method, used by all other services) | `cannamanage-service/` |
|
||
| 4.4 | Integrate AuditService into existing services (Distribution, Member, Batch, Stock, Auth, Staff) | Multiple service files |
|
||
| 4.5 | Create `AuditController` (GET paginated, filter by type/date/actor) | `cannamanage-api/` |
|
||
| 4.6 | Create audit PDF export endpoint (for Behörde inspections) | `cannamanage-api/` |
|
||
| 4.7 | Frontend: `/admin/audit-log` page (read-only table, filterable, searchable) | `cannamanage-frontend/` |
|
||
| 4.8 | Frontend: PDF export button on audit log page | `cannamanage-frontend/` |
|
||
| 4.9 | Ensure timestamps are server-generated (Europe/Berlin) and audit records cannot be modified | Entity + DB constraints |
|
||
| 4.10 | Unit tests for AuditService, integration test for immutability | Tests |
|
||
|
||
**Acceptance Criteria:**
|
||
|
||
- [ ] Every distribution, member change, batch action, login, and staff change creates an audit entry
|
||
- [ ] Audit entries are immutable (no UPDATE/DELETE — enforced by Hibernate `@Immutable` + DB trigger)
|
||
- [ ] `/admin/audit-log` page shows chronological event list with filters (type, date range, actor)
|
||
- [ ] PDF export generates a printable report for Behörde inspection
|
||
- [ ] Timestamps are server-generated in Europe/Berlin timezone (not client-submitted)
|
||
- [ ] Audit log includes DSGVO events (consent accept/revoke, data export, erasure)
|
||
- [ ] Audit log includes payment events (subscription created, payment received/failed)
|
||
- [ ] Search works across description field (full-text)
|
||
- [ ] Pagination works (default 50 per page)
|
||
|
||
**Database-level protection:**
|
||
|
||
```sql
|
||
REVOKE DELETE ON audit_events FROM cannamanage_app;
|
||
```
|
||
|
||
Only the DBA role can delete audit records — the application user has INSERT + SELECT only.
|
||
|
||
**Aufbewahrungsfrist:** 10 Jahre (KCanG-konform). Automatische Löschung nach 10 Jahren via Scheduled Task.
|
||
|
||
---
|
||
|
||
### Phase 5: Grow Calendar (FULL)
|
||
|
||
**Effort:** 5–6 days
|
||
|
||
| Step | Task | Files |
|
||
|------|------|-------|
|
||
| 5.1 | Flyway migration: `V8__grow_calendar.sql` (grow_entry + sensor_reading + photo_log + feeding_schedule tables) | `db/migration/V8__grow_calendar.sql` |
|
||
| 5.2 | Create `GrowEntry` entity + `GrowStage` enum | `cannamanage-domain/` |
|
||
| 5.3 | Create `SensorReading` entity (temp, humidity, timestamp, grow_entry_id) | `cannamanage-domain/` |
|
||
| 5.4 | Create `GrowPhoto` entity (url, caption, timestamp, grow_entry_id) | `cannamanage-domain/` |
|
||
| 5.5 | Create `FeedingSchedule` entity (nutrient, amount, frequency, grow_entry_id) | `cannamanage-domain/` |
|
||
| 5.6 | Create repositories for all grow entities | `cannamanage-service/` |
|
||
| 5.7 | Create `GrowCalendarService` (CRUD, stage transitions, batch linking) | `cannamanage-service/` |
|
||
| 5.8 | Create `SensorService` (record readings, aggregation, alerts on threshold breach) | `cannamanage-service/` |
|
||
| 5.9 | Create `GrowCalendarController` (GET/POST/PUT/DELETE + sensor + photo + feeding endpoints) | `cannamanage-api/` |
|
||
| 5.10 | Frontend: `/grow` page with FullCalendar (month view, color-coded stages) | `cannamanage-frontend/` |
|
||
| 5.11 | Frontend: create/edit grow entry dialog (strain, stage, dates, plant count, notes) | `cannamanage-frontend/` |
|
||
| 5.12 | Frontend: harvest → batch linking dialog (connect grow entry to existing batch) | `cannamanage-frontend/` |
|
||
| 5.13 | Frontend: grow entry detail panel (show full lifecycle of a grow) | `cannamanage-frontend/` |
|
||
| 5.14 | Frontend: sensor dashboard (temp/humidity charts, last 7d/30d, threshold alerts) | `cannamanage-frontend/` |
|
||
| 5.15 | Frontend: photo log gallery (upload, timeline view, captions) | `cannamanage-frontend/` |
|
||
| 5.16 | Frontend: feeding schedule editor (add nutrients, amounts, frequency, calendar overlay) | `cannamanage-frontend/` |
|
||
| 5.17 | Unit + integration tests for grow services | Tests |
|
||
|
||
**Acceptance Criteria:**
|
||
|
||
- [ ] FullCalendar renders grow entries as color-coded blocks (one color per stage)
|
||
- [ ] Click date → create new grow entry (select strain, stage, plant count)
|
||
- [ ] Drag to resize → adjusts end date
|
||
- [ ] Click entry → opens detail panel with edit/delete options
|
||
- [ ] Stage progression: Seedling → Vegetative → Flowering → Harvest → Drying → Curing
|
||
- [ ] Harvest stage allows linking to an existing batch (traceability: grow → batch → distribution)
|
||
- [ ] Calendar shows de locale (German month names, Monday start)
|
||
- [ ] Entries filtered by club (multi-tenant)
|
||
- [ ] Audit log entry for create/update/link operations
|
||
- [ ] **Sensors:** Record temp/humidity readings (manual entry or API endpoint for IoT devices)
|
||
- [ ] **Sensors:** Chart visualization (line chart, last 7d/30d toggle)
|
||
- [ ] **Sensors:** Configurable threshold alerts (e.g., temp > 30°C → notification)
|
||
- [ ] **Photos:** Upload photos per grow entry (max 10MB, JPEG/PNG)
|
||
- [ ] **Photos:** Gallery view with timeline and captions
|
||
- [ ] **Feeding:** Define feeding schedule (nutrient name, amount in ml/g, frequency)
|
||
- [ ] **Feeding:** Calendar overlay showing feeding days
|
||
|
||
---
|
||
|
||
### Phase 6: Notifications + PWA
|
||
|
||
**Effort:** 3 days
|
||
|
||
| Step | Task | Files |
|
||
|------|------|-------|
|
||
| 6.1 | Add Spring WebSocket + STOMP dependencies | `cannamanage-api/pom.xml` |
|
||
| 6.2 | Create `WebSocketConfig` (STOMP broker, SockJS fallback) | `cannamanage-api/` |
|
||
| 6.3 | Create `NotificationService` (quota warning, batch recall, distribution, payment) | `cannamanage-service/` |
|
||
| 6.4 | Integrate notifications into existing services (trigger on events) | Multiple services |
|
||
| 6.5 | Create `NotificationController` (GET unread count, mark as read) | `cannamanage-api/` |
|
||
| 6.6 | Frontend: WebSocket connection hook (SockJS client, auto-reconnect) | `cannamanage-frontend/` |
|
||
| 6.7 | Frontend: notification bell in header (unread badge count) | `cannamanage-frontend/` |
|
||
| 6.8 | Frontend: notification dropdown/panel (list of recent notifications) | `cannamanage-frontend/` |
|
||
| 6.9 | PWA: create `manifest.json` + icons (192px, 512px) | `cannamanage-frontend/public/` |
|
||
| 6.10 | PWA: create service worker (offline page, asset caching) | `cannamanage-frontend/public/sw.js` |
|
||
| 6.11 | PWA: install prompt (iOS/Android home-screen) | `cannamanage-frontend/` |
|
||
| 6.12 | Web Push: VAPID key generation, push subscription endpoint, opt-in toggle | Backend + Frontend |
|
||
|
||
**Acceptance Criteria:**
|
||
|
||
- [ ] Real-time notification when quota reaches 80% (appears in bell without page refresh)
|
||
- [ ] Real-time notification on batch recall (all affected members notified)
|
||
- [ ] Real-time notification on new distribution (member portal bell)
|
||
- [ ] Notification bell shows unread count badge
|
||
- [ ] Click bell → dropdown with notification list (mark as read)
|
||
- [ ] PWA installable on mobile (home screen icon, standalone mode)
|
||
- [ ] Offline page shown when network unavailable (instead of browser error)
|
||
- [ ] Web Push notifications (opt-in) delivered even when app is closed
|
||
- [ ] WebSocket reconnects automatically after network drop
|
||
- [ ] Notification events logged in audit log
|
||
|
||
---
|
||
|
||
### Phase 7: Launch Checklist + Documentation
|
||
|
||
**Effort:** 3 days
|
||
|
||
| Step | Task | Files |
|
||
|------|------|-------|
|
||
| 7.1 | Security scan (Snyk Code + SCA + SonarQube) — fix any Critical/High findings | Scan results |
|
||
| 7.2 | Performance test (k6 or artillery) — target: 100 concurrent users, <500ms p95 | `scripts/load-test.js` |
|
||
| 7.3 | Update wiki with Sprint 6 documentation | Wiki |
|
||
| 7.4 | Create landing page (`/`) with product description, features, screenshots | `cannamanage-frontend/` |
|
||
| 7.5 | Create pricing page (`/pricing`) with tier comparison + CTA buttons | `cannamanage-frontend/` |
|
||
| 7.6 | Create `Datenschutzerklärung` legal page (DSGVO compliant) | `cannamanage-frontend/` |
|
||
| 7.7 | Create `Impressum` legal page (required by German law) | `cannamanage-frontend/` |
|
||
| 7.8 | Create `AGB` (Terms of Service) legal page | `cannamanage-frontend/` |
|
||
| 7.9 | Create `robots.txt` + `sitemap.xml` for SEO | `cannamanage-frontend/public/` |
|
||
| 7.10 | Final smoke test on production (full user journey: register → subscribe → distribute → audit) | Manual |
|
||
| 7.11 | Set up error tracking (Sentry or similar — optional, can use Uptime Kuma logs) | Config |
|
||
|
||
**Acceptance Criteria:**
|
||
|
||
- [ ] Zero Critical/High security findings (Snyk + SonarQube clean)
|
||
- [ ] p95 latency < 500ms under 100 concurrent users
|
||
- [ ] Landing page renders with product description, screenshots, and CTA
|
||
- [ ] Pricing page shows Free Trial / Starter / Pro with feature comparison
|
||
- [ ] All legal pages present: Datenschutzerklärung, Impressum, AGB
|
||
- [ ] Full user journey works on production: signup → consent → subscribe → add member → distribute → audit log
|
||
- [ ] Wiki updated with Sprint 6 architecture and deployment docs
|
||
- [ ] `robots.txt` + `sitemap.xml` present for search engine indexing
|
||
- [ ] HTTPS everywhere (no mixed content)
|
||
|
||
---
|
||
|
||
## 5. Risk Assessment
|
||
|
||
| # | Risk | Probability | Impact | Mitigation |
|
||
|---|------|-------------|--------|------------|
|
||
| R1 | Stripe integration complexity (SEPA mandates, webhook reliability) | Medium | High | Start with test mode, use Stripe CLI for local webhook testing, extensive logging |
|
||
| R2 | DSGVO compliance gaps (incomplete data export, retention conflicts) | Medium | High | Legal review of Datenschutzerklärung before launch, document what data is retained and why |
|
||
| R3 | Production deployment issues (SSL, DNS propagation, Docker networking) | Low | Medium | Test with staging subdomain first (`staging.cannamanage.de`), blue-green deploy |
|
||
| R4 | WebSocket scalability on single VPS | Low | Low | IONOS VPS handles 1000+ concurrent WS connections easily for MVP; scale later if needed |
|
||
| R5 | Stripe webhook delivery failures (lost events) | Low | High | Implement idempotent webhook handler + periodic reconciliation job (check subscription status every 6h) |
|
||
| R6 | Backup corruption / restore failure | Low | Critical | Test restore procedure monthly, verify backup integrity with checksum |
|
||
| R7 | FullCalendar bundle size (large JS library) | Low | Low | Dynamic import, lazy-load only on `/grow` route |
|
||
| R8 | German legal requirements (Impressum, Datenschutz) | Medium | High | Consult template generators (e.g., e-recht24.de), have Patrick review before launch |
|
||
|
||
---
|
||
|
||
## 6. Database Migrations Summary
|
||
|
||
| Migration | Phase | Description |
|
||
|-----------|-------|-------------|
|
||
| `V6__consent_and_audit.sql` | 2 + 4 | `consent` table + `audit_event` table |
|
||
| `V7__stripe_columns.sql` | 3 | Add `stripe_customer_id`, `subscription_status`, `subscription_plan`, `trial_ends_at` to `club` |
|
||
| `V8__grow_calendar.sql` | 5 | `grow_entry` table with strain/stage/dates/batch FK |
|
||
| `V9__notifications.sql` | 6 | `notification` table (persisted notifications for history) |
|
||
|
||
---
|
||
|
||
## 7. New Dependencies
|
||
|
||
### Backend (Maven)
|
||
|
||
| Dependency | Version | Purpose | Phase |
|
||
|------------|---------|---------|-------|
|
||
| `com.stripe:stripe-java` | 26.x | Stripe API SDK | 3 |
|
||
| `org.springframework.boot:spring-boot-starter-websocket` | (managed) | WebSocket + STOMP | 6 |
|
||
|
||
### Frontend (npm)
|
||
|
||
| Dependency | Version | Purpose | Phase |
|
||
|------------|---------|---------|-------|
|
||
| `@stripe/stripe-js` | ^4.x | Stripe.js for Payment Element | 3 |
|
||
| `@fullcalendar/react` | ^6.x | Calendar component | 5 |
|
||
| `@fullcalendar/daygrid` | ^6.x | Month/week grid view | 5 |
|
||
| `@fullcalendar/interaction` | ^6.x | Drag/drop, click events | 5 |
|
||
| `@stomp/stompjs` | ^7.x | STOMP over WebSocket | 6 |
|
||
| `sockjs-client` | ^1.x | SockJS fallback transport | 6 |
|
||
| `next-pwa` | ^5.x | PWA plugin for Next.js (or `@ducanh2912/next-pwa`) | 6 |
|
||
| `web-push` | ^3.x | VAPID push (if server-side push needed) | 6 |
|
||
|
||
---
|
||
|
||
## 8. Open Questions (for Patrick's return)
|
||
|
||
| # | Question | Answer | Status |
|
||
|---|----------|--------|--------|
|
||
| Q1 | Domain? | Subdomain on existing `plate-software.de` (e.g., `cannamanage.plate-software.de`) — IONOS DNS | ✅ Resolved |
|
||
| Q2 | Server? | IONOS existing VPS (8 GB RAM, ~2 cores) — already running, deploy alongside existing services | ✅ Resolved |
|
||
| Q3 | Stripe account? | Fresh Stripe account for CannaManage (test mode first, then live) | ✅ Resolved |
|
||
| Q4 | Pricing model? | 3-month free trial → Tiered: Starter (≤30 members, €19/mo), Pro (≤100 members, €49/mo), Enterprise (custom) | ✅ Resolved |
|
||
| Q5 | CI/CD? | Gitea Actions (self-hosted on plate-software.de) | ✅ Resolved |
|
||
| Q6 | Backup strategy? | 7 days daily + 4 weeks weekly retention | ✅ Resolved |
|
||
| Q7 | Grow calendar scope? | FULL — sensors (temp/humidity), photo log, feeding schedule, not just date tracking | ✅ Resolved |
|
||
| Q8 | PWA scope? | Basic in Sprint 6 — manifest + offline page + install prompt | ✅ Resolved |
|
||
|
||
---
|
||
|
||
## 9. Sprint 5 Backlog Items Addressed
|
||
|
||
| Sprint 5 Backlog Item | Sprint 6 Phase | Status |
|
||
|-----------------------|----------------|--------|
|
||
| Notification System (email + in-app + push) | Phase 6 | ✅ Planned |
|
||
| PWA Manifest + Service Worker | Phase 6 | ✅ Planned |
|
||
| Club Settings UI | — | ❌ Deferred (Sprint 7) |
|
||
| Inspector Read-Only Mode | — | ❌ Deferred (Sprint 7) |
|
||
| Monthly Report Auto-Sealing | — | ❌ Deferred (Sprint 7) |
|
||
| Cryptographic Hash Chain | — | ❌ Deferred (Sprint 7) |
|
||
| 2FA (TOTP) | — | ❌ Deferred (Sprint 7) |
|
||
| Micro-interactions (Framer Motion) | — | ❌ Deferred (Sprint 7) |
|
||
|
||
---
|
||
|
||
## 10. Success Criteria (Sprint 6 Complete)
|
||
|
||
The sprint is complete when:
|
||
|
||
1. **Production live** — `cannamanage.plate-software.de` serves the full app over HTTPS with automated Gitea Actions CI/CD
|
||
2. **DSGVO compliant** — consent management, data export, data deletion all functional
|
||
3. **Payments working** — clubs can subscribe via SEPA/PayPal/Card, billing portal accessible
|
||
4. **Audit trail** — every significant action logged immutably, exportable as PDF
|
||
5. **Grow tracking** — full grow calendar with stage management, batch linking, sensor data, photo log, and feeding schedules
|
||
6. **Real-time notifications** — WebSocket bell icon + PWA installable
|
||
7. **Launch-ready** — legal pages present, security scanned, performance tested
|
||
|
||
---
|
||
|
||
## References
|
||
|
||
- Sprint 5 Plan: `docs/sprint-5/cannamanage-sprint5-plan.md`
|
||
- Sprint 5 Backlog: `docs/sprint-5/cannamanage-sprint5-backlog.md`
|
||
- Sprint 4 Plan: `docs/sprint-4/cannamanage-sprint4-plan.md`
|
||
- Stripe SEPA Docs: https://stripe.com/docs/payments/sepa-debit
|
||
- Stripe Billing Portal: https://stripe.com/docs/billing/subscriptions/integrating-customer-portal
|
||
- FullCalendar React: https://fullcalendar.io/docs/react
|
||
- Spring WebSocket: https://docs.spring.io/spring-framework/reference/web/websocket.html
|
||
- Next.js PWA: https://github.com/nicolo-ribaudo/next-pwa
|