54 KiB
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):
# /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:
# 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:
#!/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):
# .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:
@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):
-- 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):
@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):
@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:
@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:
@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):
// 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):
// 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)
@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:
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):
@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:
@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:
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:
// 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):
@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:
@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):
{
"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.deserves 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 -dstarts all services cleanly- Push to
maintriggers 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/exportreturns ZIP with all user data (JSON format) - DELETE
/api/v1/dsgvo/erasureanonymizes 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.paidwebhook extends access, stores payment in audit loginvoice.payment_failedwebhook shows warning banner to club ownercustomer.subscription.deletedwebhook locks club access- Billing portal link opens Stripe-hosted management (update card, cancel, invoices)
/settings/billingshows 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-logpage 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:
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.xmlpresent 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:
- Production live —
cannamanage.plate-software.deserves the full app over HTTPS with automated Gitea Actions CI/CD - DSGVO compliant — consent management, data export, data deletion all functional
- Payments working — clubs can subscribe via SEPA/PayPal/Card, billing portal accessible
- Audit trail — every significant action logged immutably, exportable as PDF
- Grow tracking — full grow calendar with stage management, batch linking, sensor data, photo log, and feeding schedules
- Real-time notifications — WebSocket bell icon + PWA installable
- 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