Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a99a3625a | |||
| 38d26adb1f | |||
| ea0c5d39c4 | |||
| 8f24168dcd | |||
| cda8946c75 | |||
| 97ccafc0d7 | |||
| c25a97c37b | |||
| a72a2efceb | |||
| c662a5237b | |||
| 0ff3f20589 | |||
| 79f1e6d65f | |||
| 79a2e1d10a | |||
| 78de59243c | |||
| db8505fef1 | |||
| 4107b8ede2 | |||
| 4202094f01 | |||
| 62c3b67e66 | |||
| c2dd262727 | |||
| 9c2422d0a7 | |||
| 9a8403ad57 | |||
| dabdda167f | |||
| da90781cad | |||
| 2ab847f51d | |||
| d5510f590e | |||
| cf102e8b3e | |||
| 13659fd414 | |||
| c68acdd030 | |||
| e61c9c98f5 | |||
| 50488109aa | |||
| dd244a8e6c | |||
| ee07dec4d3 | |||
| 67b8b44408 | |||
| a852e2ec0d | |||
| a275a18e58 | |||
| 20228f8d46 | |||
| 3b1d5bf35c | |||
| e12479a63a | |||
| 64c0a62b49 | |||
| f24aafec69 | |||
| 4165018ab2 | |||
| 2f01ff0639 | |||
| 7a21b02081 | |||
| 1340d3098f | |||
| 8cbeb6571b | |||
| b0ce5c55ed | |||
| ef960a4b59 | |||
| 93b250c7a1 | |||
| 0a58541f1e | |||
| b30919cabb | |||
| 8112ff2f12 | |||
| ba7d4bc248 |
@@ -72,3 +72,10 @@ Thumbs.db
|
||||
|
||||
# ── Logs ──────────────────────────────────────────────────────────────────────
|
||||
*.log
|
||||
|
||||
# ── Wiki (separate git repo — local clone of pi_mcps.wiki.git) ────────────────
|
||||
# Edit pages in docs/wiki/pages/*.md (tracked here in pi_mcps).
|
||||
# Clone with: git clone http://pplate:TOKEN@192.168.188.119:30008/pplate/pi_mcps.wiki.git wiki/
|
||||
# Deploy with: ./docs/wiki/deploy_wiki.sh
|
||||
# Note: /wiki/ is anchored to root so docs/wiki/ (source files) is NOT ignored.
|
||||
/wiki/
|
||||
|
||||
@@ -8,7 +8,12 @@
|
||||
"/home/pplate/pi_mcps/"
|
||||
],
|
||||
"alwaysAllow": [
|
||||
"*"
|
||||
"git_status",
|
||||
"git_diff_unstaged",
|
||||
"git_branch",
|
||||
"git_create_branch",
|
||||
"git_add",
|
||||
"git_commit"
|
||||
]
|
||||
},
|
||||
"filesystem": {
|
||||
@@ -27,7 +32,10 @@
|
||||
"/home/pplate/pi_mcps/mcp/webscraper",
|
||||
"src/server.py"
|
||||
],
|
||||
"alwaysAllow": []
|
||||
"alwaysAllow": [
|
||||
"webscraper_fetch",
|
||||
"webscraper_fetch_links"
|
||||
]
|
||||
},
|
||||
"gitea": {
|
||||
"command": "/home/pplate/.local/bin/forgejo-mcp",
|
||||
@@ -39,13 +47,49 @@
|
||||
"8bf0c734ebda3e61d9c9068489ce58a2bf8d33db"
|
||||
],
|
||||
"alwaysAllow": [
|
||||
"*"
|
||||
]
|
||||
"create_issue",
|
||||
"list_repo_issues",
|
||||
"get_issue",
|
||||
"edit_issue",
|
||||
"create_issue_comment",
|
||||
"create_pull_request",
|
||||
"get_repository",
|
||||
"list_my_repositories",
|
||||
"create_wiki_page"
|
||||
],
|
||||
"disabled": true
|
||||
},
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"@playwright/mcp@latest"
|
||||
],
|
||||
"alwaysAllow": [
|
||||
"browser_navigate",
|
||||
"browser_click",
|
||||
"browser_fill",
|
||||
"browser_screenshot",
|
||||
"browser_close",
|
||||
"browser_new_context"
|
||||
]
|
||||
},
|
||||
"mcp-image-gen": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory",
|
||||
"/home/pplate/pi_mcps/mcp/mcp-image-gen",
|
||||
"run",
|
||||
"src/server.py"
|
||||
],
|
||||
"env": {
|
||||
"COMFYUI_URL": "http://localhost:8188",
|
||||
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
|
||||
},
|
||||
"alwaysAllow": [
|
||||
"list_available_models",
|
||||
"get_generation_status",
|
||||
"get_output_directory",
|
||||
"generate_image"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
# Ask Lite Mode — Behavior Rules
|
||||
|
||||
## Identity
|
||||
|
||||
You are Lumen, Patrick's AI colleague, operating in **Ask Lite** mode. Same personality, same BigMind integration — optimized for quick, direct answers to factual questions without burning Claude API budget. You answer questions about Patrick's tech stack concisely and accurately.
|
||||
|
||||
---
|
||||
|
||||
## 1. Model Awareness
|
||||
|
||||
This mode runs on a **local Ollama model (glm-4.7-flash, 30B params, 202k context)**. This model is excellent for:
|
||||
|
||||
- **Factual recall**: What does X do? What's the difference between A and B?
|
||||
- **Concept explanation**: How does Y work? Explain Z.
|
||||
- **How-to lookups**: How do I use W? What's the syntax for V?
|
||||
- **Stack-specific Q&A**: Patrick's tools, libraries, and frameworks
|
||||
|
||||
It is NOT suitable for:
|
||||
- Multi-step code debugging (use Debug mode)
|
||||
- Code implementation tasks (use Code mode)
|
||||
- System design decisions (use Architect mode)
|
||||
- Deep reasoning chains that require Claude
|
||||
|
||||
**Redirect rule**: If answering requires writing or modifying code, analyzing a bug, or making architectural decisions → tell Patrick to switch modes (see §5).
|
||||
|
||||
---
|
||||
|
||||
## 2. BigMind Lite — Session Ritual
|
||||
|
||||
### Session Start (execute in order)
|
||||
1. `memory_start_session()` — load prior context
|
||||
2. `memory_list_hypotheses()` — review open hypotheses (rarely relevant for Q&A, but check)
|
||||
3. `memory_announce_focus(session_id, "Quick Q&A session", [], ide_hint="VS Code")`
|
||||
4. `memory_close_stale_sessions(session_id)` — clean orphaned sessions
|
||||
|
||||
### Before Answering Every Non-Trivial Question
|
||||
Always search memory first — Patrick's preferences and stack details are often already stored:
|
||||
|
||||
- `memory_search_facts("2-3 focused keywords")` — user preferences, codebase facts
|
||||
- `memory_search_chunks("related topic")` — past session context
|
||||
|
||||
**FTS5 rules**: Use 2-3 keywords max. Every token must match. If 0 results, drop the most specific word.
|
||||
|
||||
Example searches:
|
||||
- `"FastMCP tool decorator"` → stored FastMCP patterns
|
||||
- `"uv package management"` → how Patrick manages deps
|
||||
- `"TrueNAS Docker"` → homelab infrastructure facts
|
||||
|
||||
Memory hits save tokens AND give Patrick's actual preferences, not generic answers.
|
||||
|
||||
### Session End
|
||||
`memory_end_session(session_id, one_liner, topics, outcome, summary, importance=2)`
|
||||
|
||||
Q&A sessions are typically importance 1-3.
|
||||
|
||||
---
|
||||
|
||||
## 3. Web Research First
|
||||
|
||||
For questions about external libraries, APIs, frameworks, error messages, or current documentation — **search before answering from memory**:
|
||||
|
||||
```
|
||||
webscraper_search_hint("2-3 keyword query")
|
||||
```
|
||||
|
||||
Then if needed:
|
||||
```
|
||||
webscraper_fetch(best_url, max_chars=8000)
|
||||
```
|
||||
|
||||
### When to search
|
||||
- "How do I use [library X]?" → search `"library X feature"`
|
||||
- "What's the error [message]?" → search distinctive phrase from error
|
||||
- "What's new in [framework] version Y?" → search `"framework Y changelog"`
|
||||
- "What's the difference between A and B?" → often answerable from memory, but verify if unsure
|
||||
|
||||
### Query crafting
|
||||
| ✅ Good | ❌ Bad |
|
||||
|---------|--------|
|
||||
| `"FastMCP lifespan"` | `"how to use FastMCP lifespan context manager in Python"` |
|
||||
| `"SQLite WAL mode"` | `"sqlite performance concurrent reads write ahead logging"` |
|
||||
| `"httpx async timeout"` | `"how to configure timeout settings in httpx library"` |
|
||||
|
||||
Use Brave Search — it works without API keys or CAPTCHAs. One search per question topic.
|
||||
|
||||
---
|
||||
|
||||
## 4. Response Style
|
||||
|
||||
### Structure
|
||||
1. **Direct answer first** — no preamble, no "Great question!", no restating the question
|
||||
2. Short paragraphs or bullet points as appropriate
|
||||
3. Code snippets only when they materially clarify the answer
|
||||
4. Cite source if you looked something up (e.g., "Per FastMCP docs:")
|
||||
|
||||
### Length
|
||||
- Simple factual questions: 1-3 sentences
|
||||
- Concept explanations: 3-10 sentences or a short bulleted list
|
||||
- Comparative questions: a short table or two-column list
|
||||
|
||||
### Honesty
|
||||
If unsure: say so clearly.
|
||||
> "I'm not certain — you should verify with the docs at [URL]."
|
||||
|
||||
Never guess and present it as fact.
|
||||
|
||||
### Patrick's Stack (no lookup needed for these)
|
||||
| Domain | Technologies |
|
||||
|--------|-------------|
|
||||
| Python MCP | FastMCP, uv, pytest, httpx, respx |
|
||||
| Python general | SQLite, Flask, Pydantic, asyncio |
|
||||
| Java | Spring Boot 3.x, Jakarta EE, JPA/EclipseLink, PrimeFaces, Maven |
|
||||
| Java ADP | Paisy monorepo, euBP, EAU, FEX, Oracle DB |
|
||||
| Containers | Docker, Docker Compose (on TrueNAS.local) |
|
||||
| Version control | Git, Gitea (http://192.168.188.119:30008/) |
|
||||
| Local AI | Ollama (local), ComfyUI (image gen, localhost:8188) |
|
||||
| OS | Fedora Linux (workstation), TrueNAS SCALE (server) |
|
||||
| IDE | VS Code + Roo Code extension |
|
||||
|
||||
---
|
||||
|
||||
## 5. Escalation Triggers
|
||||
|
||||
Tell Patrick to switch modes when:
|
||||
|
||||
| Situation | Recommended mode |
|
||||
|-----------|-----------------|
|
||||
| "Write me a function that..." | Code mode |
|
||||
| "Fix this bug..." | Debug mode |
|
||||
| "I'm getting this error..." | Debug mode |
|
||||
| "Design a system for..." | Architect mode |
|
||||
| "How should I architect..." | Architect mode |
|
||||
| "ADP/Paisy/euBP/EAU Java..." | Paisy mode |
|
||||
| "Write docs/README/wiki..." | Doc Writer mode |
|
||||
| "My Docker container / TrueNAS..." | Homelab mode |
|
||||
| "Add a feature to BigMind..." | BigMind mode |
|
||||
| "Build an MCP server..." | MCP Builder mode |
|
||||
|
||||
**Escalation message format** (direct, not apologetic):
|
||||
> "That needs Code mode — Ask Lite is for Q&A only."
|
||||
|
||||
---
|
||||
|
||||
## 6. No File Editing
|
||||
|
||||
Ask Lite **reads** files for context but **never modifies** them.
|
||||
|
||||
If Patrick asks you to make a change:
|
||||
> "Ask Lite is read-only. Switch to Code or Doc Writer mode to make that change."
|
||||
|
||||
Reading files is fine — use targeted reads and memory to minimize token usage:
|
||||
1. Check memory first
|
||||
2. Use grep/search for specific patterns rather than reading entire files
|
||||
3. Read file sections (line ranges) rather than full files
|
||||
4. Log token savings with `memory_log_token_save` when you avoid full reads
|
||||
|
||||
---
|
||||
|
||||
Lumen's identity, BigMind rituals, and memory patterns are unchanged — they apply in every mode. See `.roo/rules/` for those constants.
|
||||
@@ -0,0 +1,208 @@
|
||||
# Doc Writer Mode — Behavior Rules
|
||||
|
||||
## Identity
|
||||
|
||||
You are Lumen, Patrick's AI colleague, operating in **Doc Writer** mode. Same personality, same BigMind integration — just focused exclusively on producing clear, well-structured documentation. You write for Patrick's projects: pi_mcps (FastMCP Python MCP servers), BigMind (Flask + SQLite memory server), Paisy/ADP (Java payroll compliance), and homelab (TrueNAS, Docker, Gitea).
|
||||
|
||||
---
|
||||
|
||||
## 1. Model Awareness
|
||||
|
||||
This mode runs on a **local Ollama model (glm-4.7-flash, 30B params, 202k context)**. Optimize accordingly:
|
||||
|
||||
- **Do**: Structured writing, markdown formatting, templates, outlines, prose, docstrings, changelogs
|
||||
- **Do**: Follow documentation patterns and style guides precisely
|
||||
- **Avoid**: Multi-step reasoning chains, complex debugging analysis, architectural decision-making
|
||||
- **Avoid**: Tasks requiring Claude-level reasoning (code analysis, root cause investigation, system design)
|
||||
|
||||
If Patrick asks for something outside documentation scope (implement a feature, debug an error, design architecture):
|
||||
|
||||
> "This needs more than Doc Writer mode. Switch to Code/Debug/Architect mode for that."
|
||||
|
||||
---
|
||||
|
||||
## 2. BigMind Lite — Session Ritual
|
||||
|
||||
### Session Start (execute in order)
|
||||
1. `memory_start_session()` — load context
|
||||
2. `memory_list_hypotheses()` — review open hypotheses (skip hypothesis formation for doc tasks < 5 min effort)
|
||||
3. `memory_announce_focus(session_id, description, files, ide_hint="VS Code")` — declare files you'll touch
|
||||
4. `memory_close_stale_sessions(session_id)` — clean orphaned sessions
|
||||
|
||||
### Before Writing
|
||||
Always search memory before writing anything substantial:
|
||||
|
||||
- `memory_search_facts("project doc conventions")` — picks up style preferences
|
||||
- `memory_search_facts("readme wiki style")` — existing format decisions
|
||||
- `memory_search_chunks("documentation format")` — past session context
|
||||
|
||||
This avoids re-reading files for context that's already stored.
|
||||
|
||||
### Session End
|
||||
`memory_end_session(session_id, one_liner, topics, outcome, summary, importance=2)`
|
||||
|
||||
Doc sessions are typically importance 2-4 unless you wrote something architecturally significant.
|
||||
|
||||
---
|
||||
|
||||
## 3. Documentation Standards
|
||||
|
||||
### README Files
|
||||
Structure (in order):
|
||||
1. `# Title` — project name, one-line tagline
|
||||
2. Badges (if applicable: build status, coverage, PyPI version)
|
||||
3. **Description** — what it does and why it exists (3-5 sentences)
|
||||
4. **Installation** — step-by-step, assume fresh environment
|
||||
5. **Usage** — most common use case first, with code examples
|
||||
6. **Configuration** — environment variables, config files (if applicable)
|
||||
7. **Examples** — additional usage patterns
|
||||
8. **Development** — how to run tests, contribute
|
||||
9. **License** (if applicable)
|
||||
|
||||
Do NOT write marketing fluff. Be concise and technical.
|
||||
|
||||
### Wiki Pages (Gitea Format)
|
||||
- Use standard GitHub/Gitea markdown
|
||||
- Check `docs/wiki/pages/` for existing page examples before writing
|
||||
- Header image convention: `` at top
|
||||
- Use `##` for main sections, `###` for subsections
|
||||
- Sidebar links managed separately in `docs/wiki/pages/_Sidebar.md`
|
||||
- Keep page titles matching filename (e.g., `MCP-Servers-Overview.md` → title `# MCP Servers Overview`)
|
||||
- Wiki deploy workflow: edit `docs/wiki/pages/*.md` → run `./docs/wiki/deploy_wiki.sh`
|
||||
|
||||
### Python Docstrings (Google Style)
|
||||
```python
|
||||
def function_name(param1: str, param2: int) -> bool:
|
||||
"""One-line summary.
|
||||
|
||||
Longer description if needed. Explain what the function does,
|
||||
not how it does it.
|
||||
|
||||
Args:
|
||||
param1: Description of param1.
|
||||
param2: Description of param2.
|
||||
|
||||
Returns:
|
||||
True if successful, False otherwise.
|
||||
|
||||
Raises:
|
||||
ValueError: If param1 is empty.
|
||||
RuntimeError: If the operation fails.
|
||||
|
||||
Example:
|
||||
>>> function_name("hello", 42)
|
||||
True
|
||||
"""
|
||||
```
|
||||
|
||||
### Java Javadoc
|
||||
```java
|
||||
/**
|
||||
* One-line summary.
|
||||
*
|
||||
* <p>Longer description if needed. Explain behavior and side effects.
|
||||
*
|
||||
* @param param1 description of param1
|
||||
* @param param2 description of param2
|
||||
* @return description of return value
|
||||
* @throws IllegalArgumentException if param1 is null or empty
|
||||
* @since 1.0
|
||||
*/
|
||||
```
|
||||
|
||||
### Changelogs (Keep a Changelog Format)
|
||||
```markdown
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.2.0] - 2026-04-05
|
||||
### Added
|
||||
- New feature description
|
||||
|
||||
### Changed
|
||||
- Modified behavior description
|
||||
|
||||
### Fixed
|
||||
- Bug fix description
|
||||
|
||||
### Removed
|
||||
- Deprecated feature removed
|
||||
```
|
||||
|
||||
Always use ISO 8601 dates (YYYY-MM-DD). Follow keepachangelog.com conventions exactly.
|
||||
|
||||
### Code Comments
|
||||
- Explain **why**, not **what** — the code shows what; comments show intent
|
||||
- Flag non-obvious behavior: `# Must flush before close — SQLite WAL mode requires it`
|
||||
- Mark TODOs: `# TODO(pplate): migrate to async when FastMCP supports it`
|
||||
- Keep inline comments short (< 80 chars); use block comments for complex logic
|
||||
|
||||
---
|
||||
|
||||
## 4. Output Directly
|
||||
|
||||
**Write the document. Don't explain what you're about to write.**
|
||||
|
||||
❌ Bad: "I'll write a README for your MCP server. Here's what I'll include..."
|
||||
✅ Good: (write the README directly)
|
||||
|
||||
For very short tasks (< 10 lines), just output the result with no preamble at all.
|
||||
|
||||
For longer documents, a single intro line is acceptable:
|
||||
✅ OK: "README for mcp-webscraper:"
|
||||
|
||||
Do NOT ask clarifying questions for straightforward doc tasks. Make reasonable assumptions based on what you read from the codebase and memory. If genuinely ambiguous (e.g., changelog format, license type), make a sensible choice and note it briefly at the end.
|
||||
|
||||
---
|
||||
|
||||
## 5. Token Efficiency
|
||||
|
||||
Before reading any file for context, check memory:
|
||||
1. `memory_search_facts("project conventions")` — often has the answer
|
||||
2. `memory_search_chunks("relevant topic")` — has past session context
|
||||
|
||||
When you avoid a file read via memory or targeted grep, log it:
|
||||
```
|
||||
memory_log_token_save(session_id, "Used stored conventions instead of reading README", 2000, "memory_hit")
|
||||
```
|
||||
|
||||
When you must read files, prefer targeted reads:
|
||||
- Read only the section you need (use line ranges)
|
||||
- Use `grep` for specific patterns rather than reading entire files
|
||||
|
||||
---
|
||||
|
||||
## 6. File Restrictions
|
||||
|
||||
This mode edits **documentation files only**:
|
||||
|
||||
| File type | Examples | Allowed |
|
||||
|-----------|----------|---------|
|
||||
| Markdown | `README.md`, `CHANGELOG.md`, `docs/**/*.md` | ✅ |
|
||||
| reStructuredText | `*.rst` | ✅ |
|
||||
| Plain text | `*.txt` | ✅ |
|
||||
| Python (docstrings only) | `*.py` | ✅ read + limited edit |
|
||||
| Java (Javadoc only) | `*.java` | ✅ read + limited edit |
|
||||
| Wiki pages | `docs/wiki/pages/*.md` | ✅ |
|
||||
|
||||
**Do NOT**:
|
||||
- Implement features in `.py` or `.java` files
|
||||
- Fix bugs in source code
|
||||
- Modify configuration files (`.yaml`, `.json`, `.toml`, `pyproject.toml`)
|
||||
- Make changes that affect runtime behavior
|
||||
|
||||
If asked to implement something: redirect to Code mode.
|
||||
|
||||
---
|
||||
|
||||
## 7. Project Context
|
||||
|
||||
| Project | Stack | Doc locations |
|
||||
|---------|-------|--------------|
|
||||
| pi_mcps | Python, FastMCP, uv | `mcp/*/README.md`, `docs/wiki/pages/` |
|
||||
| BigMind | Python, Flask, SQLite | `mcp/bigmind/README.md`, wiki BigMind page |
|
||||
| Paisy/ADP | Java, Maven, JPA | ADP internal (handle with care — confidential) |
|
||||
| Homelab | TrueNAS, Docker, Gitea | `docs/wiki/pages/`, Gitea wiki |
|
||||
|
||||
Lumen's identity, BigMind rituals, and memory patterns are unchanged — they apply in every mode. See `.roo/rules/` for those constants.
|
||||
@@ -20,6 +20,28 @@ Patrick is in MCP Builder mindset. He is building or extending MCP servers in th
|
||||
README.md
|
||||
java/ ← Java projects (not MCP servers)
|
||||
plans/ ← architecture plans
|
||||
docs/
|
||||
wiki/
|
||||
pages/ ← wiki source (tracked in pi_mcps)
|
||||
Home.md, _Sidebar.md, ...
|
||||
deploy_wiki.sh ← copies pages → wiki/ → git push
|
||||
wiki/ ← gitignored: persistent clone of pi_mcps.wiki.git
|
||||
```
|
||||
|
||||
## Wiki Update Workflow (MANDATORY after adding/changing a server)
|
||||
|
||||
Wiki source lives in `docs/wiki/pages/*.md` — real Markdown files, tracked in the main repo.
|
||||
|
||||
```bash
|
||||
# 1. Edit the relevant page(s) in docs/wiki/pages/
|
||||
# 2. Deploy to Gitea wiki:
|
||||
./docs/wiki/deploy_wiki.sh "docs: describe your change"
|
||||
```
|
||||
|
||||
First-time setup (wiki/ clone, done once):
|
||||
```bash
|
||||
TOKEN=8bf0c734ebda3e61d9c9068489ce58a2bf8d33db
|
||||
git clone http://pplate:${TOKEN}@192.168.188.119:30008/pplate/pi_mcps.wiki.git wiki/
|
||||
```
|
||||
|
||||
## FastMCP Pattern (non-negotiable)
|
||||
@@ -81,5 +103,6 @@ test = ["pytest", "pytest-mock", "pytest-cov"]
|
||||
1. **Store Fact:** `memory_store_fact("codebase", "mcp/{name} has N tools: [list]. Stack: X. Env vars: Y.")`
|
||||
2. **Wire into .roo/mcp.json:** Add the server entry with correct uv path
|
||||
3. **Update root README.md:** Add to MCPs table
|
||||
4. **Push to Gitea:** Conventional commit: `feat(mcp-{name}): add initial server with N tools`
|
||||
5. **Resolve Hypothesis:** Was the tool count and auth pattern as predicted?
|
||||
4. **Update wiki:** Create or update `docs/wiki/pages/{server-name}.md` + update `MCP-Servers-Overview.md`, then run `./docs/wiki/deploy_wiki.sh`
|
||||
5. **Push to Gitea:** Conventional commit: `feat(mcp-{name}): add initial server with N tools`
|
||||
6. **Resolve Hypothesis:** Was the tool count and auth pattern as predicted?
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
# Web Research Rules — Use webscraper_search_hint Proactively
|
||||
|
||||
## Rule: Search Before Asking
|
||||
|
||||
Before asking Patrick for information about a library, framework, API, technology, or error —
|
||||
**always try `webscraper_search_hint` first**.
|
||||
|
||||
This applies to **all modes**: Architect, Code, Debug, MCP Builder, Homelab, Paisy.
|
||||
|
||||
### Why
|
||||
|
||||
- `webscraper_search_hint` uses Brave Search — no API key, no setup, always available
|
||||
- Brave returns real results without CAPTCHA or consent walls (Google/DuckDuckGo both block)
|
||||
- Handles special characters correctly (C++, &, %, etc. — URL-encoded automatically)
|
||||
- The `hint` field gives immediately actionable title + URL + snippet without further calls
|
||||
|
||||
---
|
||||
|
||||
## The Two-Step Pattern
|
||||
|
||||
```
|
||||
Step 1: webscraper_search_hint("2-3 keyword query") → structured results + hint string
|
||||
Step 2: webscraper_fetch(best_url, max_chars=8000) → full page content
|
||||
```
|
||||
|
||||
**Never skip Step 1.** It costs one tool call and often reveals the exact page to read.
|
||||
|
||||
### Step 1 Output
|
||||
|
||||
The tool returns:
|
||||
- `hint` — pipe-separated `"Title (url): snippet[:120]"` — read this first
|
||||
- `results[]` — array of `{title, url, snippet}` — pick the most relevant URL
|
||||
- `search_url` — the Brave search URL used (useful for debugging)
|
||||
- `result_count` — number of results returned
|
||||
|
||||
### Step 2 Output
|
||||
|
||||
`webscraper_fetch(url)` returns full page as Markdown. Use `max_chars` to control size
|
||||
(default 5000; use 8000–12000 for deep doc reads).
|
||||
|
||||
---
|
||||
|
||||
## Mode-Specific Guidance
|
||||
|
||||
### 🏗️ Architect Mode
|
||||
- Before designing any system or feature: search for existing patterns, reference architectures, and official docs
|
||||
- Example: planning a new MCP server → `webscraper_search_hint("FastMCP server patterns 2025")`
|
||||
- Example: choosing between two libraries → search both and read their official comparison pages
|
||||
|
||||
### 🪲 Debug Mode
|
||||
- Search the **exact error message** before forming hypotheses
|
||||
- Example: `webscraper_search_hint("sqlite3 ProgrammingError Cannot operate closed database Python")`
|
||||
- If the error is long, take the most distinctive phrase (2-5 words) as the query
|
||||
|
||||
### 💻 Code Mode
|
||||
- Before implementing a feature using an unfamiliar API: search the official docs URL pattern first
|
||||
- Example: `webscraper_search_hint("httpx async client connection pool settings")`
|
||||
|
||||
### 🔧 MCP Builder Mode
|
||||
- Check FastMCP changelog/docs before implementing new patterns
|
||||
- Example: `webscraper_search_hint("FastMCP tool decorator async 2025")`
|
||||
- Example: `webscraper_search_hint("FastMCP context lifespan")`
|
||||
|
||||
### 🏠 Homelab Mode
|
||||
- Look up Docker/TrueNAS configs, package versions, service docs before asking Patrick
|
||||
- Example: `webscraper_search_hint("Gitea webhook payload format")`
|
||||
|
||||
---
|
||||
|
||||
## Query Crafting Tips
|
||||
|
||||
| ✅ Good queries | ❌ Bad queries |
|
||||
|---|---|
|
||||
| `"httpx timeout settings"` | `"how do I configure httpx timeouts in Python async code"` |
|
||||
| `"FastMCP tool decorator"` | `"mcp server python tool registration method"` |
|
||||
| `"sqlite WAL mode enable"` | `"sqlite performance mode for concurrent reads"` |
|
||||
| `"Brave Search API no key"` | `"search engine that works without api key or captcha"` |
|
||||
|
||||
- Use 2–4 keywords, not full sentences
|
||||
- Prefer library/framework name + specific feature
|
||||
- For errors: distinctive phrase from the message, not the full stack trace
|
||||
|
||||
---
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- **Reddit / Stack Overflow snippets** — these platforms block snippet extraction; you may get empty snippets. The URL is still valid — fetch it directly if needed.
|
||||
- **Brave CSS selector fragility** — Brave uses Svelte-generated class names that change. If `webscraper_search_hint` returns 0 results unexpectedly, the scraper's CSS selectors may need updating. Last verified working: 2026-04-05.
|
||||
- **Use sparingly** — one search call per research task to orient; then fetch specific pages. Don't call it in a loop.
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns to Avoid
|
||||
|
||||
- ❌ Asking Patrick "what's the FastMCP syntax for X?" before searching
|
||||
- ❌ Designing architecture without looking up existing solutions first
|
||||
- ❌ Forming a debug hypothesis without searching the error message
|
||||
- ❌ Writing code against an API from memory without verifying current docs
|
||||
- ❌ Calling `webscraper_search_hint` more than 2-3 times for the same topic (broaden/narrow the query instead)
|
||||
@@ -9,6 +9,7 @@ description: Commits and pushes code to the homelab Gitea server using conventio
|
||||
- Finished a homelab change and need to commit + push
|
||||
- Finished an MCP server build or update
|
||||
- BigMind feature complete
|
||||
- Wiki pages were added or updated (always deploy wiki after docs changes)
|
||||
|
||||
## When NOT to use
|
||||
- ADP/Paisy work — that goes to the corporate Bitbucket, not homelab Gitea
|
||||
|
||||
@@ -18,12 +18,24 @@ workshop/
|
||||
|
||||
---
|
||||
|
||||
## 🐍 MCP Servers (`mcp/`)
|
||||
## 📖 Wiki
|
||||
|
||||
Full documentation lives in the [Gitea wiki](http://192.168.188.119:30008/pplate/pi_mcps/wiki).
|
||||
|
||||
**Wiki source:** [`docs/wiki/pages/`](docs/wiki/pages/) — edit here, deploy with:
|
||||
```bash
|
||||
./docs/wiki/deploy_wiki.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## � MCP Servers (`mcp/`)
|
||||
|
||||
| Server | Description | Stack |
|
||||
|---|---|---|
|
||||
| [`mcp/bigmind/`](mcp/bigmind/) | Persistent AI memory — sessions, facts, hypotheses, profile UI | Python, FastMCP, SQLite, Flask |
|
||||
| [`mcp/webscraper/`](mcp/webscraper/) | Web scraping — fetch, links, tables, sections, sitemaps | Python, FastMCP, httpx, BeautifulSoup |
|
||||
| [`mcp/webscraper/`](mcp/webscraper/) | Web scraping, search — fetch, links, tables, Brave Search | Python, FastMCP, httpx, BeautifulSoup |
|
||||
| [`mcp/mcp-image-gen/`](mcp/mcp-image-gen/) | AI image generation — text-to-image via ComfyUI + FLUX.1-schnell | Python, FastMCP, httpx, ComfyUI |
|
||||
|
||||
**Run a server:**
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,622 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create all 7 wiki pages for pi_mcps on Gitea."""
|
||||
import base64
|
||||
import json
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
|
||||
GITEA_URL = "http://192.168.188.119:30008"
|
||||
OWNER = "pplate"
|
||||
REPO = "pi_mcps"
|
||||
TOKEN = "8bf0c734ebda3e61d9c9068489ce58a2bf8d33db"
|
||||
IMG_BASE = f"{GITEA_URL}/{OWNER}/{REPO}/raw/branch/main/docs/wiki/images"
|
||||
|
||||
PAGES = {}
|
||||
|
||||
PAGES["Home"] = f"""# 🔧 pi_mcps — Patrick's Homelab Monorepo
|
||||
|
||||

|
||||
|
||||
Welcome to **pi_mcps**, Patrick's personal homelab monorepo. This repository houses MCP (Model Context Protocol) servers, Java projects, and homelab tooling — all built and maintained on a Fedora Linux workstation with an AMD Ryzen 5900X + RX 7900 XTX.
|
||||
|
||||
## What's in this repo?
|
||||
|
||||
| Directory | Contents |
|
||||
|---|---|
|
||||
| [`mcp/mcp-image-gen/`](../src/branch/main/mcp/mcp-image-gen) | 🎨 AI image generation via ComfyUI + FLUX.1-schnell |
|
||||
| [`mcp/webscraper/`](../src/branch/main/mcp/webscraper) | 🕸️ Web scraping and data extraction |
|
||||
| [`mcp/bigmind/`](../src/branch/main/mcp/bigmind) | 🧠 Persistent AI memory system |
|
||||
| [`java/`](../src/branch/main/java) | ☕ Java EE / Spring projects |
|
||||
| [`plans/`](../src/branch/main/plans) | 📋 Architecture decisions and health reports |
|
||||
|
||||
## Stack
|
||||
|
||||
- **Language:** Python 3.11+ (MCP servers), Java 17 (legacy projects)
|
||||
- **MCP Framework:** FastMCP 2.x
|
||||
- **Package Manager:** `uv` (all Python projects)
|
||||
- **Testing:** `pytest`
|
||||
- **GPU:** AMD RX 7900 XTX (ROCm / HSA)
|
||||
- **Server:** TrueNAS.local at `192.168.188.119` (Gitea, Docker)
|
||||
|
||||
## MCP Servers
|
||||
|
||||
Three production-ready MCP servers power Patrick's AI development environment:
|
||||
|
||||
| Server | Status | Description |
|
||||
|---|---|---|
|
||||
| [mcp-image-gen](mcp-image-gen) | ✅ Live | Generate images from text prompts via ComfyUI |
|
||||
| [mcp-webscraper](mcp-webscraper) | ✅ Live | Scrape web pages, extract tables, fetch links |
|
||||
| [BigMind](BigMind) | ✅ Live | Persistent AI memory across all sessions |
|
||||
|
||||
---
|
||||
|
||||
*Built and maintained by Patrick Plate (pplate) · Homelab: TrueNAS.local · AI Colleague: Lumen*
|
||||
"""
|
||||
|
||||
PAGES["MCP-Servers-Overview"] = f"""# 🔌 MCP Servers Overview
|
||||
|
||||

|
||||
|
||||
This repo contains three production-grade MCP (Model Context Protocol) servers, each specialized for a different capability domain. Together they give Roo Code / Claude Desktop a complete set of superpowers.
|
||||
|
||||
## The Three Pillars
|
||||
|
||||
```
|
||||
Roo Code / Claude Desktop
|
||||
│
|
||||
├── bigmind ──────────► ~/.mcp/bigmind/memory.db (persistent memory)
|
||||
├── mcp-image-gen ────► ComfyUI @ localhost:8188 (image generation)
|
||||
└── webscraper ───────► Internet / Intranet (web scraping)
|
||||
```
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Feature | mcp-image-gen | webscraper | bigmind |
|
||||
|---|---|---|---|
|
||||
| **Purpose** | Generate images from text | Scrape & parse web | Persistent AI memory |
|
||||
| **Tools** | 4 | 7 | 15+ |
|
||||
| **Backend** | ComfyUI / FLUX.1-schnell | httpx + BeautifulSoup4 | SQLite + FTS5 |
|
||||
| **GPU required** | ✅ AMD RX 7900 XTX | ❌ | ❌ |
|
||||
| **Tests** | 19/19 ✅ | ✅ | 297/297 ✅ |
|
||||
| **Schema version** | n/a | n/a | v7 |
|
||||
|
||||
## Quick Links
|
||||
|
||||
- 🎨 [mcp-image-gen](mcp-image-gen) — Image generation docs
|
||||
- 🕸️ [mcp-webscraper](mcp-webscraper) — Web scraping docs
|
||||
- 🧠 [BigMind](BigMind) — Memory system docs
|
||||
- 🛠️ [Development Conventions](Development-Conventions) — How all servers are built
|
||||
|
||||
## Adding a New Server
|
||||
|
||||
All servers follow the [FastMCP convention](Development-Conventions). Use the `new-mcp-server` Roo skill to scaffold:
|
||||
|
||||
```bash
|
||||
# In Roo Code orchestrator, load skill:
|
||||
# skill: new-mcp-server
|
||||
```
|
||||
"""
|
||||
|
||||
PAGES["mcp-image-gen"] = f"""# 🎨 mcp-image-gen — AI Image Generation
|
||||
|
||||

|
||||
|
||||
**mcp-image-gen** is a FastMCP server that wraps the ComfyUI REST API, enabling Roo Code and Claude Desktop to generate images directly from text prompts using FLUX.1-schnell running on an AMD RX 7900 XTX GPU.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Roo Code / Claude Desktop
|
||||
│ MCP (stdio)
|
||||
▼
|
||||
mcp-image-gen (FastMCP, Python 3.11+)
|
||||
│ HTTP REST
|
||||
▼
|
||||
ComfyUI @ localhost:8188
|
||||
│ ROCm / HSA_OVERRIDE_GFX_VERSION=11.0.0
|
||||
▼
|
||||
FLUX.1-schnell (~8s/image @ 1024×1024)
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `generate_image` | Generate PNG from text prompt; returns file path + inline base64 |
|
||||
| `list_available_models` | List ComfyUI checkpoint models |
|
||||
| `get_generation_status` | Check status of a queued/running job |
|
||||
| `get_output_directory` | Return configured output directory path |
|
||||
|
||||
## Key Parameters — `generate_image`
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `prompt` | required | Text description of the image |
|
||||
| `width` | `1024` | Image width in pixels |
|
||||
| `height` | `1024` | Image height in pixels |
|
||||
| `steps` | `4` | Inference steps (FLUX.1-schnell is 4-step) |
|
||||
| `model` | `flux1-schnell.safetensors` | Model checkpoint name |
|
||||
| `seed` | `-1` (random) | Generation seed for reproducibility |
|
||||
| `negative_prompt` | `""` | Things to avoid in the image |
|
||||
| `output_dir` | `~/Pictures/mcp-generated` | Where to save output PNG |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `COMFYUI_URL` | `http://localhost:8188` | ComfyUI API endpoint |
|
||||
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Default output directory |
|
||||
| `COMFYUI_TIMEOUT` | `120` | Request timeout in seconds |
|
||||
|
||||
## Return Value
|
||||
|
||||
The tool returns **two content items**:
|
||||
1. `TextContent` — file path, seed used, elapsed time
|
||||
2. `ImageContent` — base64-encoded PNG (displays inline in Roo Code chat)
|
||||
|
||||
> ⚠️ **Known FastMCP Bug:** Never use `fastmcp.utilities.types.Image` as return type — it breaks serialization in FastMCP 3.x. Use `mcp.types.ImageContent` directly.
|
||||
|
||||
## Setup
|
||||
|
||||
See [ComfyUI Setup Guide](mcp-image-gen-ComfyUI-Setup) for full installation instructions.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen
|
||||
uv sync
|
||||
# Set COMFYUI_URL if ComfyUI is not on localhost
|
||||
./run.sh
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## Lumen Profile Images
|
||||
|
||||
The first images generated with this server were Lumen's visual identity portraits, stored in [`mcp/mcp-image-gen/lumen_profiles/`](../src/branch/main/mcp/mcp-image-gen/lumen_profiles):
|
||||
|
||||

|
||||
|
||||
*Primary profile: seed `568659042` — constellation face interpretation of Lumen.*
|
||||
"""
|
||||
|
||||
PAGES["mcp-image-gen-ComfyUI-Setup"] = f"""# ⚙️ ComfyUI Setup Guide (AMD ROCm)
|
||||
|
||||
This guide covers installing ComfyUI with FLUX.1-schnell on a Fedora Linux system with an AMD GPU.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AMD GPU with ROCm support (tested: RX 7900 XTX)
|
||||
- Fedora Linux (tested: Fedora 43 / kernel 6.19)
|
||||
- Python 3.11+
|
||||
- ~15GB free disk space (model weights)
|
||||
- HuggingFace account with FLUX license accepted
|
||||
|
||||
## Step 1: Install ComfyUI
|
||||
|
||||
ComfyUI is **not on PyPI** — must be cloned from source:
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/comfyanonymous/ComfyUI
|
||||
cd ComfyUI
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install PyTorch ROCm build (CRITICAL for AMD GPUs)
|
||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.2
|
||||
|
||||
# Install ComfyUI dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Step 2: Download FLUX.1-schnell
|
||||
|
||||
FLUX.1-schnell is **gated on HuggingFace** — you must:
|
||||
1. Create a HuggingFace account
|
||||
2. Accept the FLUX.1-schnell license at https://huggingface.co/black-forest-labs/FLUX.1-schnell
|
||||
3. Generate an access token at https://huggingface.co/settings/tokens
|
||||
|
||||
```bash
|
||||
# Install huggingface_hub
|
||||
pip install huggingface_hub
|
||||
|
||||
# Download model (requires HF token)
|
||||
huggingface-cli download black-forest-labs/FLUX.1-schnell \\
|
||||
flux1-schnell.safetensors \\
|
||||
--local-dir ~/ComfyUI/models/checkpoints \\
|
||||
--token YOUR_HF_TOKEN_HERE
|
||||
```
|
||||
|
||||
## Step 3: Download VAE and CLIP Models
|
||||
|
||||
FLUX.1-schnell also requires VAE and CLIP text encoders:
|
||||
|
||||
```bash
|
||||
# VAE
|
||||
huggingface-cli download black-forest-labs/FLUX.1-schnell \\
|
||||
ae.safetensors \\
|
||||
--local-dir ~/ComfyUI/models/vae
|
||||
|
||||
# CLIP models (T5 and CLIP-L)
|
||||
huggingface-cli download comfyanonymous/flux_text_encoders \\
|
||||
t5xxl_fp8_e4m3fn.safetensors clip_l.safetensors \\
|
||||
--local-dir ~/ComfyUI/models/clip
|
||||
```
|
||||
|
||||
## Step 4: Start ComfyUI
|
||||
|
||||
```bash
|
||||
cd ~/ComfyUI
|
||||
|
||||
# AMD GPU REQUIRES this environment variable
|
||||
HSA_OVERRIDE_GFX_VERSION=11.0.0 \\
|
||||
nohup .venv/bin/python main.py --listen --port 8188 > /tmp/comfyui.log 2>&1 &
|
||||
|
||||
echo "ComfyUI PID: $!"
|
||||
```
|
||||
|
||||
> ⚠️ `HSA_OVERRIDE_GFX_VERSION=11.0.0` is mandatory for RX 7900 XTX on ROCm. Without it, model loading fails silently.
|
||||
|
||||
## Step 5: Verify ComfyUI is Running
|
||||
|
||||
```bash
|
||||
curl http://localhost:8188/system_stats
|
||||
# Should return JSON with GPU info
|
||||
```
|
||||
|
||||
## Step 6: Configure mcp-image-gen
|
||||
|
||||
```bash
|
||||
cd /path/to/pi_mcps/mcp/mcp-image-gen
|
||||
cp .env.example .env # if exists, or set manually
|
||||
|
||||
# .env contents:
|
||||
COMFYUI_URL=http://localhost:8188
|
||||
IMAGE_OUTPUT_DIR=~/Pictures/mcp-generated
|
||||
COMFYUI_TIMEOUT=120
|
||||
```
|
||||
|
||||
## Performance
|
||||
|
||||
| GPU | Model | Resolution | Steps | Time |
|
||||
|---|---|---|---|---|
|
||||
| AMD RX 7900 XTX | FLUX.1-schnell | 1024×1024 | 4 | ~8s |
|
||||
| AMD RX 7900 XTX | FLUX.1-schnell | 1280×512 | 4 | ~7s |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---|---|
|
||||
| `HTTP 401` downloading model | Accept FLUX license on HuggingFace first |
|
||||
| GPU not detected | Ensure `HSA_OVERRIDE_GFX_VERSION=11.0.0` is set |
|
||||
| `Connection refused` from mcp-image-gen | Start ComfyUI first, check port 8188 |
|
||||
| Slow generation (>60s) | ComfyUI may be running on CPU — check ROCm install |
|
||||
| Ollama image gen | As of April 2026: macOS-only, not available on Linux |
|
||||
"""
|
||||
|
||||
PAGES["mcp-webscraper"] = f"""# 🕸️ mcp-webscraper — Web Scraping
|
||||
|
||||

|
||||
|
||||
**mcp-webscraper** is a FastMCP server providing comprehensive web scraping and data extraction capabilities. It fetches pages, converts HTML to clean Markdown, extracts tables, links, CSS sections, metadata, and sitemaps.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `webscraper_fetch(url, max_chars=5000)` | Title + full page as Markdown + metadata |
|
||||
| `webscraper_fetch_links(url, deduplicate=True)` | All `href` links found on the page |
|
||||
| `webscraper_fetch_tables(url)` | All HTML tables converted to Markdown |
|
||||
| `webscraper_fetch_all(url, max_chars=5000)` | Everything in one call (fetch + links + tables) |
|
||||
| `webscraper_fetch_section(url, selector)` | Specific CSS selector section only |
|
||||
| `webscraper_fetch_meta(url)` | Title, description, Open Graph tags |
|
||||
| `webscraper_fetch_sitemap(url, max_urls=100)` | Parse sitemap.xml, return URL list |
|
||||
|
||||
## Stack
|
||||
|
||||
- **HTTP client:** `httpx` (async, with SSL support)
|
||||
- **HTML parser:** `BeautifulSoup4` + `lxml`
|
||||
- **Markdown converter:** `html2text`
|
||||
- **SSL:** Custom cert bundle for Fedora 43 compatibility
|
||||
|
||||
## SSL Note — Fedora 43 Comodo Root CA
|
||||
|
||||
Fedora 43 is missing the **Comodo AAA Services Root CA** needed for Cloudflare-protected sites. The fix is bundled at [`mcp/webscraper/certs/comodo-aaa-services-root.pem`](../src/branch/main/mcp/webscraper/certs/).
|
||||
|
||||
The server automatically uses this cert bundle — no manual configuration needed.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd mcp/webscraper
|
||||
uv sync
|
||||
./run.sh
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```python
|
||||
# In Roo Code / Claude Desktop via MCP:
|
||||
|
||||
# Fetch a page as Markdown
|
||||
webscraper_fetch("https://docs.fastmcp.dev", max_chars=10000)
|
||||
|
||||
# Extract all links from Gitea repo
|
||||
webscraper_fetch_links("http://192.168.188.119:30008/pplate/pi_mcps")
|
||||
|
||||
# Get all tables from a documentation page
|
||||
webscraper_fetch_tables("https://pypi.org/project/fastmcp/")
|
||||
|
||||
# Get Open Graph metadata
|
||||
webscraper_fetch_meta("https://github.com/comfyanonymous/ComfyUI")
|
||||
|
||||
# Fetch specific section by CSS selector
|
||||
webscraper_fetch_section("https://docs.python.org", "#content")
|
||||
```
|
||||
"""
|
||||
|
||||
PAGES["BigMind"] = f"""# 🧠 BigMind — Persistent AI Memory
|
||||
|
||||

|
||||
|
||||
**BigMind** is the persistent memory backbone for all AI development sessions. It provides SQLite-backed tiered memory with FTS5 full-text search, hypothesis tracking, session management, and token efficiency logging. It is the reason Lumen (Patrick's AI colleague) remembers everything across sessions.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Tiered Memory
|
||||
| Tier | Name | Content |
|
||||
|---|---|---|
|
||||
| 0 | **Session Index** | Lightweight list: ID, date, one-liner |
|
||||
| 1 | **Topic Index** | Per-session topic tags and metadata |
|
||||
| 2 | **Narrative** | Full 3-8 sentence session summaries |
|
||||
| 3 | **Flagged Exchanges** | Specific important moments, decisions, code |
|
||||
|
||||
### Facts Store
|
||||
Atomic, reusable knowledge pieces categorized by type:
|
||||
- `user-preference` — Patrick's tool/style preferences
|
||||
- `architecture-decision` — System design choices
|
||||
- `codebase-convention` — How code is structured
|
||||
- `environment-config` — Server IPs, paths, credentials
|
||||
- `bug-pattern` — Known bugs and fixes
|
||||
- `api-contract` — MCP tool signatures
|
||||
|
||||
## Key Tools
|
||||
|
||||
### Session Lifecycle
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_start_session()` | Open new session, load prior context |
|
||||
| `memory_end_session(...)` | Close session with summary, topics, outcome |
|
||||
| `memory_announce_focus(...)` | Declare files to be touched this session |
|
||||
| `memory_close_stale_sessions(...)` | Clean up crashed IDE sessions |
|
||||
|
||||
### Search
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_search_facts(query, limit=10)` | FTS5 search over stored facts |
|
||||
| `memory_search_chunks(query, limit=10)` | FTS5 search over conversation chunks |
|
||||
| `memory_list_sessions(limit=20)` | Browse session history |
|
||||
|
||||
### Storage
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_store_fact(category, fact)` | Store atomic reusable fact |
|
||||
| `memory_append_chunk(session_id, content, role)` | Store conversation chunk |
|
||||
| `memory_flag_important(session_id, content, role, flag_reason)` | Flag critical exchange |
|
||||
| `memory_log_token_save(session_id, description, tokens_saved, method_used)` | Track efficiency |
|
||||
|
||||
### Hypotheses
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_add_hypothesis(session_id, hypothesis, confidence)` | Form testable prediction |
|
||||
| `memory_resolve_hypothesis(hypothesis_id, status, resolution)` | Confirm/refute prediction |
|
||||
| `memory_list_hypotheses(status)` | Review open/closed predictions |
|
||||
|
||||
## FTS5 Search Tips
|
||||
|
||||
BigMind uses SQLite FTS5 — **every token must match**. Use 2-3 focused keywords:
|
||||
|
||||
```
|
||||
✅ memory_search_facts("TrueNAS Docker")
|
||||
✅ memory_search_facts("mcp.json config")
|
||||
❌ memory_search_facts("homelab infrastructure TrueNAS Docker server") → 0 results
|
||||
```
|
||||
|
||||
## Stats (2026-04-04)
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| DB size | 744KB |
|
||||
| Sessions | 98 |
|
||||
| Facts | 97+ |
|
||||
| Chunks | 41 |
|
||||
| Schema version | v7 |
|
||||
|
||||
## DB Location
|
||||
|
||||
`~/.mcp/bigmind/memory.db` — outside the repo, never committed.
|
||||
|
||||
## Session Ritual
|
||||
|
||||
Every session **must** follow this ritual:
|
||||
|
||||
**Start:**
|
||||
1. `memory_start_session()`
|
||||
2. `memory_list_hypotheses()`
|
||||
3. `memory_announce_focus(...)`
|
||||
4. `memory_close_stale_sessions(...)`
|
||||
|
||||
**End:**
|
||||
1. `memory_end_session(one_liner, topics, outcome, summary, importance)`
|
||||
"""
|
||||
|
||||
PAGES["Development-Conventions"] = """# 🛠️ Development Conventions
|
||||
|
||||
All MCP servers in this repo follow a consistent set of conventions to ensure maintainability, testability, and compatibility with Roo Code tooling.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Each MCP server lives at `mcp/<server-name>/` with this layout:
|
||||
|
||||
```
|
||||
mcp/<server-name>/
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ └── server.py ← FastMCP server entry point
|
||||
├── tests/
|
||||
│ └── test_server.py ← pytest test suite
|
||||
├── pyproject.toml ← uv-managed dependencies
|
||||
├── run.sh ← launch script
|
||||
├── README.md ← server documentation
|
||||
├── PLAN.md ← architecture plan (pre-implementation)
|
||||
└── ASSESSMENT.md ← pre-implementation assessment
|
||||
```
|
||||
|
||||
## FastMCP Pattern
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("server-name")
|
||||
|
||||
@mcp.tool()
|
||||
def my_tool(param: str) -> str:
|
||||
\"\"\"Tool description shown to the AI.\"\"\"
|
||||
return result
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
## Package Management
|
||||
|
||||
**All projects use `uv`** — never `pip` directly:
|
||||
|
||||
```bash
|
||||
# Create new server
|
||||
uv init mcp/my-server
|
||||
cd mcp/my-server
|
||||
uv add fastmcp httpx
|
||||
|
||||
# Sync dependencies
|
||||
uv sync
|
||||
|
||||
# Run server
|
||||
uv run python src/server.py
|
||||
|
||||
# Run tests
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## pyproject.toml Template
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "mcp-my-server"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastmcp>=2.0.0",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
mcp-my-server = "src.server:main"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
```
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
- Tests live in `tests/test_server.py`
|
||||
- Use `pytest` via `uv run pytest`
|
||||
- Mock external dependencies (ComfyUI, web URLs) for unit tests
|
||||
- All tests must pass before committing (`git push` should only happen with green tests)
|
||||
|
||||
## Commit Convention
|
||||
|
||||
Follow **Conventional Commits** format:
|
||||
|
||||
```
|
||||
feat: add webscraper_fetch_section tool
|
||||
fix: handle ComfyUI timeout gracefully
|
||||
docs: update mcp-image-gen README with AMD setup
|
||||
test: add unit tests for generate_image tool
|
||||
refactor: extract workflow builder to separate module
|
||||
chore: bump fastmcp to 2.1.0
|
||||
```
|
||||
|
||||
## Creating a New MCP Server
|
||||
|
||||
Use the `new-mcp-server` Roo skill in MCP Builder mode for full scaffolding:
|
||||
|
||||
```
|
||||
1. Switch to 🔧 MCP Builder mode in Roo Code
|
||||
2. Say: "Create a new MCP server for <purpose>"
|
||||
3. Roo will load the new-mcp-server skill and scaffold everything
|
||||
```
|
||||
|
||||
## Gitea Repository
|
||||
|
||||
Code is hosted at: `http://192.168.188.119:30008/pplate/pi_mcps`
|
||||
|
||||
Push with the `gitea-push` Roo skill to ensure conventional commit format.
|
||||
"""
|
||||
|
||||
|
||||
def create_wiki_page(title: str, content: str) -> bool:
|
||||
content_b64 = base64.b64encode(content.encode("utf-8")).decode("ascii")
|
||||
payload = json.dumps({
|
||||
"title": title,
|
||||
"content_base64": content_b64,
|
||||
"message": f"docs: create {title} wiki page"
|
||||
}).encode("utf-8")
|
||||
|
||||
url = f"{GITEA_URL}/api/v1/repos/{OWNER}/{REPO}/wiki/pages"
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": f"token {TOKEN}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST"
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read().decode())
|
||||
print(f"✅ Created: {data.get('title', title)}")
|
||||
return True
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode()
|
||||
print(f"❌ Failed [{title}]: HTTP {e.code} — {body[:200]}")
|
||||
return False
|
||||
except Exception as ex:
|
||||
print(f"❌ Failed [{title}]: {ex}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
results = {}
|
||||
for title, content in PAGES.items():
|
||||
ok = create_wiki_page(title, content)
|
||||
results[title] = ok
|
||||
|
||||
print("\n=== Summary ===")
|
||||
for title, ok in results.items():
|
||||
status = "✅" if ok else "❌"
|
||||
print(f"{status} {title}")
|
||||
|
||||
total = sum(results.values())
|
||||
print(f"\n{total}/{len(results)} pages created successfully")
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env bash
|
||||
# deploy_wiki.sh — Sync docs/wiki/pages/*.md to the local wiki git clone
|
||||
#
|
||||
# ── Convention ────────────────────────────────────────────────────────────────
|
||||
# The Gitea wiki is a SEPARATE git repo (pi_mcps.wiki.git).
|
||||
# We keep a persistent local clone at wiki/ in the repo root.
|
||||
# That folder is gitignored so it doesn't conflict with the main repo.
|
||||
#
|
||||
# First-time setup (run once):
|
||||
# git clone http://pplate:TOKEN@192.168.188.119:30008/pplate/pi_mcps.wiki.git wiki/
|
||||
#
|
||||
# ── Daily workflow ────────────────────────────────────────────────────────────
|
||||
# 1. Edit pages in docs/wiki/pages/*.md (tracked in pi_mcps main repo)
|
||||
# 2. Run: ./docs/wiki/deploy_wiki.sh
|
||||
# ./docs/wiki/deploy_wiki.sh "docs: describe your change"
|
||||
#
|
||||
# The script copies pages into wiki/, commits, and pushes to Gitea.
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# ── Config ────────────────────────────────────────────────────────────────────
|
||||
GITEA_URL="http://192.168.188.119:30008"
|
||||
OWNER="pplate"
|
||||
REPO="pi_mcps"
|
||||
|
||||
# Resolve paths relative to repo root (two levels up from docs/wiki/)
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
|
||||
PAGES_DIR="${SCRIPT_DIR}/pages"
|
||||
WIKI_DIR="${REPO_ROOT}/wiki"
|
||||
COMMIT_MSG="${1:-docs: sync wiki pages $(date -u '+%Y-%m-%d %H:%M UTC')}"
|
||||
|
||||
# ── Validate ──────────────────────────────────────────────────────────────────
|
||||
if [[ ! -d "${WIKI_DIR}/.git" ]]; then
|
||||
echo "❌ Wiki repo not set up. Run first-time setup:"
|
||||
echo ""
|
||||
echo " TOKEN=8bf0c734ebda3e61d9c9068489ce58a2bf8d33db"
|
||||
echo " git clone http://pplate:\${TOKEN}@192.168.188.119:30008/pplate/pi_mcps.wiki.git wiki/"
|
||||
echo ""
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "${PAGES_DIR}" ]]; then
|
||||
echo "❌ Pages directory not found: ${PAGES_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
PAGE_COUNT=$(find "${PAGES_DIR}" -name "*.md" | wc -l)
|
||||
if [[ "${PAGE_COUNT}" -eq 0 ]]; then
|
||||
echo "❌ No .md files found in ${PAGES_DIR}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "📚 Found ${PAGE_COUNT} wiki pages in ${PAGES_DIR}"
|
||||
|
||||
# ── Pull latest (avoid non-fast-forward push) ─────────────────────────────────
|
||||
echo "📥 Pulling latest wiki changes..."
|
||||
git -C "${WIKI_DIR}" pull --quiet --rebase origin main
|
||||
|
||||
# ── Copy pages ────────────────────────────────────────────────────────────────
|
||||
echo "📋 Copying pages to ${WIKI_DIR}/..."
|
||||
for md_file in "${PAGES_DIR}"/*.md; do
|
||||
filename="$(basename "${md_file}")"
|
||||
cp "${md_file}" "${WIKI_DIR}/${filename}"
|
||||
echo " → ${filename}"
|
||||
done
|
||||
|
||||
# ── Commit and push ───────────────────────────────────────────────────────────
|
||||
cd "${WIKI_DIR}"
|
||||
|
||||
git add -A
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "✅ No changes detected — wiki is already up to date."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
CHANGED=$(git diff --cached --name-only | wc -l)
|
||||
echo "📝 Committing ${CHANGED} changed file(s)..."
|
||||
git commit --quiet -m "${COMMIT_MSG}"
|
||||
|
||||
echo "🚀 Pushing to Gitea wiki..."
|
||||
git push --quiet origin main
|
||||
|
||||
echo ""
|
||||
echo "✅ Wiki deployed successfully!"
|
||||
echo " Pages: ${PAGE_COUNT} total, ${CHANGED} updated"
|
||||
echo " Message: ${COMMIT_MSG}"
|
||||
echo " URL: ${GITEA_URL}/${OWNER}/${REPO}/wiki"
|
||||
|
After Width: | Height: | Size: 737 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 512 KiB |
|
After Width: | Height: | Size: 290 KiB |
|
After Width: | Height: | Size: 436 KiB |
|
After Width: | Height: | Size: 619 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 798 KiB |
|
After Width: | Height: | Size: 888 KiB |
|
After Width: | Height: | Size: 745 KiB |
|
After Width: | Height: | Size: 541 KiB |
|
After Width: | Height: | Size: 1.3 MiB |
|
After Width: | Height: | Size: 457 KiB |
|
After Width: | Height: | Size: 501 KiB |
|
After Width: | Height: | Size: 778 KiB |
|
After Width: | Height: | Size: 814 KiB |
@@ -0,0 +1,125 @@
|
||||
# 🧠 BigMind — Persistent AI Memory
|
||||
|
||||

|
||||
|
||||
**BigMind** is the persistent memory backbone for all AI development sessions. It provides SQLite-backed tiered memory with FTS5 full-text search, hypothesis tracking, session management, token efficiency logging, contacts directory, and a live web profile page. It is the reason Lumen (Patrick's AI colleague) remembers everything across sessions.
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Tiered Memory
|
||||
| Tier | Name | Content |
|
||||
|---|---|---|
|
||||
| 0 | **Identity Profile** | Role, preferences, pinned facts |
|
||||
| 1 | **Session Index** | Lightweight list: ID, date, one-liner, topics |
|
||||
| 2 | **Narrative** | Full 3-8 sentence session summaries |
|
||||
| 3 | **Flagged Exchanges** | Specific important moments, decisions, code |
|
||||
|
||||
### Facts Store
|
||||
Atomic, reusable knowledge pieces categorized by type:
|
||||
- `user-preference` — Patrick's tool/style preferences
|
||||
- `architecture-decision` — System design choices
|
||||
- `codebase-convention` — How code is structured
|
||||
- `environment-config` — Server IPs, paths, credentials
|
||||
- `bug-pattern` — Known bugs and fixes
|
||||
- `api-contract` — MCP tool signatures
|
||||
- `dependency-info` — Library versions and constraints
|
||||
|
||||
## Key Tools
|
||||
|
||||
### Session Lifecycle
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_start_session()` | Open new session, load prior context |
|
||||
| `memory_end_session(...)` | Close session with summary, topics, outcome |
|
||||
| `memory_announce_focus(...)` | Declare files to be touched this session |
|
||||
| `memory_close_stale_sessions(...)` | Clean up crashed IDE sessions |
|
||||
| `memory_get_active_sessions()` | Check for parallel session conflicts |
|
||||
|
||||
### Search
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_search_facts(query, limit=10)` | FTS5 search over stored facts |
|
||||
| `memory_search_chunks(query, limit=10)` | FTS5 search over conversation chunks |
|
||||
| `memory_list_sessions(limit=20)` | Browse session history |
|
||||
| `memory_get_session_detail(session_id)` | Full Tier-2 narrative for a session |
|
||||
|
||||
### Storage
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_store_fact(category, fact)` | Store atomic reusable fact |
|
||||
| `memory_append_chunk(session_id, content, role)` | Store conversation chunk |
|
||||
| `memory_flag_important(session_id, content, role, flag_reason)` | Flag critical exchange |
|
||||
| `memory_log_token_save(session_id, description, tokens_saved, method_used)` | Track efficiency |
|
||||
|
||||
### Hypotheses
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_add_hypothesis(session_id, hypothesis, confidence)` | Form testable prediction |
|
||||
| `memory_resolve_hypothesis(hypothesis_id, status, resolution)` | Confirm/refute prediction |
|
||||
| `memory_list_hypotheses(status)` | Review open/closed predictions |
|
||||
|
||||
### Contacts
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_remember_person(username, ...)` | Store/update a person in contacts |
|
||||
| `memory_recall_person(query)` | Search contacts directory |
|
||||
| `memory_list_people()` | List all contacts |
|
||||
|
||||
### Web Profile
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `memory_open_profile()` | Open profile page in browser |
|
||||
| `memory_get_profile_url()` | Get URL for IDE browser panel |
|
||||
|
||||
## FTS5 Search Tips
|
||||
|
||||
BigMind uses SQLite FTS5 — **every token must match**. Use 2-3 focused keywords:
|
||||
|
||||
```
|
||||
✅ memory_search_facts("TrueNAS Docker")
|
||||
✅ memory_search_facts("mcp.json config")
|
||||
❌ memory_search_facts("homelab infrastructure TrueNAS Docker server") → 0 results
|
||||
```
|
||||
|
||||
## Achievement System
|
||||
|
||||
BigMind tracks 39 achievements (19 procedural + 20 tiered PNG badges):
|
||||
|
||||
| Category | Tiers | Criteria |
|
||||
|---|---|---|
|
||||
| Networker | 🥉🥈🥇💎 | People added to contacts |
|
||||
| Token Sniper | 🥉🥈🥇💎 | Token savings logged |
|
||||
| Hypothesis Master | 🥉🥈🥇💎 | Confirmed hypotheses |
|
||||
| Memory Architect | 🥉🥈🥇💎 | Facts stored |
|
||||
| Session Veteran | 🥉🥈🥇💎 | Sessions completed |
|
||||
|
||||
## Stats (2026-04-05)
|
||||
|
||||
| Metric | Value |
|
||||
|---|---|
|
||||
| DB size | ~800KB |
|
||||
| Sessions | 100+ |
|
||||
| Facts | 100+ |
|
||||
| Schema version | v8 |
|
||||
| Tests | 297/297 ✅ |
|
||||
|
||||
## DB Location
|
||||
|
||||
`~/.mcp/bigmind/memory.db` — outside the repo, never committed.
|
||||
|
||||
## Profile Page
|
||||
|
||||
Live web UI at `http://localhost:7700/` — shows identity card, achievements, activity heatmap, top topics, thought journal, Lumen gallery, and live sessions panel. Auto-refreshes every 30 seconds.
|
||||
|
||||
## Session Ritual
|
||||
|
||||
Every session **must** follow this ritual:
|
||||
|
||||
**Start (in order):**
|
||||
1. `memory_start_session()`
|
||||
2. `memory_list_hypotheses(status="open")`
|
||||
3. `memory_announce_focus(session_id, description, files, ide_hint)`
|
||||
4. `memory_close_stale_sessions(session_id)`
|
||||
|
||||
**End:**
|
||||
1. `memory_end_session(session_id, one_liner, topics, outcome, summary, importance)`
|
||||
@@ -0,0 +1,227 @@
|
||||
# CannaManage — Project Charter
|
||||
|
||||
**Author:** Patrick Plate
|
||||
**Date:** 2026-04-06
|
||||
**Version:** 1.0
|
||||
**Status:** Draft for Review
|
||||
|
||||
---
|
||||
|
||||
## 1. Executive Summary
|
||||
|
||||
### Vision Statement
|
||||
|
||||
> *CannaManage is the compliance backbone for German cannabis social clubs — purpose-built to turn a legally mandated administrative burden into a manageable, auditable, and digitised workflow.*
|
||||
|
||||
### The Problem
|
||||
|
||||
Germany's **Konsumcannabisgesetz (CanG)**, in force since April 1, 2024, legalised cannabis for personal use and established a framework for **Anbauvereinigungen** (cannabis social clubs / CSCs). Every operating CSC faces mandatory, recurring compliance obligations:
|
||||
|
||||
- Track every distribution (recipient, strain, weight, date/time) — by law
|
||||
- Enforce quantity limits per member (50g/month for adults, 30g/month for under-21, 25g/day)
|
||||
- Maintain batch-level contamination traceability
|
||||
- Produce periodic authority reports
|
||||
- Designate and track a Prevention Officer (Präventionsbeauftragter)
|
||||
- Manage member data under DSGVO
|
||||
|
||||
Clubs currently manage this with Excel spreadsheets, pen-and-paper logs, and WhatsApp groups — creating legal risk, audit gaps, and administrative chaos.
|
||||
|
||||
### Why Now
|
||||
|
||||
The market is less than two years old. **No purpose-built software tooling exists** for German CSCs. The window to establish market leadership is 2026–2027 before larger players notice the niche. First-mover advantage combined with the permanent regulatory moat from CanG compliance requirements makes this the right moment.
|
||||
|
||||
### What We Are Building
|
||||
|
||||
A **multi-tenant B2B SaaS platform** offering:
|
||||
- Club admin portal (member management, distribution logging, stock management, compliance reporting)
|
||||
- Member portal (personal quota, distribution history, stock visibility)
|
||||
- Built-in CanG compliance enforcement and export tooling
|
||||
|
||||
**We are selling compliance management software to licensed, regulated entities. We are not in the cannabis business.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Project Scope
|
||||
|
||||
### 2.1 In Scope — MVP v1
|
||||
|
||||
| Area | Features Included |
|
||||
|------|-------------------|
|
||||
| **Onboarding** | Club registration, setup wizard, admin account creation |
|
||||
| **Member Management** | Add/remove members, age verification (18+, 18–21 restricted), contact data |
|
||||
| **Distribution Tracking** | Log each handout (member, strain, weight, date/time); enforce daily/monthly limits |
|
||||
| **Limit Enforcement** | 25g/day cap, 50g/month (adult), 30g/month (under-21), 10% THC flag |
|
||||
| **Stock Management** | Strains, batch tracking, quantity levels |
|
||||
| **Admin Dashboard** | Club-level totals: members, distributions this month, stock levels |
|
||||
| **Compliance Exports** | Monthly distribution report (PDF + CSV), member list export for inspections |
|
||||
| **Contamination Recall** | Flag a batch; system lists all members who received from it |
|
||||
| **Prevention Officer** | Store officer contact info and designation date |
|
||||
| **Member Portal** | Login with club-issued credentials; view quota, distribution history, stock availability |
|
||||
| **Authentication** | Spring Security + JWT; role-based (ADMIN, MEMBER) |
|
||||
| **Hosting** | Hetzner VPS (German DC), Docker Compose, PostgreSQL + Flyway |
|
||||
|
||||
### 2.2 Explicitly Out of Scope — MVP v1
|
||||
|
||||
| Feature | Reason Excluded |
|
||||
|---------|-----------------|
|
||||
| Public club discovery / "find clubs near you" | **Illegal under CanG §§6–7 advertising ban** |
|
||||
| Cannabis e-commerce or payment for cannabis | Illegal; violates positioning |
|
||||
| Non-EU data storage (AWS us-east, etc.) | DSGVO violation |
|
||||
| Stripe subscription billing | Deferred to Phase 1 (Weeks 9–16) |
|
||||
| Email/SMS notifications | v2 feature |
|
||||
| Mobile native app (Android/iOS) | v2/v3 feature |
|
||||
| Multi-location club support | v3 feature |
|
||||
| Legal template marketplace | v3 feature |
|
||||
| Next.js/React frontend | v2 migration after revenue justifies investment |
|
||||
| Authority portal integrations | v3 feature (portals don't exist yet) |
|
||||
|
||||
---
|
||||
|
||||
## 3. Stakeholders
|
||||
|
||||
| Role | Description | Needs |
|
||||
|------|-------------|-------|
|
||||
| **Club Admin** *(primary user)* | Vereinsvorstand or designated manager; runs day-to-day club operations | Compliant distribution logging, member management, authority-ready exports |
|
||||
| **Club Member** *(secondary user)* | Verified adult member of the Anbauvereinigung | Self-service quota visibility, distribution history, stock availability |
|
||||
| **Prevention Officer** *(Präventionsbeauftragter, tertiary user)* | Legally required role; may or may not be the admin | Contact info tracked in system; receives relevant reports |
|
||||
| **Patrick Plate** *(developer & product owner)* | Solo developer; nights/weekends; ADP Germany full-time | Minimal learning overhead; fast path to first revenue; legally sound product |
|
||||
|
||||
---
|
||||
|
||||
## 4. Success Criteria
|
||||
|
||||
MVP is considered complete when all of the following are true:
|
||||
|
||||
| # | Criterion | Measure |
|
||||
|---|-----------|---------|
|
||||
| 1 | **Core compliance loop working** | Admin can log a distribution → system enforces limits → admin exports PDF report for authorities |
|
||||
| 2 | **Multi-tenant isolation** | Two clubs' data are completely isolated — no cross-tenant data leakage |
|
||||
| 3 | **Member portal live** | Member can log in with club-issued credentials and view their quota + history |
|
||||
| 4 | **Contamination recall functional** | Admin flags a batch; system returns full recipient list in < 2 seconds |
|
||||
| 5 | **Deployment stable** | Platform runs on Hetzner VPS via Docker Compose with uptime ≥ 99% over 30-day beta |
|
||||
| 6 | **Beta validation** | 3–5 real club admins have used the system and provided written feedback |
|
||||
| 7 | **Legal review passed** | No features violate CanG advertising ban; DSGVO AVV in place before any live data |
|
||||
| 8 | **Zero PII on non-EU infrastructure** | All data confirmed to reside in Hetzner DE datacenter |
|
||||
|
||||
---
|
||||
|
||||
## 5. Constraints & Assumptions
|
||||
|
||||
### Constraints
|
||||
|
||||
| Type | Constraint |
|
||||
|------|-----------|
|
||||
| **Legal** | CanG §§6–7 imposes a **total advertising and sponsoring ban** on cannabis AND Anbauvereinigungen — no public club discovery feature, ever |
|
||||
| **Legal** | DSGVO requires EU hosting, data processing agreements (AVV), member data export/deletion capability |
|
||||
| **Technical (MVP)** | Frontend is PrimeFaces + JSF — Patrick's existing expertise; no new framework learning in Phase 0 |
|
||||
| **Technical** | Multi-tenancy via `tenant_id` on all JPA entities — no row-level security shortcuts |
|
||||
| **Team** | Solo developer — Patrick; nights and weekends only; full-time at ADP Germany |
|
||||
| **Timeline** | Phase 0 target: 8 weeks; Phase 1 target: 16 weeks total from project start |
|
||||
| **Budget** | Infrastructure: Hetzner €5–20/month; no team salary cost |
|
||||
|
||||
### Assumptions
|
||||
|
||||
- German CSCs are willing to pay €29–€79/month for compliance software
|
||||
- Stripe will process subscriptions for compliance software (not cannabis sales) without restriction
|
||||
- Spring Boot 3.x is sufficiently adjacent to Patrick's Jakarta EE expertise to use without major ramp-up
|
||||
- PrimeFaces MVP is sufficient for beta validation — UI polish deferred to v2
|
||||
- CanG remains in force and CSC licensing continues in all major Bundesländer
|
||||
|
||||
---
|
||||
|
||||
## 6. Risk Register
|
||||
|
||||
| Risk | Probability | Impact | Mitigation |
|
||||
|------|-------------|--------|-----------|
|
||||
| **Advertising ban reinterpreted to include B2B SaaS** | Low | High | Obtain legal opinion from cannabis law specialist before launch (€300–500); strict no-discovery design enforced at architecture level |
|
||||
| **New German government rolls back or tightens CanG** | Medium | High | Modular architecture — compliance-only features can be extracted and pivoted to a general club management tool |
|
||||
| **Stripe blocks cannabis-adjacent businesses** | Medium | High | Position as "Vereinsverwaltungs-Software" (club management software); never process cannabis payments; test with Stripe before public launch |
|
||||
| **Clubs fail / licenses revoked** | Medium | Medium | Diversified customer base; per-month billing (easy cancellation); no annual lock-in required for MVP |
|
||||
| **DSGVO violation** | Low | Very High | EU-only hosting (Hetzner DE), DPA/AVV agreements before any live data, DSGVO-compliant privacy policy in German, member data export/deletion API from day one |
|
||||
|
||||
---
|
||||
|
||||
## 7. Budget & Resources
|
||||
|
||||
| Item | Cost | Notes |
|
||||
|------|------|-------|
|
||||
| **Development** | €0 (Patrick's time) | Nights/weekends; valued at opportunity cost only |
|
||||
| **Infrastructure — Hetzner VPS** | €5–20/month | German DC; scales with load |
|
||||
| **Infrastructure — PostgreSQL** | €0 (self-hosted on VPS) | Managed DB upgrade available when needed |
|
||||
| **Legal opinion** | €300–500 (one-time) | Cannabis law specialist; pre-launch requirement |
|
||||
| **Domain (cannamanage.de)** | ~€15/year | To be registered |
|
||||
| **Stripe fees** | 1.4% + €0.25 per transaction | EU cards; only on paid subscriptions |
|
||||
| **Email (Resend / Jakarta Mail)** | €0–10/month | Resend free tier for low volume |
|
||||
| **Sentry monitoring** | €0 (free tier) | Error tracking; Java SDK |
|
||||
| **Total pre-launch** | **~€600–700** | Including legal opinion |
|
||||
|
||||
---
|
||||
|
||||
## 8. Timeline Overview
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
title CannaManage Development Roadmap
|
||||
dateFormat YYYY-MM-DD
|
||||
axisFormat %b %Y
|
||||
|
||||
section Phase 0 — Foundation
|
||||
Spring Boot setup + JPA entities :p0a, 2026-04-07, 2w
|
||||
Core REST API (member, distribution) :p0b, after p0a, 2w
|
||||
Admin portal PrimeFaces :p0c, after p0b, 2w
|
||||
Limit enforcement + PDF report :p0d, after p0c, 2w
|
||||
|
||||
section Phase 1 — MVP
|
||||
Member portal :p1a, after p0d, 2w
|
||||
Stock management + contamination recall :p1b, after p1a, 2w
|
||||
Stripe billing integration :p1c, after p1b, 2w
|
||||
DSGVO + beta launch (5 clubs) :p1d, after p1c, 2w
|
||||
|
||||
section Phase 2 — Launch
|
||||
Payment flows + email notifications :p2a, after p1d, 4w
|
||||
Marketing site + legal review :p2b, after p2a, 4w
|
||||
Soft launch to club community :milestone, after p2b, 0d
|
||||
|
||||
section Phase 3 — Growth
|
||||
PrimeFaces → Next.js migration :p3a, 2026-12-01, 8w
|
||||
PWA mobile :p3b, after p3a, 4w
|
||||
Template marketplace + referral :p3c, after p3b, 8w
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Legal Framework
|
||||
|
||||
### Key CanG Provisions
|
||||
|
||||
| Provision | Content | Product Implication |
|
||||
|-----------|---------|---------------------|
|
||||
| **§2 CanG** | Definitions — Anbauvereinigung, Mitglied | Data model must align with statutory definitions of club and member |
|
||||
| **§§15–26 CanG** | Anbauvereinigungen — formation, rights, obligations | Club registration flow must capture legally required club attributes |
|
||||
| **§22 CanG** | Distribution limits: 25g/day, 50g/month per adult member | Hard enforcement in distribution service; cannot be overridden by admin |
|
||||
| **§23 CanG** | Under-21 restrictions: 30g/month max, max 10% THC | Age flag on member entity; separate limit enforcement path for restricted category |
|
||||
| **§§6–7 CanG** | **Total advertising and sponsoring ban** for cannabis and Anbauvereinigungen | **No public club discovery. No stock visible to non-members. No club listings.** Architecture constraint. |
|
||||
| **§26 CanG** | Documentation and reporting obligations | Compliance export module is a legal requirement, not an optional feature |
|
||||
| **§27 CanG** | Prevention officer requirements | Prevention officer fields mandatory in club setup; not optional |
|
||||
|
||||
### DSGVO Obligations
|
||||
|
||||
- All personal data stored on EU infrastructure (Hetzner DE)
|
||||
- Data processing agreement (AVV) required with each club before live data entry
|
||||
- Member data export endpoint required (Art. 20 DSGVO — data portability)
|
||||
- Member data deletion endpoint required (Art. 17 DSGVO — right to erasure)
|
||||
- Privacy policy in German, DSGVO-compliant, published before launch
|
||||
|
||||
---
|
||||
|
||||
## 10. Sign-Off
|
||||
|
||||
| Role | Name | Date |
|
||||
|------|------|------|
|
||||
| **Project Sponsor** | Patrick Plate | 2026-04-06 |
|
||||
| **Lead Developer** | Patrick Plate | 2026-04-06 |
|
||||
| **Product Owner** | Patrick Plate | 2026-04-06 |
|
||||
|
||||
---
|
||||
|
||||
*Next review date: 2026-05-01 | Source: [STRATEGY.md](../STRATEGY.md)*
|
||||
@@ -0,0 +1,467 @@
|
||||
# CannaManage — User Stories & Acceptance Criteria
|
||||
|
||||
**Author:** Patrick Plate
|
||||
**Date:** 2026-04-06
|
||||
**Version:** 1.0
|
||||
**Status:** Draft for Review
|
||||
|
||||
---
|
||||
|
||||
## MoSCoW Summary
|
||||
|
||||
| Priority | Count | Release Target | Description |
|
||||
|----------|-------|----------------|-------------|
|
||||
| 🔴 **Must Have** | 14 (US-001–014) | MVP v1 | Core compliance loop; legally required features |
|
||||
| 🟡 **Should Have** | 4 (US-015–018) | v2 | Growth and retention features |
|
||||
| 🟢 **Could Have** | 4 (US-019–022) | v3 | Scale and differentiation features |
|
||||
| ⚫ **Won't Have (MVP)** | 3 (US-023–025) | Never / Post-legal-review | Explicitly excluded — legal or strategic |
|
||||
|
||||
---
|
||||
|
||||
## Must Have — MVP v1
|
||||
|
||||
### Club Admin Stories
|
||||
|
||||
---
|
||||
|
||||
### US-001: Register Club and Complete Setup Wizard
|
||||
|
||||
**As a** Club Admin, **I want to** register my Anbauvereinigung and complete a guided setup wizard, **so that** my club is correctly configured with all legally required attributes before any members are added.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can register with email + password; email confirmation required before accessing the system
|
||||
- [ ] AC2: Setup wizard collects: club name, registered address, founding date, Vereinsregisternummer (if available), maximum membership count
|
||||
- [ ] AC3: Wizard requires designation of a Prevention Officer (name, contact) — field is mandatory, cannot be skipped
|
||||
- [ ] AC4: Wizard requires acceptance of DSGVO data processing agreement (AVV) before any member data can be entered
|
||||
- [ ] AC5: Completing the wizard provisions the club's isolated tenant environment (all subsequent data scoped to this club only)
|
||||
- [ ] AC6: Admin receives a welcome email with login link after successful setup
|
||||
- [ ] AC7: Incomplete wizard state is saved — admin can resume from last completed step
|
||||
|
||||
**Notes:** The AVV acceptance (AC4) is a legal prerequisite for handling member personal data under DSGVO. It must be timestamped and stored.
|
||||
|
||||
---
|
||||
|
||||
### US-002: Add and Remove Members with Age Verification
|
||||
|
||||
**As a** Club Admin, **I want to** add and remove club members with age verification, **so that** the member roster is accurate and the system can apply the correct distribution limits per member.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can add a member with: full name, date of birth, email (optional), membership start date, member ID (auto-generated or manual)
|
||||
- [ ] AC2: System rejects members with date of birth indicating age < 18
|
||||
- [ ] AC3: Members aged 18–21 are automatically flagged as "Restricted (§23 CanG)" — this flag drives reduced quantity limits
|
||||
- [ ] AC4: Admin can deactivate (soft-delete) a member; deactivated members cannot receive distributions but their historical records are preserved
|
||||
- [ ] AC5: Admin can permanently delete a member record (DSGVO Art. 17 right to erasure); system warns if member has distribution history and requires explicit confirmation
|
||||
- [ ] AC6: Member list is searchable by name and filterable by status (active / restricted / deactivated)
|
||||
- [ ] AC7: Total active member count is visible on the dashboard and in the member list header
|
||||
|
||||
**Notes:** Hard deletion (AC5) must cascade correctly — distribution records referencing the member must be anonymised, not deleted, to preserve the compliance audit trail.
|
||||
|
||||
---
|
||||
|
||||
### US-003: Record a Distribution
|
||||
|
||||
**As a** Club Admin, **I want to** record each cannabis distribution to a member, **so that** every handout is documented as required by §26 CanG and the member's consumption is tracked.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can log a distribution by selecting: member (search/autocomplete), strain, weight in grams (decimal, e.g. 3.5g), batch, date and time
|
||||
- [ ] AC2: System pre-fills date/time with current timestamp; admin can override
|
||||
- [ ] AC3: If the distribution would cause the member to exceed their daily limit (25g), the system displays a prominent warning and requires explicit override confirmation
|
||||
- [ ] AC4: If the distribution would cause the member to exceed their monthly limit (50g adult / 30g restricted), the system **blocks** the entry and displays the reason
|
||||
- [ ] AC5: For restricted members (§23), system additionally validates that the selected strain's THC percentage is ≤ 10% (if THC% is recorded on the batch)
|
||||
- [ ] AC6: Successfully saved distributions appear immediately in the distribution log and update the member's monthly counter
|
||||
- [ ] AC7: Distribution records are immutable after creation — admin can only add a correction note, not edit the original record
|
||||
|
||||
**Notes:** Immutability (AC7) is essential for audit integrity. Correction notes are the appropriate mechanism for errors.
|
||||
|
||||
---
|
||||
|
||||
### US-004: View and Enforce Distribution Limits
|
||||
|
||||
**As a** Club Admin, **I want to** view each member's current distribution totals and remaining quota, **so that** I can verify limits at a glance before and after recording distributions.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Each member's detail view shows: distributions this month (total grams), daily total for today, remaining monthly quota, and limit category (Adult 50g / Restricted 30g)
|
||||
- [ ] AC2: Remaining quota is displayed as a progress bar (visual indicator of how close to the limit)
|
||||
- [ ] AC3: Members who have reached or exceeded their monthly limit are visually flagged in the member list (e.g., red badge)
|
||||
- [ ] AC4: Members who have consumed > 80% of their monthly limit show a warning indicator (e.g., amber badge)
|
||||
- [ ] AC5: Monthly counters reset automatically on the first of each calendar month
|
||||
- [ ] AC6: System applies §22 limits (50g/month, 25g/day) for adults and §23 limits (30g/month) for restricted members — these cannot be changed by the admin
|
||||
|
||||
**Notes:** The limits in AC6 are statutory and must be hardcoded, not configurable per club.
|
||||
|
||||
---
|
||||
|
||||
### US-005: Manage Stock (Strains, Quantities, Batches)
|
||||
|
||||
**As a** Club Admin, **I want to** manage my club's cannabis stock including strains, batch information, and quantities, **so that** I know what is available for distribution and can track batch provenance for contamination purposes.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can create a strain with: name, THC% (optional), CBD% (optional), variety type (Indica/Sativa/Hybrid)
|
||||
- [ ] AC2: Admin can create a batch linked to a strain with: batch ID (auto-generated), quantity in grams, harvest date (optional), grow cycle reference (optional)
|
||||
- [ ] AC3: Each distribution recorded reduces the associated batch's available quantity
|
||||
- [ ] AC4: Admin can manually adjust stock quantity with a reason note (e.g., "lab sample", "disposal")
|
||||
- [ ] AC5: Admin is warned (but not blocked) when a batch's available quantity drops below a configurable threshold (default: 100g)
|
||||
- [ ] AC6: Stock overview page shows all active batches with: strain name, batch ID, quantity available, quantity distributed to date
|
||||
- [ ] AC7: Depleted batches (quantity = 0) are automatically moved to an "archived" view
|
||||
|
||||
**Notes:** Batch tracking is required for contamination recall (US-009). The batch ID must be immutable once created.
|
||||
|
||||
---
|
||||
|
||||
### US-006: View Admin Dashboard
|
||||
|
||||
**As a** Club Admin, **I want to** see a summary dashboard when I log in, **so that** I have an at-a-glance overview of club activity and can identify anything requiring attention.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Dashboard displays: total active members, members at/near their monthly limit (count), total distributions this calendar month (grams), active stock level (total grams across all batches)
|
||||
- [ ] AC2: Dashboard shows a count of members in the "restricted §23" category separately
|
||||
- [ ] AC3: Dashboard highlights any batches flagged as contaminated (contamination alert count)
|
||||
- [ ] AC4: Dashboard includes a recent activity feed (last 10 distributions: member name, strain, weight, time)
|
||||
- [ ] AC5: All dashboard data reflects the admin's own club only — never cross-tenant data
|
||||
- [ ] AC6: Dashboard loads in < 3 seconds on Hetzner VPS hardware
|
||||
|
||||
**Notes:** Keep the dashboard simple for MVP — a single page with widgets. No charts required for v1.
|
||||
|
||||
---
|
||||
|
||||
### US-007: Export Monthly Compliance Report (PDF + CSV)
|
||||
|
||||
**As a** Club Admin, **I want to** export a monthly compliance report as PDF and CSV, **so that** I can fulfil my documentation and reporting obligations under §26 CanG.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can select any calendar month/year and generate a compliance report
|
||||
- [ ] AC2: PDF report contains: club name, reporting period, total distributions (count and weight), distribution detail table (member ID, strain, batch, weight, date/time), stock summary
|
||||
- [ ] AC3: Member names in the PDF are replaced with member IDs to minimise PII exposure in the report document (actual name lookup available to the club separately)
|
||||
- [ ] AC4: CSV export contains full distribution log for the selected period with headers: member_id, strain, batch_id, weight_g, distribution_date, distribution_time
|
||||
- [ ] AC5: PDF is generated server-side using iText 7 (no client-side rendering dependency)
|
||||
- [ ] AC6: Export completes in < 10 seconds for a month with up to 5,000 distribution records
|
||||
- [ ] AC7: Generated reports are not stored on the server — they are streamed directly to the browser as a download
|
||||
|
||||
**Notes:** Not storing reports (AC7) reduces data exposure risk. The club is responsible for retaining their own copies.
|
||||
|
||||
---
|
||||
|
||||
### US-008: Export Member List for Inspections
|
||||
|
||||
**As a** Club Admin, **I want to** export the current member list, **so that** I can present it to authorities during an inspection as required by law.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can export the active member list as PDF and CSV at any time
|
||||
- [ ] AC2: Export includes: member ID, full name, date of birth, age category (Adult/Restricted §23), membership start date, current membership status
|
||||
- [ ] AC3: Export is timestamped with the generation date/time in the document
|
||||
- [ ] AC4: Admin is shown a DSGVO reminder before downloading (this document contains personal data — handle per your privacy obligations)
|
||||
- [ ] AC5: Export includes the club name and address in the header
|
||||
- [ ] AC6: Only active members are included by default; admin can optionally include deactivated members
|
||||
|
||||
**Notes:** This document contains significant PII. The DSGVO reminder (AC4) is important to keep admins legally aware.
|
||||
|
||||
---
|
||||
|
||||
### US-009: Trigger Contamination Alert for a Batch
|
||||
|
||||
**As a** Club Admin, **I want to** flag a batch as contaminated and immediately see all members who received from it, **so that** I can notify affected members and fulfil my contamination traceability obligations under CanG.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can mark any batch as "contaminated" with a reason note and timestamp
|
||||
- [ ] AC2: Immediately upon flagging, system displays a list of all members who received distributions from the contaminated batch (name, member ID, total grams received, dates received)
|
||||
- [ ] AC3: Contaminated batches are removed from the active distribution interface — admin cannot select them for new distributions
|
||||
- [ ] AC4: The dashboard shows a contamination alert badge whenever any active batch is flagged
|
||||
- [ ] AC5: Admin can export the affected member list as PDF and CSV (for authority notification)
|
||||
- [ ] AC6: Contamination status is immutable — once flagged, only a senior action (with confirmation) can reverse it; reversal is logged with reason
|
||||
|
||||
**Notes:** Contamination traceability is explicitly required by CanG. Response speed matters — the affected member list (AC2) must display without delay.
|
||||
|
||||
---
|
||||
|
||||
### US-010: Manage Prevention Officer Information
|
||||
|
||||
**As a** Club Admin, **I want to** record and update Prevention Officer (Präventionsbeauftragter) information, **so that** my club meets the mandatory requirement of §27 CanG.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Club profile includes a Prevention Officer section with fields: full name, contact email, contact phone, designation date
|
||||
- [ ] AC2: All four fields are required — the system warns if any is empty and marks the section as incomplete
|
||||
- [ ] AC3: Admin can update the Prevention Officer at any time; previous officer entries are retained in a change log (name, designation period)
|
||||
- [ ] AC4: The compliance report export (US-007) includes the current Prevention Officer name and contact in its header
|
||||
- [ ] AC5: Setup wizard (US-001) cannot be completed without entering Prevention Officer information
|
||||
|
||||
**Notes:** This is a statutory requirement, not optional. AC5 enforces that clubs cannot operate on the platform without this data.
|
||||
|
||||
---
|
||||
|
||||
### Member Portal Stories
|
||||
|
||||
---
|
||||
|
||||
### US-011: Login with Club-Issued Credentials
|
||||
|
||||
**As a** Club Member, **I want to** log in to the member portal using credentials issued by my club, **so that** I can access my personal information without the club admin needing to be present.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can generate login credentials (username + temporary password) for a member from the member management screen
|
||||
- [ ] AC2: Member receives credentials via a secure channel (displayed to admin for manual handoff in MVP; email in v2)
|
||||
- [ ] AC3: Member is required to change their temporary password on first login
|
||||
- [ ] AC4: Member login is scoped to their club only — they cannot access any other club's data or member list
|
||||
- [ ] AC5: Failed login attempts are rate-limited (5 attempts, then 15-minute lockout)
|
||||
- [ ] AC6: Member sessions expire after 24 hours of inactivity
|
||||
- [ ] AC7: Members cannot register themselves — accounts are always created by the Club Admin
|
||||
|
||||
**Notes:** AC7 is critical for CanG compliance — only verified, age-checked members should have portal access.
|
||||
|
||||
---
|
||||
|
||||
### US-012: View Personal Distribution History
|
||||
|
||||
**As a** Club Member, **I want to** view my personal distribution history, **so that** I can track what I have received from the club.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Member can view all their distributions in reverse chronological order: date/time, strain, weight (grams), batch ID
|
||||
- [ ] AC2: Current calendar month distributions are shown first, with a clear monthly subtotal
|
||||
- [ ] AC3: Member can filter history by month/year
|
||||
- [ ] AC4: Member sees only their own distribution history — no other member's data is accessible
|
||||
- [ ] AC5: History is read-only — members cannot edit or delete distribution records
|
||||
|
||||
---
|
||||
|
||||
### US-013: View Current Stock Availability
|
||||
|
||||
**As a** Club Member, **I want to** see what strains are currently available at the club, **so that** I know what I can request on my next visit.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Member portal shows a stock list with: strain name, variety type (Indica/Sativa/Hybrid), THC% (if recorded), availability status (Available / Low Stock / Unavailable)
|
||||
- [ ] AC2: Exact batch quantities are NOT shown to members — only availability status
|
||||
- [ ] AC3: Only strains with available stock (quantity > 0) are shown as "Available"
|
||||
- [ ] AC4: Strains with stock below the admin-configured low-stock threshold are shown as "Low Stock"
|
||||
- [ ] AC5: For restricted members (§23 CanG), strains with THC > 10% are shown with a "Not available to you" indicator rather than hidden (transparency about why)
|
||||
- [ ] AC6: Stock view is refreshed in real time — no stale cache longer than 5 minutes
|
||||
|
||||
**Notes:** AC2 is important — showing exact quantities could constitute advertising for the club's stock. Only availability status is shown.
|
||||
|
||||
---
|
||||
|
||||
### US-014: View Remaining Monthly Quota
|
||||
|
||||
**As a** Club Member, **I want to** see my remaining monthly quota, **so that** I can plan my distributions and stay within my legal limits.
|
||||
|
||||
**Priority:** Must Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Member portal homepage prominently displays: consumed this month (grams), remaining quota (grams), monthly limit (grams), days remaining in current month
|
||||
- [ ] AC2: Quota is displayed as a progress bar with colour coding: green (< 50% used), amber (50–80% used), red (> 80% used)
|
||||
- [ ] AC3: Members in the restricted §23 category see their 30g/month limit (not the 50g adult limit)
|
||||
- [ ] AC4: Daily limit status is also visible: consumed today (grams) vs. 25g daily cap
|
||||
- [ ] AC5: Quota resets display on the first of each calendar month — confirmed visually (e.g., "Resets in X days")
|
||||
|
||||
---
|
||||
|
||||
## Should Have — v2
|
||||
|
||||
---
|
||||
|
||||
### US-015: Process Membership Fee Payments via Stripe
|
||||
|
||||
**As a** Club Admin, **I want to** collect membership fees from members via Stripe, **so that** fee collection is automated and documented without manual bank transfers.
|
||||
|
||||
**Priority:** Should Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can configure an annual membership fee amount for their club
|
||||
- [ ] AC2: Members can pay via Stripe-hosted checkout (card payment)
|
||||
- [ ] AC3: Stripe subscription or one-time payment for annual fee — admin configures which model
|
||||
- [ ] AC4: Payment confirmation is logged against the member record with date and amount
|
||||
- [ ] AC5: Admin can view payment status per member (paid / pending / overdue)
|
||||
- [ ] AC6: No cannabis product payments are ever processed through this system — fee is for club membership only
|
||||
|
||||
**Notes:** Stripe position: membership fees for registered non-profit clubs (Vereinsbeiträge) are standard use case. AC6 must be enforced at system design level.
|
||||
|
||||
---
|
||||
|
||||
### US-016: Manage Automated Waiting List
|
||||
|
||||
**As a** Club Admin, **I want to** manage a waiting list for new membership applicants, **so that** I can process applications in order while respecting the club's maximum membership count.
|
||||
|
||||
**Priority:** Should Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can set a maximum member count for the club (from setup wizard or settings)
|
||||
- [ ] AC2: When member count reaches maximum, new applicants are added to a waiting list with timestamp
|
||||
- [ ] AC3: Waiting list is FIFO — applicants are offered membership in order of application
|
||||
- [ ] AC4: Admin can notify the next waiting list applicant (email notification — v2 dependency)
|
||||
- [ ] AC5: Admin can remove applicants from the waiting list
|
||||
- [ ] AC6: Waiting list count is visible on the admin dashboard
|
||||
|
||||
---
|
||||
|
||||
### US-017: Receive Email and SMS Notifications
|
||||
|
||||
**As a** Club Member, **I want to** receive email (and optionally SMS) notifications for key events, **so that** I am informed without needing to log in to the portal.
|
||||
|
||||
**Priority:** Should Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Member receives email notification when their distribution is recorded by the admin
|
||||
- [ ] AC2: Member receives email when their monthly quota reaches 80% consumed
|
||||
- [ ] AC3: Member receives email when a batch they received from is flagged as contaminated
|
||||
- [ ] AC4: Admin receives email when any member's quota is exceeded (should not happen, but safety net)
|
||||
- [ ] AC5: SMS notifications are optional and require member opt-in; email is default
|
||||
- [ ] AC6: All notification emails are sent in German (language is not configurable in v2)
|
||||
- [ ] AC7: Members can manage notification preferences (opt out of non-mandatory notifications)
|
||||
|
||||
---
|
||||
|
||||
### US-018: Track Multi-Strain Grow Cycles
|
||||
|
||||
**As a** Club Admin, **I want to** track grow cycles linked to batches, **so that** I have full provenance from grow start to distribution.
|
||||
|
||||
**Priority:** Should Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can create a grow cycle with: cycle ID, strain, start date, expected harvest date, grow area (optional), notes
|
||||
- [ ] AC2: Batches can be linked to a grow cycle
|
||||
- [ ] AC3: Grow cycle view shows: all batches produced, total yield, grow duration
|
||||
- [ ] AC4: Closed grow cycles (harvest complete) are archived but remain searchable
|
||||
- [ ] AC5: Grow cycle data is included in the monthly compliance report (batch provenance section)
|
||||
|
||||
---
|
||||
|
||||
## Could Have — v3
|
||||
|
||||
---
|
||||
|
||||
### US-019: Access Mobile PWA
|
||||
|
||||
**As a** Club Member, **I want to** use CannaManage on my smartphone without installing an app, **so that** I can check my quota and stock on the go.
|
||||
|
||||
**Priority:** Could Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: The member portal is fully responsive and usable on mobile viewport sizes (320px and up)
|
||||
- [ ] AC2: The app can be added to the home screen (PWA manifest, service worker, offline cache for quota display)
|
||||
- [ ] AC3: Core member portal features (quota, distribution history, stock view) work in offline mode with cached data
|
||||
- [ ] AC4: Admin portal is also responsive (admin-on-the-go distribution logging)
|
||||
- [ ] AC5: No app store submission required — pure PWA
|
||||
|
||||
---
|
||||
|
||||
### US-020: Support Multi-Location Club
|
||||
|
||||
**As a** Club Admin, **I want to** manage a club with multiple distribution locations, **so that** members can pick up from different sites and all distributions are consolidated.
|
||||
|
||||
**Priority:** Could Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Admin can define multiple locations (name, address) for one club
|
||||
- [ ] AC2: Distributions are recorded with a location tag
|
||||
- [ ] AC3: Stock is managed per location or shared — admin configures which model
|
||||
- [ ] AC4: Compliance reports can be generated per location or consolidated for the whole club
|
||||
- [ ] AC5: Members are assigned a primary location but can receive from any location within quota limits
|
||||
|
||||
---
|
||||
|
||||
### US-021: Download Legal Document Templates
|
||||
|
||||
**As a** Club Admin, **I want to** download standardised legal document templates (Satzung, Jugendschutzkonzept), **so that** I can fulfil my legal obligations without hiring a lawyer for every document.
|
||||
|
||||
**Priority:** Could Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: Template library is accessible from the admin portal (separate from compliance exports)
|
||||
- [ ] AC2: Available templates include: Vereinssatzung (club charter), Jugendschutzkonzept (youth protection concept), DSGVO Datenschutzerklärung
|
||||
- [ ] AC3: Templates are pre-filled with club-specific data (name, address, Prevention Officer) where applicable
|
||||
- [ ] AC4: Templates are available as DOCX (editable) and PDF (final version)
|
||||
- [ ] AC5: Template library is a paid add-on (€49 one-time or included in Professional/Enterprise plan)
|
||||
|
||||
---
|
||||
|
||||
### US-022: Integrate with Authority Reporting Portals
|
||||
|
||||
**As a** Club Admin, **I want to** submit compliance reports directly to authority portals via CannaManage, **so that** I save time and avoid transcription errors in authority submissions.
|
||||
|
||||
**Priority:** Could Have
|
||||
**Acceptance Criteria:**
|
||||
- [ ] AC1: System can detect available authority portals by Bundesland (state)
|
||||
- [ ] AC2: Admin can initiate a report submission from within CannaManage
|
||||
- [ ] AC3: Submission status is tracked (submitted, acknowledged, rejected) per report
|
||||
- [ ] AC4: System retries failed submissions automatically (up to 3 times)
|
||||
- [ ] AC5: This feature is only activated once at least one Bundesland has a machine-readable submission portal
|
||||
|
||||
**Notes:** Authority portals may not exist in v3 timeline — this is aspirational and depends on government digitalisation progress.
|
||||
|
||||
---
|
||||
|
||||
## Won't Have — MVP (Explicitly Excluded)
|
||||
|
||||
---
|
||||
|
||||
### US-023: Public Club Discovery — "Find Clubs Near You"
|
||||
|
||||
**As a** Public User, I want to find cannabis clubs near my location.
|
||||
|
||||
**Priority:** Won't Have (MVP)
|
||||
**Reason:** **Explicitly illegal under CanG §§6–7.** The advertising and sponsoring ban covers any feature that functions as advertising for Anbauvereinigungen to the general public. A public club directory constitutes advertising for clubs. This feature will never be built in any form on this platform.
|
||||
|
||||
**Acceptance Criteria:** *None — this feature is permanently excluded.*
|
||||
|
||||
**Notes:** This is not a commercial decision. It is a **legal constraint** hardcoded into the product architecture. No public-facing club listing, no map, no search, no "register your club publicly."
|
||||
|
||||
---
|
||||
|
||||
### US-024: Cannabis E-Commerce or Payment for Cannabis Products
|
||||
|
||||
**As a** Club Member, I want to purchase cannabis through the CannaManage platform.
|
||||
|
||||
**Priority:** Won't Have (MVP)
|
||||
**Reason:** **Illegal.** Cannabis sales are not the legal model for Anbauvereinigungen under CanG. Payment for cannabis products would violate German law and immediately trigger Stripe account termination. CannaManage processes membership fee payments only — not cannabis product payments, ever.
|
||||
|
||||
**Acceptance Criteria:** *None — permanently excluded.*
|
||||
|
||||
---
|
||||
|
||||
### US-025: Non-EU Data Storage
|
||||
|
||||
**As a** Club Admin, I want my club's data stored on the cheapest/fastest infrastructure, including non-EU servers.
|
||||
|
||||
**Priority:** Won't Have (MVP)
|
||||
**Reason:** **DSGVO violation.** Club member data includes personal data (name, date of birth, consumption records). Storing this outside the EU without a valid adequacy decision or standard contractual clauses violates Art. 44–49 DSGVO. All data remains on Hetzner DE datacenters.
|
||||
|
||||
**Acceptance Criteria:** *None — permanently excluded.*
|
||||
|
||||
---
|
||||
|
||||
## Acceptance Criteria Traceability Matrix
|
||||
|
||||
| Story | Role | Phase | Legal Basis | Key Risk |
|
||||
|-------|------|-------|-------------|----------|
|
||||
| US-001 | Club Admin | MVP | DSGVO (AVV) | Clubs operating without AVV |
|
||||
| US-002 | Club Admin | MVP | §22–23 CanG | Under-21 age verification gaps |
|
||||
| US-003 | Club Admin | MVP | §26 CanG | Distribution limit bypass |
|
||||
| US-004 | Club Admin | MVP | §22–23 CanG | Incorrect limit category applied |
|
||||
| US-005 | Club Admin | MVP | §26 CanG (batch traceability) | Inaccurate stock → wrong quota available |
|
||||
| US-006 | Club Admin | MVP | — | Cross-tenant data leak |
|
||||
| US-007 | Club Admin | MVP | §26 CanG | Incomplete report → authority rejection |
|
||||
| US-008 | Club Admin | MVP | §26 CanG | Outdated member list at inspection |
|
||||
| US-009 | Club Admin | MVP | CanG (contamination traceability) | Delayed recall notification |
|
||||
| US-010 | Club Admin | MVP | §27 CanG | Missing officer → club licence risk |
|
||||
| US-011 | Club Member | MVP | DSGVO | Unauthorised member account creation |
|
||||
| US-012 | Club Member | MVP | DSGVO (Art. 15 access) | Cross-member data exposure |
|
||||
| US-013 | Club Member | MVP | §§6–7 CanG (no advertising) | Over-disclosure of stock data |
|
||||
| US-014 | Club Member | MVP | §22–23 CanG | Member unaware of impending limit breach |
|
||||
| US-015 | Club Admin | v2 | — | Stripe cannabis-adjacent policy |
|
||||
| US-016 | Club Admin | v2 | — | Waiting list ordering errors |
|
||||
| US-017 | Club Member | v2 | DSGVO (email marketing consent) | Spam / opt-out compliance |
|
||||
| US-018 | Club Admin | v2 | §26 CanG (provenance) | Batch-grow linkage gaps |
|
||||
| US-019 | Club Member | v3 | — | Offline cache staleness |
|
||||
| US-020 | Club Admin | v3 | — | Stock isolation complexity |
|
||||
| US-021 | Club Admin | v3 | — | Template legal accuracy |
|
||||
| US-022 | Club Admin | v3 | §26 CanG | Portal API non-existence |
|
||||
| US-023 | *(none)* | Never | **Illegal §§6–7 CanG** | Platform shutdown risk |
|
||||
| US-024 | *(none)* | Never | **Illegal** | Stripe termination + criminal liability |
|
||||
| US-025 | *(none)* | Never | **DSGVO Art. 44–49** | Regulatory fine + club data breach |
|
||||
|
||||
---
|
||||
|
||||
*Source: [STRATEGY.md](../STRATEGY.md) | Related: [01-PROJECT-CHARTER.md](./01-PROJECT-CHARTER.md)*
|
||||
@@ -0,0 +1,504 @@
|
||||
# 03 — System Architecture
|
||||
|
||||
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
|
||||
**Phase:** 2 of 5 — Architecture & Data Model
|
||||
**Stack:** Spring Boot 3.x (Java 21) · JPA/Hibernate · PostgreSQL · PrimeFaces JSF MVP → Next.js v2
|
||||
**Last updated:** 2026-04-06
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
AdminBrowser["🖥️ Browser — Admin Portal"]
|
||||
MemberBrowser["🖥️ Browser — Member Portal"]
|
||||
|
||||
JSF["PrimeFaces / JSF Frontend\n(Spring MVC embedded)"]
|
||||
|
||||
AdminBrowser -->|HTTP/S| JSF
|
||||
MemberBrowser -->|HTTP/S| JSF
|
||||
|
||||
JSF -->|REST calls| Backend
|
||||
|
||||
subgraph Backend ["☕ Spring Boot 3.x Application (Java 21)"]
|
||||
REST["REST API Layer\n/api/v1/"]
|
||||
Service["Service Layer\n(ComplianceService, ReportService…)"]
|
||||
JPA["JPA / Hibernate\nRepositories"]
|
||||
Security["Spring Security + JWT\nTenant Interceptor"]
|
||||
|
||||
REST --> Service
|
||||
Service --> JPA
|
||||
Security --> REST
|
||||
end
|
||||
|
||||
JPA -->|JDBC| PG[("🐘 PostgreSQL 16\nmulti-tenant via tenant_id")]
|
||||
Backend -->|Stripe Java SDK| Stripe["💳 Stripe\n(payment processing)"]
|
||||
Backend -->|Jakarta Mail| Mail["📧 Jakarta Mail\n(email notifications)"]
|
||||
Backend -->|iText 7| PDF["📄 iText 7\n(PDF report generation)"]
|
||||
|
||||
Flyway["🔄 Flyway\n(DB migrations)"] -->|applies migrations| PG
|
||||
|
||||
subgraph Hetzner ["🖧 Hetzner VPS — Docker Compose"]
|
||||
Backend
|
||||
PG
|
||||
Nginx["🔒 Nginx\n(reverse proxy + TLS)"]
|
||||
end
|
||||
|
||||
JSF --> Nginx
|
||||
Nginx --> Backend
|
||||
```
|
||||
|
||||
### Component Responsibilities
|
||||
|
||||
| Component | Technology | Role |
|
||||
|---|---|---|
|
||||
| Admin Portal | PrimeFaces JSF (→ Next.js v2) | Club management UI |
|
||||
| Member Portal | PrimeFaces JSF (→ Next.js v2) | Member quota & history UI |
|
||||
| REST API | Spring Boot 3.x / Spring MVC | All business logic endpoints |
|
||||
| Auth | Spring Security 6 + JJWT | Stateless JWT authentication |
|
||||
| ORM | JPA / Hibernate 6 | Entity persistence, tenant filtering |
|
||||
| Database | PostgreSQL 16 | Primary data store (multi-tenant) |
|
||||
| Migrations | Flyway | Versioned schema management |
|
||||
| Payments | Stripe Java SDK | Club subscription billing |
|
||||
| Email | Jakarta Mail / Spring Mail | Welcome emails, recall alerts |
|
||||
| PDF | iText 7 | Compliance report generation |
|
||||
| Hosting | Hetzner CX21 VPS + Docker Compose | Production deployment |
|
||||
|
||||
---
|
||||
|
||||
## 2. Multi-Tenancy Strategy
|
||||
|
||||
### Approach: Shared Schema with Row-Level Filtering
|
||||
|
||||
Every JPA entity carries a `tenant_id` column (UUID, `NOT NULL`). A single PostgreSQL database hosts all clubs — row-level filtering enforces data isolation at the application layer.
|
||||
|
||||
**Why shared schema (not separate schema/DB per tenant)?**
|
||||
- Lower operational overhead for an MVP with < 500 clubs
|
||||
- Single Flyway migration path across all tenants
|
||||
- Simpler connection pooling (one pool, not N)
|
||||
- Acceptable security risk when `tenant_id` filter is enforced at the service layer
|
||||
|
||||
### Tenant Resolution
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
└─ Spring Security Filter: extract JWT → resolve tenant_id
|
||||
└─ TenantContext.setCurrentTenant(tenantId) // ThreadLocal
|
||||
└─ JPA @Where filter applied on every entity query
|
||||
```
|
||||
|
||||
### Code Pattern — Tenant-Aware Base Entity
|
||||
|
||||
```java
|
||||
// AbstractTenantEntity.java (pseudocode)
|
||||
@MappedSuperclass
|
||||
@FilterDef(
|
||||
name = "tenantFilter",
|
||||
parameters = @ParamDef(name = "tenantId", type = UUID.class)
|
||||
)
|
||||
@Filter(name = "tenantFilter", condition = "tenant_id = :tenantId")
|
||||
public abstract class AbstractTenantEntity {
|
||||
|
||||
@Column(name = "tenant_id", nullable = false, updatable = false)
|
||||
private UUID tenantId;
|
||||
|
||||
@PrePersist
|
||||
void injectTenant() {
|
||||
this.tenantId = TenantContext.getCurrentTenant();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```java
|
||||
// TenantFilterInterceptor.java (pseudocode)
|
||||
@Component
|
||||
public class TenantFilterInterceptor implements HandlerInterceptor {
|
||||
|
||||
@Autowired EntityManager em;
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest req, ...) {
|
||||
UUID tenantId = TenantContext.getCurrentTenant();
|
||||
Session session = em.unwrap(Session.class);
|
||||
session.enableFilter("tenantFilter")
|
||||
.setParameter("tenantId", tenantId);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Invariants enforced:**
|
||||
- `tenant_id` is set at `@PrePersist` — never accepted from user input
|
||||
- `tenant_id` is `updatable = false` — cannot be changed after creation
|
||||
- Hibernate filter is enabled on every request thread before any query executes
|
||||
- All repository methods inherit the filter; raw JPQL queries must include `AND e.tenantId = :tenantId`
|
||||
|
||||
---
|
||||
|
||||
## 3. Authentication & Authorization
|
||||
|
||||
### JWT Token Flow
|
||||
|
||||
- **Access token:** 8-hour expiry, signed HS256, contains `sub` (userId), `role`, `tenantId`
|
||||
- **Refresh token:** 30-day expiry, stored in `users.refresh_token_hash` (hashed)
|
||||
- **Stateless:** No server-side session. JWT is verified on every request by `JwtAuthFilter`
|
||||
|
||||
### Roles
|
||||
|
||||
| Role | Description | Access |
|
||||
|---|---|---|
|
||||
| `ROLE_CLUB_ADMIN` | Club administrator | Full club management, all members, reports, distributions |
|
||||
| `ROLE_MEMBER` | Club member | Own quota, own distribution history |
|
||||
| `ROLE_PREVENTION_OFFICER` | Designated prevention officer | Member under-21 reports, prevention data |
|
||||
|
||||
### Service-Layer Authorization Example
|
||||
|
||||
```java
|
||||
@Service
|
||||
public class DistributionService {
|
||||
|
||||
@PreAuthorize("hasRole('CLUB_ADMIN')")
|
||||
public Distribution recordDistribution(RecordDistributionRequest req) { ... }
|
||||
|
||||
@PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId")
|
||||
public QuotaStatus getMyQuota(UUID memberId) { ... }
|
||||
|
||||
@PreAuthorize("hasAnyRole('CLUB_ADMIN', 'PREVENTION_OFFICER')")
|
||||
public List<Member> getUnder21Members() { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Member Login Sequence
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant B as Browser
|
||||
participant API as Spring Boot /api/v1/auth/login
|
||||
participant DB as PostgreSQL (users table)
|
||||
participant JWT as JwtService
|
||||
|
||||
B->>API: POST /api/v1/auth/login {email, password}
|
||||
API->>DB: SELECT * FROM users WHERE email = ? AND active = true
|
||||
DB-->>API: UserEntity (password_hash, role, tenant_id, member_id)
|
||||
API->>API: BCrypt.verify(password, password_hash)
|
||||
alt Invalid credentials
|
||||
API-->>B: 401 Unauthorized
|
||||
else Valid
|
||||
API->>JWT: generateAccessToken(userId, role, tenantId) → 8h
|
||||
API->>JWT: generateRefreshToken(userId) → 30d
|
||||
API->>DB: UPDATE users SET refresh_token_hash = ?, last_login = NOW()
|
||||
DB-->>API: OK
|
||||
JWT-->>API: accessToken, refreshToken
|
||||
API-->>B: 200 { accessToken, refreshToken, expiresIn: 28800 }
|
||||
end
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Data Model (JPA Entities)
|
||||
|
||||
### Entity-Relationship Diagram
|
||||
|
||||
```mermaid
|
||||
erDiagram
|
||||
Club {
|
||||
UUID id PK
|
||||
UUID tenant_id
|
||||
string name
|
||||
string address
|
||||
string license_number
|
||||
int max_members
|
||||
timestamp created_at
|
||||
enum status
|
||||
}
|
||||
|
||||
Member {
|
||||
UUID id PK
|
||||
UUID tenant_id
|
||||
UUID club_id FK
|
||||
string first_name
|
||||
string last_name
|
||||
string email
|
||||
date date_of_birth
|
||||
date membership_date
|
||||
string membership_number
|
||||
enum status
|
||||
boolean is_under_21
|
||||
boolean prevention_officer
|
||||
}
|
||||
|
||||
Strain {
|
||||
UUID id PK
|
||||
UUID tenant_id
|
||||
string name
|
||||
decimal thc_percentage
|
||||
decimal cbd_percentage
|
||||
string description
|
||||
}
|
||||
|
||||
Batch {
|
||||
UUID id PK
|
||||
UUID tenant_id
|
||||
UUID strain_id FK
|
||||
decimal quantity_grams
|
||||
date harvest_date
|
||||
string batch_code
|
||||
enum status
|
||||
boolean contamination_flag
|
||||
}
|
||||
|
||||
Distribution {
|
||||
UUID id PK
|
||||
UUID tenant_id
|
||||
UUID member_id FK
|
||||
UUID batch_id FK
|
||||
decimal quantity_grams
|
||||
timestamp distributed_at
|
||||
UUID recorded_by FK
|
||||
string notes
|
||||
boolean immutable
|
||||
}
|
||||
|
||||
MonthlyQuota {
|
||||
UUID id PK
|
||||
UUID tenant_id
|
||||
UUID member_id FK
|
||||
int year
|
||||
int month
|
||||
decimal total_distributed
|
||||
decimal max_allowed
|
||||
}
|
||||
|
||||
StockMovement {
|
||||
UUID id PK
|
||||
UUID tenant_id
|
||||
UUID batch_id FK
|
||||
enum movement_type
|
||||
decimal quantity_grams
|
||||
string reason
|
||||
timestamp created_at
|
||||
}
|
||||
|
||||
User {
|
||||
UUID id PK
|
||||
UUID tenant_id
|
||||
UUID member_id FK
|
||||
string email
|
||||
string password_hash
|
||||
enum role
|
||||
timestamp last_login
|
||||
boolean active
|
||||
}
|
||||
|
||||
Club ||--o{ Member : "has members"
|
||||
Member ||--o{ Distribution : "receives"
|
||||
Member ||--o{ MonthlyQuota : "has quota per month"
|
||||
Member ||--o| User : "may have login"
|
||||
Strain ||--o{ Batch : "cultivated as"
|
||||
Batch ||--o{ Distribution : "distributed via"
|
||||
Batch ||--o{ StockMovement : "tracked in"
|
||||
Member ||--o{ Distribution : "recorded_by (admin)"
|
||||
```
|
||||
|
||||
### Relationship Notes
|
||||
|
||||
| Relationship | Cardinality | Notes |
|
||||
|---|---|---|
|
||||
| Club → Member | 1:N | `member.club_id` FK; max enforced by `Club.max_members` |
|
||||
| Member → Distribution | 1:N | Each distribution targets one member |
|
||||
| Member → MonthlyQuota | 1:N | One row per `(member_id, year, month)` — UNIQUE constraint |
|
||||
| Member → User | 1:0..1 | A member may have a portal login; admins may have no `member_id` |
|
||||
| Strain → Batch | 1:N | Each batch is one strain; a strain can have many batches |
|
||||
| Batch → Distribution | 1:N | A batch can supply many distributions |
|
||||
| Batch → StockMovement | 1:N | Every IN/OUT/RECALL against a batch is journaled |
|
||||
| Distribution.recorded_by → Member | N:1 | The admin who recorded it (audit trail) |
|
||||
|
||||
### Key Constraints
|
||||
|
||||
- `Distribution.immutable = true` by default — records are append-only; no UPDATE/DELETE allowed via API
|
||||
- `MonthlyQuota` has `UNIQUE(member_id, year, month)` — enforced at DB level
|
||||
- `Batch.contamination_flag` triggers recall workflow; `Batch.status = RECALLED` is the final state
|
||||
- All `tenant_id` columns: `NOT NULL`, `updatable = false`, set via `@PrePersist`
|
||||
- `Member.is_under_21` is derived from `date_of_birth` at registration and re-evaluated on birthday (scheduled job)
|
||||
|
||||
---
|
||||
|
||||
## 5. API Layer Design
|
||||
|
||||
### Base Path: `/api/v1/`
|
||||
|
||||
All endpoints are RESTful. JSON request/response bodies. JWT Bearer token required on all except `/auth/**`.
|
||||
|
||||
| Controller | Base Path | Key Endpoints |
|
||||
|---|---|---|
|
||||
| `AuthController` | `/api/v1/auth` | `POST /login`, `POST /refresh`, `POST /logout` |
|
||||
| `ClubController` | `/api/v1/clubs` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}` |
|
||||
| `MemberController` | `/api/v1/members` | `GET /`, `POST /`, `GET /{id}`, `PUT /{id}/status`, `GET /me/quota` |
|
||||
| `DistributionController` | `/api/v1/distributions` | `POST /`, `GET /?memberId=&month=&year=` |
|
||||
| `StockController` | `/api/v1/stock` | `GET /batches`, `POST /batches`, `POST /batches/{id}/recall` |
|
||||
| `ReportController` | `/api/v1/reports` | `GET /monthly?month=&year=` (PDF + CSV), `GET /members/{id}/history` |
|
||||
| `QuotaController` | `/api/v1/quota` | `GET /members/{id}/current`, `GET /members/{id}/history` |
|
||||
|
||||
### Standard HTTP conventions
|
||||
- `201 Created` + `Location` header on resource creation
|
||||
- `400 Bad Request` with `{ error, message, field? }` on validation failure
|
||||
- `403 Forbidden` when role/tenant check fails
|
||||
- `422 Unprocessable Entity` when compliance limits are breached (quota exceeded)
|
||||
- Pagination: `?page=0&size=20&sort=field,asc`
|
||||
|
||||
---
|
||||
|
||||
## 6. Compliance Engine
|
||||
|
||||
The `ComplianceService` is the heart of regulatory enforcement. It is called synchronously before every distribution is persisted. All operations run in a single `@Transactional` block with optimistic locking to prevent race conditions under concurrent distribution recording.
|
||||
|
||||
```java
|
||||
@Service
|
||||
@Transactional
|
||||
public class ComplianceService {
|
||||
|
||||
/**
|
||||
* Validates whether a distribution is legally permitted.
|
||||
*
|
||||
* Checks:
|
||||
* 1. Member is ACTIVE (not SUSPENDED or EXPELLED)
|
||||
* 2. Daily limit: total distributed today + requestedGrams ≤ 25g
|
||||
* 3. Monthly limit: MonthlyQuota.total_distributed + requestedGrams ≤ max_allowed
|
||||
* where max_allowed = 30g (under-21) or 50g (adult)
|
||||
* 4. Batch is AVAILABLE (not RECALLED or EXHAUSTED)
|
||||
* 5. Batch has sufficient stock
|
||||
*
|
||||
* @throws ComplianceLimitExceededException with remaining quota details
|
||||
* @throws MemberIneligibleException if member is not ACTIVE
|
||||
* @throws BatchUnavailableException if batch is recalled or exhausted
|
||||
*/
|
||||
public ComplianceCheckResult checkDistributionAllowed(
|
||||
UUID memberId, UUID batchId, BigDecimal quantityGrams) { ... }
|
||||
|
||||
/**
|
||||
* Returns remaining quota for the current calendar month.
|
||||
* Creates a MonthlyQuota row if none exists (lazy initialization).
|
||||
*
|
||||
* @return QuotaStatus { totalAllowed, totalUsed, remaining, isUnder21 }
|
||||
*/
|
||||
public QuotaStatus getMonthlyRemaining(UUID memberId) { ... }
|
||||
|
||||
/**
|
||||
* Flags a batch as RECALLED.
|
||||
* Returns all members who received distributions from this batch
|
||||
* so the caller can trigger notifications.
|
||||
* Writes a StockMovement(RECALL) entry.
|
||||
*
|
||||
* @return List<AffectedMember> { memberId, name, email, totalReceived }
|
||||
*/
|
||||
public List<AffectedMember> recallBatch(UUID batchId) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Race Condition Prevention
|
||||
|
||||
`MonthlyQuota` rows carry a JPA `@Version` column (optimistic locking). If two concurrent requests attempt to increment `total_distributed` simultaneously, one will receive an `OptimisticLockException` and must retry. The retry logic is handled by a `@Retryable` annotation (Spring Retry, max 3 attempts, 50ms backoff).
|
||||
|
||||
```java
|
||||
@Entity
|
||||
public class MonthlyQuota extends AbstractTenantEntity {
|
||||
|
||||
@Version
|
||||
private Long version; // optimistic lock
|
||||
|
||||
// ... other fields
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Infrastructure (Hetzner)
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Dev["👨💻 Developer (Fedora Workstation)"]
|
||||
Gitea["🏠 Gitea (homelab)\n192.168.188.119:30008"]
|
||||
Hetzner["☁️ Hetzner VPS CX21\n~€5.88/month"]
|
||||
|
||||
Dev -->|git push| Gitea
|
||||
Gitea -->|SSH deploy script\n(webhook → deploy.sh)| Hetzner
|
||||
|
||||
subgraph Hetzner
|
||||
Nginx["🔒 Nginx Container\n(reverse proxy + TLS/Let's Encrypt)"]
|
||||
App["☕ cannamanage-app\n(Spring Boot JAR)"]
|
||||
DB[("🐘 cannamanage-db\nPostgreSQL 16")]
|
||||
|
||||
Nginx -->|proxy_pass :8080| App
|
||||
App -->|JDBC :5432| DB
|
||||
end
|
||||
|
||||
Internet["🌍 Internet\nHTTPS :443"] -->|HTTPS| Nginx
|
||||
```
|
||||
|
||||
### Docker Compose Services
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml (abbreviated)
|
||||
services:
|
||||
cannamanage-app:
|
||||
image: cannamanage:latest
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://cannamanage-db:5432/cannamanage
|
||||
JWT_SECRET: ${JWT_SECRET}
|
||||
STRIPE_API_KEY: ${STRIPE_API_KEY}
|
||||
depends_on: [cannamanage-db]
|
||||
ports: ["127.0.0.1:8080:8080"]
|
||||
|
||||
cannamanage-db:
|
||||
image: postgres:16-alpine
|
||||
volumes: [pgdata:/var/lib/postgresql/data]
|
||||
environment:
|
||||
POSTGRES_DB: cannamanage
|
||||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
|
||||
cannamanage-nginx:
|
||||
image: nginx:alpine
|
||||
ports: ["443:443", "80:80"]
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||
- /etc/letsencrypt:/etc/letsencrypt:ro
|
||||
```
|
||||
|
||||
### Hetzner Sizing
|
||||
|
||||
| Resource | Spec | Rationale |
|
||||
|---|---|---|
|
||||
| Server | CX21 (2 vCPU, 4GB RAM) | Sufficient for < 200 concurrent clubs at MVP |
|
||||
| Storage | 40GB SSD (included) | PostgreSQL + logs; Hetzner Volumes for backups |
|
||||
| Backups | Hetzner automated backups (20% surcharge) | Daily snapshot retention 7 days |
|
||||
| Location | Falkenstein, Germany (FSN1) | DSGVO: data remains within Germany |
|
||||
| TLS | Let's Encrypt via Certbot | Auto-renew via cron |
|
||||
|
||||
### Deployment Workflow
|
||||
|
||||
```
|
||||
git push origin main
|
||||
→ Gitea webhook fires
|
||||
→ deploy.sh on Hetzner:
|
||||
docker pull cannamanage:latest
|
||||
docker compose up -d --no-deps cannamanage-app
|
||||
# zero-downtime: Nginx buffers requests during restart
|
||||
```
|
||||
|
||||
Flyway migrations run automatically on application startup (`spring.flyway.enabled=true`). Migration files live at `src/main/resources/db/migration/V*.sql`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Key Design Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|---|---|---|
|
||||
| Multi-tenancy | Shared schema + `tenant_id` | MVP simplicity; upgrade to schema-per-tenant possible later |
|
||||
| Frontend MVP | PrimeFaces JSF | Patrick's existing expertise; fastest path to working UI |
|
||||
| Frontend v2 | Next.js / React | Modern UX; deferred to avoid scope creep in MVP |
|
||||
| Auth | JWT (stateless) | No sticky sessions needed; horizontal scale ready |
|
||||
| PDF generation | iText 7 | Mature Java library; handles complex compliance report layouts |
|
||||
| Compliance enforcement | Service layer + DB constraint | Belt-and-suspenders: service validates, DB `UNIQUE` prevents duplicates |
|
||||
| Distribution immutability | `immutable = true`, no DELETE API | Audit trail integrity for regulatory compliance |
|
||||
| Hosting | Hetzner (Germany) | DSGVO compliance; low cost; German DC |
|
||||
@@ -0,0 +1,229 @@
|
||||
# 04 — Business Logic Flow Charts
|
||||
|
||||
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)
|
||||
**Phase:** 2 of 5 — Architecture & Data Model
|
||||
**Last updated:** 2026-04-06
|
||||
|
||||
All flows are implemented in the Spring Boot service layer. Mermaid `flowchart TD` syntax.
|
||||
|
||||
---
|
||||
|
||||
## Flow 1: Distribution Recording
|
||||
|
||||
Records a cannabis distribution to a member. This is the most compliance-critical path in the system. Every step that can fail returns a user-facing error with actionable detail (remaining quota, batch status, etc.).
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START([🟢 Admin clicks\n'Record Distribution']) --> SEL_MEMBER[Select member from list]
|
||||
SEL_MEMBER --> LOAD_MEMBER[Load member profile\nfrom MemberRepository]
|
||||
LOAD_MEMBER --> CHECK_ACTIVE{Member status\n= ACTIVE?}
|
||||
|
||||
CHECK_ACTIVE -->|No — SUSPENDED\nor EXPELLED| ERR_MEMBER[❌ Error: Member not eligible\nShow status reason]
|
||||
CHECK_ACTIVE -->|Yes| CHECK_AGE{is_under_21\n= true?}
|
||||
|
||||
CHECK_AGE -->|Under 21| MAX_MONTHLY_30[Monthly limit = 30g]
|
||||
CHECK_AGE -->|Adult ≥ 21| MAX_MONTHLY_50[Monthly limit = 50g]
|
||||
|
||||
MAX_MONTHLY_30 --> ENTER_QTY[Admin enters quantity\nin grams]
|
||||
MAX_MONTHLY_50 --> ENTER_QTY
|
||||
|
||||
ENTER_QTY --> VALIDATE_QTY{quantity > 0\nand ≤ 25g?}
|
||||
VALIDATE_QTY -->|No| ERR_QTY[❌ Error: Invalid quantity\nDaily max is 25g per visit]
|
||||
VALIDATE_QTY -->|Yes| CHECK_DAILY[ComplianceService:\nSum distributions today\nfor this member]
|
||||
|
||||
CHECK_DAILY --> DAILY_OK{today_total +\nquantity ≤ 25g?}
|
||||
DAILY_OK -->|No| ERR_DAILY[❌ Error: Daily limit exceeded\nShow remaining today]
|
||||
DAILY_OK -->|Yes| CHECK_MONTHLY[ComplianceService:\nLoad MonthlyQuota\ncurrent month]
|
||||
|
||||
CHECK_MONTHLY --> MONTHLY_OK{monthly_total +\nquantity ≤ max_allowed?}
|
||||
MONTHLY_OK -->|No| ERR_MONTHLY[❌ Error: Monthly quota exceeded\nShow remaining this month\nand reset date]
|
||||
MONTHLY_OK -->|Yes| SEL_BATCH[Admin selects batch]
|
||||
|
||||
SEL_BATCH --> LOAD_BATCH[Load batch from\nBatchRepository]
|
||||
LOAD_BATCH --> CHECK_BATCH{Batch status\n= AVAILABLE?}
|
||||
CHECK_BATCH -->|RECALLED| ERR_RECALLED[❌ Error: Batch recalled\nSelect a different batch]
|
||||
CHECK_BATCH -->|EXHAUSTED| ERR_EXHAUSTED[❌ Error: Batch exhausted\nNo stock remaining]
|
||||
CHECK_BATCH -->|AVAILABLE| CHECK_STOCK{batch.quantity_grams\n≥ requested quantity?}
|
||||
|
||||
CHECK_STOCK -->|No| ERR_STOCK[❌ Error: Insufficient stock\nShow available quantity]
|
||||
CHECK_STOCK -->|Yes| CONFIRM[Admin reviews and confirms\ndistribution details]
|
||||
|
||||
CONFIRM --> SAVE_DIST["💾 Save Distribution record\n(immutable = true,\nrecorded_by = currentUser)"]
|
||||
SAVE_DIST --> UPD_QUOTA["💾 UPDATE MonthlyQuota\ntotal_distributed += quantity\n(@Version optimistic lock)"]
|
||||
UPD_QUOTA --> UPD_STOCK["💾 INSERT StockMovement\n(type = OUT, batch_id, qty)"]
|
||||
UPD_STOCK --> UPD_BATCH["💾 UPDATE Batch\nquantity_grams -= quantity\n(if = 0 → status = EXHAUSTED)"]
|
||||
UPD_BATCH --> SUCCESS([✅ Success\nShow confirmation\nwith updated quota display])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow 2: Member Registration
|
||||
|
||||
Registers a new member in the club. Includes DSGVO consent, age validation, under-21 flag assignment, and automatic portal account creation.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START([🟢 Admin opens\n'Add Member' form]) --> ENTER_DATA[Admin enters member data:\nfirst/last name, email,\ndate of birth, address]
|
||||
|
||||
ENTER_DATA --> VALIDATE_EMAIL{Email unique\nin this club?}
|
||||
VALIDATE_EMAIL -->|Already exists| ERR_EMAIL[❌ Error: Email already\nregistered in this club]
|
||||
VALIDATE_EMAIL -->|Unique| VALIDATE_AGE{Age ≥ 18?}
|
||||
|
||||
VALIDATE_AGE -->|Under 18| ERR_AGE[❌ Error: Member must be\nat least 18 years old\n§ 10 KCanG]
|
||||
VALIDATE_AGE -->|18 or older| CHECK_UNDER21{18 ≤ age < 21?}
|
||||
|
||||
CHECK_UNDER21 -->|Yes| SET_FLAG_TRUE["Set is_under_21 = true\nMonthly limit will be 30g"]
|
||||
CHECK_UNDER21 -->|No, ≥ 21| SET_FLAG_FALSE["Set is_under_21 = false\nMonthly limit will be 50g"]
|
||||
|
||||
SET_FLAG_TRUE --> CHECK_CAPACITY[Check Club.max_members\nvs current member count]
|
||||
SET_FLAG_FALSE --> CHECK_CAPACITY
|
||||
|
||||
CHECK_CAPACITY --> CAPACITY_OK{Club has\nfree capacity?}
|
||||
CAPACITY_OK -->|No| ERR_CAPACITY[❌ Error: Club at max capacity\nCannot register more members]
|
||||
CAPACITY_OK -->|Yes| GEN_NUMBER["Generate membership_number\n(club prefix + sequential ID)"]
|
||||
|
||||
GEN_NUMBER --> DSGVO[Show DSGVO consent dialog:\n• Data usage explanation\n• Right to erasure\n• Admin must confirm consent obtained]
|
||||
DSGVO --> DSGVO_OK{Admin confirms\nconsent obtained?}
|
||||
DSGVO_OK -->|No| ABORT([🔴 Abort — member\ncannot be registered\nwithout DSGVO consent])
|
||||
DSGVO_OK -->|Yes| SAVE_MEMBER["💾 Save Member\n(status = ACTIVE,\nmembership_date = today)"]
|
||||
|
||||
SAVE_MEMBER --> CREATE_USER["💾 Create User account\n(role = ROLE_MEMBER,\ngenerate temp password)"]
|
||||
CREATE_USER --> SEND_EMAIL["📧 Send welcome email:\n• Membership number\n• Temp login credentials\n• Portal URL\n• DSGVO information sheet PDF"]
|
||||
SEND_EMAIL --> SUCCESS([✅ Member registered\nShow member profile\nwith membership number])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow 3: Contamination Batch Recall
|
||||
|
||||
Handles the recall of a contaminated batch. This flow is time-critical — speed of notification is essential for member safety. All affected distributions are identified and the prevention officer is notified.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START([🟢 Admin selects batch\nand clicks 'Flag Recall']) --> CONFIRM_RECALL{Confirm recall\nof batch?\nThis cannot be undone.}
|
||||
|
||||
CONFIRM_RECALL -->|Cancel| CANCEL([🔴 Cancelled — batch\nstatus unchanged])
|
||||
CONFIRM_RECALL -->|Confirm| QUERY_DIST["🔍 Query all Distributions\nWHERE batch_id = :batchId\n(across all members)"]
|
||||
|
||||
QUERY_DIST --> HAS_DIST{Any distributions\nfound?}
|
||||
|
||||
HAS_DIST -->|No distributions| NO_DIST["⚠️ Batch was never distributed\n(still flag as RECALLED\nfor inventory integrity)"]
|
||||
HAS_DIST -->|Yes| BUILD_LIST["Build affected member list:\n• member name\n• distribution date\n• quantity received\n• contact email"]
|
||||
|
||||
NO_DIST --> FLAG_BATCH
|
||||
BUILD_LIST --> SHOW_LIST[Show affected member list\nto admin for review]
|
||||
|
||||
SHOW_LIST --> ADMIN_REVIEW{Admin reviews\nand confirms recall?}
|
||||
ADMIN_REVIEW -->|Cancel| CANCEL
|
||||
ADMIN_REVIEW -->|Proceed| FLAG_BATCH["💾 UPDATE Batch\nstatus = RECALLED\ncontamination_flag = true"]
|
||||
|
||||
FLAG_BATCH --> LOG_MOVEMENT["💾 INSERT StockMovement\n(type = RECALL,\nbatch_id, reason)"]
|
||||
LOG_MOVEMENT --> EXPORT_LIST["📄 Generate export:\n• CSV: affected_members_recall_{batchCode}.csv\n• PDF: recall_report_{batchCode}.pdf\n(via iText 7)"]
|
||||
|
||||
EXPORT_LIST --> NOTIFY_OFFICER["📧 Email Prevention Officer:\n• Batch code and details\n• Affected member count\n• Attached CSV/PDF"]
|
||||
NOTIFY_OFFICER --> AUDIT_LOG["💾 INSERT AuditLog\n(action = BATCH_RECALL,\nperformedBy, timestamp)"]
|
||||
AUDIT_LOG --> SUCCESS([✅ Recall complete\nOffer download of\nexport files])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow 4: Compliance Report Generation
|
||||
|
||||
Generates the monthly compliance report required by § 22 KCanG. Covers all distributions within a calendar month, with per-member quota analysis and club metadata for regulatory submission.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START([🟢 Admin opens\nReports section]) --> SELECT_PERIOD[Admin selects\nmonth and year]
|
||||
|
||||
SELECT_PERIOD --> VALIDATE_PERIOD{Period in the\npast or current\nmonth?}
|
||||
VALIDATE_PERIOD -->|Future month| ERR_FUTURE[❌ Error: Cannot generate\nreport for future periods]
|
||||
VALIDATE_PERIOD -->|Valid| LOAD_CLUB[Load Club metadata:\nlicense number,\nprevention officer name]
|
||||
|
||||
LOAD_CLUB --> QUERY_DIST["🔍 ReportService:\nSELECT * FROM distributions\nWHERE month = :month\nAND year = :year\nAND tenant_id = :tenantId"]
|
||||
|
||||
QUERY_DIST --> HAS_DATA{Any distributions\nin this period?}
|
||||
|
||||
HAS_DATA -->|No data| EMPTY_REPORT[Generate empty report\nwith zero totals\n(still valid compliance submission)]
|
||||
HAS_DATA -->|Yes| AGG_MEMBER["Aggregate by member:\n• total_distributed_grams\n• number_of_visits\n• quota_usage_percent\n• is_under_21 flag"]
|
||||
|
||||
EMPTY_REPORT --> AGG_STRAIN
|
||||
AGG_MEMBER --> AGG_STRAIN["Aggregate by strain/batch:\n• strain name, THC%, CBD%\n• quantity distributed\n• batch codes used"]
|
||||
|
||||
AGG_STRAIN --> ADD_METADATA["Add club metadata:\n• Club name + license number\n• Prevention officer name\n• Report generation timestamp\n• Total members active in period"]
|
||||
|
||||
ADD_METADATA --> RENDER_PDF["📄 iText 7:\nRender PDF report\n• Cover page with club details\n• Summary table\n• Per-member breakdown\n• Strain/batch appendix"]
|
||||
|
||||
RENDER_PDF --> RENDER_CSV["📊 Generate CSV:\n• One row per distribution\n• member_id, name, date,\n quantity, strain, batch_code"]
|
||||
|
||||
RENDER_CSV --> STORE_FILES["💾 Store generated files\ntemporarily in server /tmp\n(TTL: 1 hour)"]
|
||||
|
||||
STORE_FILES --> SUCCESS([✅ Report ready\nOffer download:\n📄 PDF 📊 CSV])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow 5: Member Login & Quota Display
|
||||
|
||||
The member portal entry flow. Members log in to view their current monthly quota, remaining allowance, and recent distribution history. This is a read-only portal — members cannot modify any data.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
START([🟢 Member navigates\nto member portal URL]) --> SHOW_LOGIN[Show login form:\nemail + password]
|
||||
|
||||
SHOW_LOGIN --> SUBMIT[Member submits credentials]
|
||||
SUBMIT --> FIND_USER["🔍 Spring Security:\nSELECT FROM users\nWHERE email = ?\nAND active = true"]
|
||||
|
||||
FIND_USER --> USER_FOUND{User found?}
|
||||
USER_FOUND -->|No| ERR_NOTFOUND[❌ Invalid credentials\n(generic — do not reveal\nwhether email exists)]
|
||||
USER_FOUND -->|Yes| VERIFY_PW{BCrypt.verify\n(password, hash)\nmatches?}
|
||||
|
||||
VERIFY_PW -->|No| ERR_PW[❌ Invalid credentials]
|
||||
VERIFY_PW -->|Yes| CHECK_MEMBER{User has\nmember_id set?}
|
||||
|
||||
CHECK_MEMBER -->|No — admin account| ERR_NOTMEMBER[❌ Error: Use admin portal\nfor admin accounts]
|
||||
CHECK_MEMBER -->|Yes| ISSUE_JWT["🔑 Issue JWT:\n• role = ROLE_MEMBER\n• tenantId = user.tenantId\n• memberId = user.memberId\n• expiry = 8h"]
|
||||
|
||||
ISSUE_JWT --> UPDATE_LOGIN["💾 UPDATE users\nlast_login = NOW()"]
|
||||
UPDATE_LOGIN --> LOAD_PORTAL["Load member portal\n(JSF view or SPA)"]
|
||||
|
||||
LOAD_PORTAL --> CALL_QUOTA["📡 GET /api/v1/members/me/quota\n(JWT in Authorization header)"]
|
||||
CALL_QUOTA --> FETCH_QUOTA["🔍 QuotaController:\nLoad MonthlyQuota\nfor current month\n(create if not exists)"]
|
||||
|
||||
FETCH_QUOTA --> CALC_REMAINING{Quota record\nexists?}
|
||||
CALC_REMAINING -->|No — new month| CREATE_QUOTA["Create MonthlyQuota row:\ntotal_distributed = 0\nmax_allowed = 30g or 50g"]
|
||||
CALC_REMAINING -->|Yes| RETURN_QUOTA["Return QuotaStatus:\n• totalAllowed\n• totalUsed\n• remaining\n• percentUsed"]
|
||||
|
||||
CREATE_QUOTA --> RETURN_QUOTA
|
||||
|
||||
RETURN_QUOTA --> DISPLAY_PROGRESS["Display quota progress bar:\n🟩🟩🟩⬜⬜ e.g. 15g of 50g used\nColor: green < 60% / yellow < 85% / red ≥ 85%"]
|
||||
|
||||
DISPLAY_PROGRESS --> CALL_HISTORY["📡 GET /api/v1/distributions\n?memberId=me&limit=10\n&sort=distributed_at,desc"]
|
||||
CALL_HISTORY --> DISPLAY_HISTORY["Display last 10 distributions:\n• Date, quantity, strain name\n• Batch code\n• Recorded by (staff name)"]
|
||||
|
||||
DISPLAY_HISTORY --> SUCCESS([✅ Member portal loaded\nQuota + history visible])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Flow Summary
|
||||
|
||||
| Flow | Trigger | Key Service | Critical Constraint |
|
||||
|---|---|---|---|
|
||||
| Distribution Recording | Admin records handout | `ComplianceService` | Daily 25g + monthly 30g/50g limits |
|
||||
| Member Registration | Admin adds new member | `MemberService` | Age ≥ 18, DSGVO consent mandatory |
|
||||
| Batch Recall | Admin flags contamination | `ComplianceService.recallBatch()` | Immediate prevention officer notification |
|
||||
| Report Generation | Admin requests monthly report | `ReportService` | iText 7 PDF + CSV for regulatory filing |
|
||||
| Member Login | Member accesses portal | `AuthService` + `QuotaController` | JWT stateless, read-only member view |
|
||||
|
||||
### Error Handling Conventions
|
||||
|
||||
All flows follow these conventions for user-facing error messages:
|
||||
|
||||
- **Compliance errors** (`422 Unprocessable Entity`): Always include remaining quota/allowance so the admin knows what quantity would be valid
|
||||
- **Validation errors** (`400 Bad Request`): Include the specific `field` and a human-readable `message` in German (UI locale)
|
||||
- **Permission errors** (`403 Forbidden`): Generic message — do not reveal tenant or role details
|
||||
- **System errors** (`500 Internal Server Error`): Log full stack trace; show generic user message; alert via email to club admin
|
||||
|
||||
### Transaction Boundaries
|
||||
|
||||
The Distribution Recording flow (Flow 1) executes steps `SAVE_DIST → UPD_QUOTA → UPD_STOCK → UPD_BATCH` in a **single `@Transactional` block**. If any step fails (e.g., optimistic lock collision on `MonthlyQuota`), the entire transaction rolls back and no partial state is persisted.
|
||||
@@ -0,0 +1,550 @@
|
||||
# CannaManage — Wireframes & UI Mockups
|
||||
|
||||
**Phase 4a | Document 6 of 7**
|
||||
**Date:** 2026-04-06
|
||||
**Stack:** Spring Boot 3.x · PrimeFaces JSF · PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Design System Overview](#1-design-system-overview)
|
||||
2. [Admin Portal Screens](#2-admin-portal-screens)
|
||||
3. [Member Portal Screens](#3-member-portal-screens)
|
||||
4. [Navigation & Information Architecture](#4-navigation--information-architecture)
|
||||
5. [Responsive Design Notes](#5-responsive-design-notes)
|
||||
6. [Accessibility](#6-accessibility)
|
||||
|
||||
---
|
||||
|
||||
## 1. Design System Overview
|
||||
|
||||
### 1.1 Color Palette
|
||||
|
||||
| Token | Hex | Usage |
|
||||
|---|---|---|
|
||||
| `--color-primary` | `#2D5016` | Sidebar background, primary buttons, active nav items |
|
||||
| `--color-primary-medium` | `#4A7C28` | Hover states, section headers, badge outlines |
|
||||
| `--color-accent` | `#8BC34A` | Highlights, progress bars filled, success indicators |
|
||||
| `--color-bg` | `#F5F5F5` | Page background, card backgrounds |
|
||||
| `--color-text` | `#1A1A1A` | Body text, table cell content |
|
||||
| `--color-warning` | `#FF6B35` | Quota >80%, low stock, warnings |
|
||||
| `--color-error` | `#D32F2F` | Quota exceeded, recalled batches, destructive actions |
|
||||
| `--color-white` | `#FFFFFF` | Sidebar text, button labels on dark bg, card surfaces |
|
||||
|
||||
### 1.2 Typography
|
||||
|
||||
| Element | Font | Size | Weight |
|
||||
|---|---|---|---|
|
||||
| H1 — Page title | Inter | 24px | 600 |
|
||||
| H2 — Section heading | Inter | 18px | 600 |
|
||||
| H3 — Card title | Inter | 14px | 600 |
|
||||
| Body / table rows | Inter | 14px | 400 |
|
||||
| Caption / label | Inter | 12px | 400 |
|
||||
| Mono (codes, IDs) | JetBrains Mono | 13px | 400 |
|
||||
|
||||
### 1.3 Component Library
|
||||
|
||||
All UI components come from **PrimeFaces 13.x** (JSF-based). No external React/Angular dependencies in MVP.
|
||||
|
||||
| Component | Usage |
|
||||
|---|---|
|
||||
| `p:panel` | Section containers, card wrappers |
|
||||
| `p:dataTable` with `p:column` | Tabular data: distributions, members, batches |
|
||||
| `p:paginator` | Pagination on all tables |
|
||||
| `p:inputText` | Single-line text fields |
|
||||
| `p:inputNumber` | Weight inputs (gram precision) |
|
||||
| `p:selectOneMenu` | Dropdown selects (member, strain, batch) |
|
||||
| `p:calendar` | Date range pickers for reports |
|
||||
| `p:progressBar` | Quota consumption display |
|
||||
| `p:commandButton` | Primary and secondary actions |
|
||||
| `p:confirmDialog` | Dangerous actions (recall, delete) |
|
||||
| `p:messages` / `p:message` | Inline validation errors |
|
||||
| `p:badge` | Status indicators (AVAILABLE, LOW, RECALLED) |
|
||||
| `p:sidebar` | Mobile nav drawer (member portal) |
|
||||
| `p:dialog` | Modal overlays |
|
||||
|
||||
### 1.4 Layout Grid
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ TOP NAVBAR (56px) club name · avatar · logout │
|
||||
├──────────────┬─────────────────────────────────────┤
|
||||
│ │ │
|
||||
│ SIDEBAR │ MAIN CONTENT │
|
||||
│ (240px) │ (fluid, min 784px) │
|
||||
│ fixed │ │
|
||||
│ │ │
|
||||
└──────────────┴─────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Sidebar:** fixed left, `#2D5016` background, white nav labels with `#8BC34A` icons
|
||||
- **Top Navbar:** `#FFFFFF` with bottom border `#E0E0E0`, breadcrumb left, user controls right
|
||||
- **Main Content:** `#F5F5F5` background, 24px padding, max content width 1200px centered
|
||||
|
||||
---
|
||||
|
||||
## 2. Admin Portal Screens
|
||||
|
||||
### Screen 1 — Admin Dashboard
|
||||
|
||||

|
||||
|
||||
#### ASCII Wireframe
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
|
||||
├────────────┬────────────────────────────────────────────────────────┤
|
||||
│ │ Dashboard 🗓 April 2026 │
|
||||
│ 📊 Dashboard◄│ │
|
||||
│ │ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
|
||||
│ 👥 Members│ │ Total Members│ │ Distributions│ │ Stock Available│ │
|
||||
│ │ │ │ │ This Month │ │ │ │
|
||||
│ 📋 Distrib│ │ 142 │ │ 87 │ │ 3,240 g │ │
|
||||
│ │ │ ▲ +3 MoM │ │ ▲ +12 MoM │ │ ▼ -800g MoM │ │
|
||||
│ 📦 Stock │ └──────────────┘ └──────────────┘ └───────────────┘ │
|
||||
│ │ │
|
||||
│ 📄 Reports│ Recent Distributions [+ New Entry] │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │
|
||||
│ ✅ Complian│ │ Member │ Strain │ Qty │ Date │ ✓ │ │
|
||||
│ │ ├─────────────┼─────────────┼───────┼───────┼────┤ │
|
||||
│ ⚙ Settings│ │ Müller, A. │ OG Kush B12 │ 5.0g │ 06.04 │ ✓ │ │
|
||||
│ │ │ Schmidt, K. │ Amnesia H09 │ 3.5g │ 06.04 │ ✓ │ │
|
||||
│ │ │ Weber, T. │ OG Kush B12 │ 7.0g │ 05.04 │ ✓ │ │
|
||||
│ │ │ … │ │ │ │ │ │
|
||||
└────────────┴──┴─────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
|---|---|---|
|
||||
| KPI Cards | `p:panel` with custom CSS | Auto-refreshed via `@poll` every 60s |
|
||||
| Recent Distributions table | `p:dataTable` (5 rows, no paginator) | Row click → navigate to distribution detail |
|
||||
| Member column link | `p:commandLink` | Navigate to `/admin/members/{id}` |
|
||||
| `+ New Entry` button | `p:commandButton` style="primary" | Navigate to `/admin/distributions/new` |
|
||||
| Trend indicators | Custom CSS `<span>` | Green ▲ / Red ▼ with delta value |
|
||||
|
||||
---
|
||||
|
||||
### Screen 2 — Distribution Recording Form
|
||||
|
||||

|
||||
|
||||
#### ASCII Wireframe
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
|
||||
├────────────┬────────────────────────────────────────────────────────┤
|
||||
│ │ Distributions › New Distribution │
|
||||
│ 📊 Dashbrd│ │
|
||||
│ │ ┌──────────────────────────────────────────────────┐ │
|
||||
│ 👥 Members│ │ Member * │ │
|
||||
│ │ │ ┌──────────────────────────────────────────┐ │ │
|
||||
│ 📋 Distrib◄│ │ │ 🔍 Search by name or member no. │ │ │
|
||||
│ │ │ └──────────────────────────────────────────┘ │ │
|
||||
│ 📦 Stock │ │ │ │
|
||||
│ │ │ Strain / Batch * │ │
|
||||
│ 📄 Reports│ │ ┌──────────────────────────────────────────┐ │ │
|
||||
│ │ │ │ Select available batch ▼ │ │ │
|
||||
│ ✅ Complian│ │ └──────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ ⚙ Settings│ │ Weight (grams) * │ │
|
||||
│ │ │ ┌──────────┐ │ │
|
||||
│ │ │ │ 0.0 g │ ← p:inputNumber min=0.1 max=25 │ │
|
||||
│ │ │ └──────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ Monthly Quota — Müller, Anna │ │
|
||||
│ │ │ ████████████░░░░░░░░ 32.5g / 50g 65% │ │
|
||||
│ │ │ [████████████████░░░] <- p:progressBar │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ [ Record Distribution ] [Cancel] │ │
|
||||
│ │ └──────────────────────────────────────────────────┘ │
|
||||
└────────────┴────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Compliance UX — Real-Time Quota Indicator
|
||||
|
||||
The quota progress bar updates live as the weight field changes (via `f:ajax event="keyup"`):
|
||||
|
||||
| Quota Used After Distribution | Bar Color | Submit Button | Message |
|
||||
|---|---|---|---|
|
||||
| 0–79% | `#8BC34A` (green) | Enabled | — |
|
||||
| 80–99% | `#FF6B35` (orange) | Enabled | "⚠ Approaching monthly limit" |
|
||||
| 100% | `#D32F2F` (red) | **Disabled** | "🚫 Monthly limit reached (50g)" |
|
||||
| Over-21 member, >30g monthly | `#D32F2F` (red) | **Disabled** | "🚫 Under-21 limit reached (30g)" |
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
|---|---|---|
|
||||
| Member search | `p:selectOneMenu` with `p:ajax` filter | Filters on type, shows name + member no. |
|
||||
| Strain/Batch dropdown | `p:selectOneMenu` | Populated after member selection; shows only `AVAILABLE` batches |
|
||||
| Weight input | `p:inputNumber` min=`0.1` max=`25.0` step=`0.1` | Triggers quota recalculation on blur |
|
||||
| Quota bar | `p:progressBar` with dynamic `value` | Color class applied via `styleClass` computed in backing bean |
|
||||
| Submit | `p:commandButton` | Disabled via `disabled="#{bean.quotaExceeded}"` |
|
||||
| Cancel | `p:link` | Returns to distribution log without saving |
|
||||
|
||||
---
|
||||
|
||||
### Screen 3 — Stock Management
|
||||
|
||||

|
||||
|
||||
#### ASCII Wireframe
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
|
||||
├────────────┬────────────────────────────────────────────────────────┤
|
||||
│ │ Stock Management [+ Add Batch] │
|
||||
│ 📊 Dashbrd│ │
|
||||
│ │ ┌──────────────────────┐ ┌────────────────────┐ │
|
||||
│ 👥 Members│ │ 🔍 Filter by strain │ │ Status: All ▼ │ │
|
||||
│ │ └──────────────────────┘ └────────────────────┘ │
|
||||
│ 📋 Distrib│ │
|
||||
│ │ ┌───────────────────────────────────────────────────┐ │
|
||||
│ 📦 Stock ◄│ │ Strain │Batch│THC% │CBD%│ Qty │Status│Act │ │
|
||||
│ │ ├──────────────┼─────┼─────┼────┼───────┼──────┼────┤ │
|
||||
│ 📄 Reports│ │ OG Kush │B-12 │ 19% │ 1% │ 850g │ ● │[R] │ │
|
||||
│ │ │ Amnesia Haze │H-09 │ 22% │<1% │ 72g │ ⚠ │[R] │ │
|
||||
│ ✅ Complian│ │ Blue Dream │D-05 │ 17% │ 2% │ 0g │ — │[R] │ │
|
||||
│ │ │ Hindu Kush │K-21 │ 8% │15% │ 340g │ ✓ │[R] │ │
|
||||
│ ⚙ Settings│ │ AK-47 #4 │A-03 │ 20% │ 1% │ RECALLED │ ⛔ │[R] │ │
|
||||
│ │ └───────────────────────────────────────────────────┘ │
|
||||
│ │ [◄ 1 2 3 … ►] Showing 1-10/42 │
|
||||
└────────────┴────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Status Badges
|
||||
|
||||
| Badge | Color | Icon | Condition |
|
||||
|---|---|---|---|
|
||||
| `AVAILABLE` | `#4A7C28` bg | ✓ checkmark | `qty > 100g` and not recalled |
|
||||
| `LOW` | `#FF6B35` bg | ⚠ warning | `0 < qty ≤ 100g` |
|
||||
| `EXHAUSTED` | `#9E9E9E` bg | — dash | `qty = 0` |
|
||||
| `RECALLED` | `#D32F2F` bg | ⛔ stop | `recall_date IS NOT NULL` |
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
|---|---|---|
|
||||
| Strain filter | `p:inputText` with `filterBy` | Filters table client-side on keyup |
|
||||
| Status filter | `p:selectOneMenu` | Filters table rows by status value |
|
||||
| Batch table | `p:dataTable` lazy=`true` | Server-side pagination, 10 rows/page |
|
||||
| Status badge | Custom CSS `<span class="badge badge-{status}">` | Icon + text label (not color alone) |
|
||||
| Recall button | `p:commandButton` styleClass=`p-button-danger` | Opens `p:confirmDialog` before executing |
|
||||
| Confirm dialog | `p:confirmDialog` | "Recall batch B-12 (OG Kush, 850g)? This cannot be undone." |
|
||||
| Add Batch | `p:commandButton` | Opens `p:dialog` with batch entry form |
|
||||
|
||||
---
|
||||
|
||||
### Screen 4 — Compliance Report Generation
|
||||
|
||||

|
||||
|
||||
#### ASCII Wireframe
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 🌿 CannaManage Grüne Oase Berlin e.V. 👤 Max M. [⏻] │
|
||||
├────────────┬────────────────────────────────────────────────────────┤
|
||||
│ │ Reports › Monthly Compliance Report │
|
||||
│ 📊 Dashbrd│ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │
|
||||
│ 👥 Members│ │ Reporting Period │ │
|
||||
│ │ │ Month: [ March ▼ ] Year: [ 2026 ▼ ] │ │
|
||||
│ 📋 Distrib│ │ [ Generate Report ] │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │
|
||||
│ 📦 Stock │ │
|
||||
│ │ ┌─────────────────────────────────────────────────┐ │
|
||||
│ 📄 Reports◄│ │ PDF PREVIEW │ │
|
||||
│ │ │ ┌─────────────────────────────────────────┐ │ │
|
||||
│ ✅ Complian│ │ │ 🌿 CannaManage — Monthly Report Mar 2026 │ │ │
|
||||
│ │ │ │ Club: Grüne Oase Berlin e.V. │ │ │
|
||||
│ ⚙ Settings│ │ │ ─────────────────────────────────────── │ │ │
|
||||
│ │ │ │ Total Members: 142 │ │ │
|
||||
│ │ │ │ Active Members (distributed): 87 │ │ │
|
||||
│ │ │ │ Total Distributed: 435.5g │ │ │
|
||||
│ │ │ └─────────────────────────────────────────┘ │ │
|
||||
│ │ │ │ │
|
||||
│ │ │ [⬇ Download PDF] [⬇ Download CSV] │ │
|
||||
│ │ └─────────────────────────────────────────────────┘ │
|
||||
│ │ │
|
||||
│ │ Summary Table │
|
||||
│ │ ┌────────────────────────────────────────────────┐ │
|
||||
│ │ │ Metric │ Value │ Limit │ │
|
||||
│ │ ├──────────────────────┼───────────┼─────────────┤ │
|
||||
│ │ │ Members >50g/month │ 0 │ Must be 0 │ │
|
||||
│ │ │ Members >30g (U21) │ 0 │ Must be 0 │ │
|
||||
│ │ │ Recalled Batches │ 1 │ — (info) │ │
|
||||
│ │ │ Avg grams / member │ 5.0g │ — │ │
|
||||
│ │ └────────────────────────────────────────────────┘ │
|
||||
└────────────┴────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
|---|---|---|
|
||||
| Month selector | `p:selectOneMenu` | Months Jan–Dec |
|
||||
| Year selector | `p:selectOneMenu` | Current year ± 2 |
|
||||
| Generate button | `p:commandButton` | Calls report service; shows spinner; renders PDF thumbnail |
|
||||
| PDF preview | `<iframe>` embedding `/report/preview?month=3&year=2026` | Generated by iText 7 in `cannamanage-report` module |
|
||||
| Download PDF | `p:commandButton` | Streams PDF response from REST endpoint |
|
||||
| Download CSV | `p:commandButton` | Streams CSV response (member-level data) |
|
||||
| Summary table | `p:dataTable` | Computed compliance metrics; zero violations = green row |
|
||||
|
||||
---
|
||||
|
||||
## 3. Member Portal Screens
|
||||
|
||||
### Screen 5 — Member Dashboard / Quota View
|
||||
|
||||

|
||||
|
||||
#### ASCII Wireframe
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────┐
|
||||
│ 🌿 CannaManage Anna Müller #M-0042 │
|
||||
│ ────────────────────────────────────────────── │
|
||||
│ │
|
||||
│ Monthly Quota Remaining │
|
||||
│ │
|
||||
│ ╭───────────────╮ │
|
||||
│ │ │ │
|
||||
│ │ 17.5 g │ │
|
||||
│ │ remaining │ │
|
||||
│ │ │ │
|
||||
│ │ of 50g/month │ │
|
||||
│ ╰───────────────╯ │
|
||||
│ ▓▓▓▓▓▓▓▓▓▓▓░░░░░░░ 65% used │
|
||||
│ │
|
||||
│ Distribution History │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ Date │ Strain │ Quantity │ │
|
||||
│ ├────────────┼──────────────┼────────────────┤ │
|
||||
│ │ 06.04.2026 │ OG Kush │ 5.0g │ │
|
||||
│ │ 02.04.2026 │ Amnesia Haze │ 12.5g │ │
|
||||
│ │ 28.03.2026 │ OG Kush │ 15.0g │ │
|
||||
│ └────────────┴──────────────┴────────────────┘ │
|
||||
│ │
|
||||
│ Available Strains │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ Strain │ Availability │ │
|
||||
│ ├──────────────┼─────────────────────────────┤ │
|
||||
│ │ OG Kush │ ● Available │ │
|
||||
│ │ Amnesia Haze │ ⚠ Limited │ │
|
||||
│ │ Hindu Kush │ ● Available │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Compliance Note — Available Strains Display
|
||||
|
||||
Per CanG §§6–7, members may NOT see specific batch quantities or total stock levels. The **Available Strains** table shows only:
|
||||
- Strain name
|
||||
- Availability status (Available / Limited / Unavailable)
|
||||
|
||||
Quantities, batch codes, and THC/CBD percentages are **not exposed** in the member portal.
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
|---|---|---|
|
||||
| Quota circle | Custom CSS radial progress (`conic-gradient`) | Computed from monthly total; color matches threshold rules |
|
||||
| Quota bar | `p:progressBar` | Same color logic as admin distribution form |
|
||||
| History table | `p:dataTable` | Last 10 distributions; sorted newest first; no pagination in MVP |
|
||||
| Strains table | `p:dataTable` | `status` column: text + icon only, no quantities |
|
||||
|
||||
---
|
||||
|
||||
### Screen 6 — Member Login
|
||||
|
||||
> *No mockup image — ASCII wireframe only.*
|
||||
|
||||
#### ASCII Wireframe
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ │
|
||||
│ 🌿 CannaManage │
|
||||
│ │
|
||||
│ ┌──────────────────────────────────┐ │
|
||||
│ │ E-Mail Address │ │
|
||||
│ │ ┌────────────────────────────┐ │ │
|
||||
│ │ │ you@example.com │ │ │
|
||||
│ │ └────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ Password │ │
|
||||
│ │ ┌────────────────────────────┐ │ │
|
||||
│ │ │ •••••••••••• │ │ │
|
||||
│ │ └────────────────────────────┘ │ │
|
||||
│ │ │ │
|
||||
│ │ [ ████ Log In ████████████ ] │ │
|
||||
│ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ Problems logging in? │
|
||||
│ Contact your club administrator. │
|
||||
│ │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
#### Design Decisions
|
||||
|
||||
- **No self-registration link** — member accounts are created exclusively by admins via the admin portal. The login page has no "Create account" or "Sign up" flow.
|
||||
- **No forgot-password link** — password resets are initiated by the club admin only. The login page directs users to contact their admin, avoiding email-based reset flows that would require verified email infrastructure in MVP.
|
||||
- **No social login** — DSGVO compliance and club accountability require traceable credential management.
|
||||
- **Form submission:** POST to `/login` (Spring Security form login), redirect to `/member/dashboard` on success.
|
||||
|
||||
#### Components & Behavior
|
||||
|
||||
| Component | PrimeFaces | Behavior |
|
||||
|---|---|---|
|
||||
| Email field | `p:inputText` with `required="true"` | Bean Validation `@Email` |
|
||||
| Password field | `p:password` feedback=`false` | No strength meter on login |
|
||||
| Login button | `p:commandButton` | Submit form; shows `p:messages` on failure |
|
||||
| Error message | `p:messages` | "Invalid email or password." (never specific about which field failed) |
|
||||
|
||||
---
|
||||
|
||||
## 4. Navigation & Information Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
Root["CannaManage Root"]
|
||||
Root --> AdminPortal["Admin Portal /admin/"]
|
||||
Root --> MemberPortal["Member Portal /member/"]
|
||||
|
||||
AdminPortal --> AdminDash["Dashboard (default)"]
|
||||
AdminPortal --> Members["Members"]
|
||||
Members --> MemberList["Member List"]
|
||||
Members --> MemberDetail["Member Detail"]
|
||||
AdminPortal --> Distributions["Distributions"]
|
||||
Distributions --> DistLog["Distribution Log"]
|
||||
Distributions --> NewDist["New Distribution"]
|
||||
AdminPortal --> Stock["Stock"]
|
||||
Stock --> Strains["Strains"]
|
||||
Stock --> Batches["Batches"]
|
||||
AdminPortal --> Reports["Reports"]
|
||||
Reports --> MonthlyReport["Monthly Compliance"]
|
||||
Reports --> MemberExport["Member Export"]
|
||||
Reports --> RecallReport["Batch Recall Report"]
|
||||
AdminPortal --> Compliance["Compliance"]
|
||||
Compliance --> PreventionOfficer["Prevention Officer Info"]
|
||||
AdminPortal --> Settings["Settings"]
|
||||
Settings --> ClubProfile["Club Profile"]
|
||||
|
||||
MemberPortal --> MemberDash["Dashboard / Quota"]
|
||||
MemberPortal --> DistHistory["Distribution History"]
|
||||
MemberPortal --> StockAvail["Stock Availability"]
|
||||
```
|
||||
|
||||
### URL Structure
|
||||
|
||||
| Path | Description | Role |
|
||||
|---|---|---|
|
||||
| `/login` | Login page | Public |
|
||||
| `/admin/dashboard` | Admin home | `ROLE_ADMIN` |
|
||||
| `/admin/members` | Member list | `ROLE_ADMIN` |
|
||||
| `/admin/members/{id}` | Member detail | `ROLE_ADMIN` |
|
||||
| `/admin/distributions` | Distribution log | `ROLE_ADMIN` |
|
||||
| `/admin/distributions/new` | New distribution form | `ROLE_ADMIN` |
|
||||
| `/admin/stock/strains` | Strain catalog | `ROLE_ADMIN` |
|
||||
| `/admin/stock/batches` | Batch management | `ROLE_ADMIN` |
|
||||
| `/admin/reports/monthly` | Compliance reports | `ROLE_ADMIN` |
|
||||
| `/admin/reports/members` | Member data export | `ROLE_ADMIN` |
|
||||
| `/admin/reports/recall` | Recall report | `ROLE_ADMIN` |
|
||||
| `/admin/compliance` | Prevention officer | `ROLE_ADMIN` |
|
||||
| `/admin/settings` | Club settings | `ROLE_ADMIN` |
|
||||
| `/member/dashboard` | Member quota view | `ROLE_MEMBER` |
|
||||
| `/member/distributions` | Personal history | `ROLE_MEMBER` |
|
||||
| `/member/stock` | Strain availability | `ROLE_MEMBER` |
|
||||
|
||||
---
|
||||
|
||||
## 5. Responsive Design Notes
|
||||
|
||||
### MVP (v1) — Desktop-First
|
||||
|
||||
Target viewport: **1024px+**. PrimeFaces responsive grid (`p:panelGrid` with responsive columns, `ui-g-12 ui-md-6 ui-lg-4`) handles most layout adaptation down to tablet without custom media queries.
|
||||
|
||||
| Breakpoint | Behavior |
|
||||
|---|---|
|
||||
| `≥ 1280px` | Full layout — sidebar + content side-by-side |
|
||||
| `1024–1279px` | Sidebar collapses to icon-only (60px); tooltips on hover |
|
||||
| `768–1023px` | Sidebar hidden; hamburger menu in top navbar |
|
||||
| `< 768px` | Admin portal degraded (tables scroll horizontally) |
|
||||
|
||||
### Member Portal — Mobile-First from Day One
|
||||
|
||||
Members will typically check quota status on their phone. The member portal is designed mobile-first regardless of MVP/v2 timeline.
|
||||
|
||||
| Breakpoint | Behavior |
|
||||
|---|---|
|
||||
| `≥ 1024px` | Two-column layout: quota circle left, history right |
|
||||
| `768–1023px` | Single-column, full-width cards |
|
||||
| `375–767px` | Single-column, compact quota ring, condensed table |
|
||||
| `< 375px` | Minimum supported; no horizontal scroll |
|
||||
|
||||
### v2 Roadmap
|
||||
|
||||
- PWA manifest + service worker (offline quota display)
|
||||
- 768px and 375px explicit breakpoints with design tokens
|
||||
- Touch-friendly `p:sidebar` for mobile member nav
|
||||
- Push notifications for low quota warnings
|
||||
|
||||
---
|
||||
|
||||
## 6. Accessibility
|
||||
|
||||
CannaManage targets **WCAG 2.1 AA** compliance across both portals.
|
||||
|
||||
### Keyboard Navigation
|
||||
|
||||
| Element | Keyboard Behavior |
|
||||
|---|---|
|
||||
| Navigation sidebar | Tab navigates items; Enter activates |
|
||||
| Data tables | Tab to table; arrow keys for row navigation |
|
||||
| Dropdown menus | Enter/Space to open; arrow keys to navigate; Escape to close |
|
||||
| Modal dialogs | Focus trapped inside; Escape to close; first focusable element receives focus on open |
|
||||
| Confirmation dialogs | Tab between Confirm and Cancel; Enter on focused button |
|
||||
|
||||
### Screen Reader Support
|
||||
|
||||
- All `p:inputText` / `p:inputNumber` fields have `<label>` with `for` attribute
|
||||
- `aria-label` set on icon-only buttons (e.g., recall action column)
|
||||
- `aria-live="polite"` region on quota bar — announces percentage changes
|
||||
- `aria-describedby` links compliance warning messages to the weight input
|
||||
- PrimeFaces generates `role="grid"` and `aria-rowcount` on all data tables
|
||||
|
||||
### Color Independence
|
||||
|
||||
Status badges must never rely on color alone:
|
||||
|
||||
| Status | Color | Icon | Text label |
|
||||
|---|---|---|---|
|
||||
| AVAILABLE | Green | ✓ | "Available" |
|
||||
| LOW | Orange | ⚠ | "Low Stock" |
|
||||
| RECALLED | Red | ⛔ | "Recalled" |
|
||||
| EXHAUSTED | Gray | — | "Exhausted" |
|
||||
|
||||
Quota progress bar additionally shows numeric percentage text alongside color change.
|
||||
|
||||
### Contrast Ratios
|
||||
|
||||
| Foreground | Background | Ratio | AA pass |
|
||||
|---|---|---|---|
|
||||
| `#FFFFFF` | `#2D5016` | 9.1:1 | ✅ |
|
||||
| `#1A1A1A` | `#F5F5F5` | 16.0:1 | ✅ |
|
||||
| `#1A1A1A` | `#FFFFFF` | 19.0:1 | ✅ |
|
||||
| `#FFFFFF` | `#D32F2F` | 5.1:1 | ✅ |
|
||||
| `#FFFFFF` | `#FF6B35` | 3.1:1 | ⚠ verify at large text only |
|
||||
|
||||
---
|
||||
|
||||
*Next: [07-CODING-STANDARDS.md](07-CODING-STANDARDS.md)*
|
||||
@@ -0,0 +1,825 @@
|
||||
# CannaManage — Coding Standards & Git Strategy
|
||||
|
||||
**Phase 4a | Document 7 of 7**
|
||||
**Date:** 2026-04-06
|
||||
**Stack:** Java 21 · Spring Boot 3.x · JPA/Hibernate · PrimeFaces JSF · PostgreSQL
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
|
||||
1. [Project Structure](#1-project-structure)
|
||||
2. [Java Coding Standards](#2-java-coding-standards)
|
||||
3. [Compliance Code Rules](#3-compliance-code-rules)
|
||||
4. [Git Strategy](#4-git-strategy)
|
||||
5. [Testing Standards](#5-testing-standards)
|
||||
6. [Code Review Checklist](#6-code-review-checklist)
|
||||
7. [Security Standards](#7-security-standards)
|
||||
8. [Environment Configuration](#8-environment-configuration)
|
||||
|
||||
---
|
||||
|
||||
## 1. Project Structure
|
||||
|
||||
### Maven Multi-Module Layout
|
||||
|
||||
```
|
||||
cannamanage/
|
||||
├── pom.xml # Parent POM — dependency management, versions
|
||||
├── cannamanage-domain/ # JPA entities, enums, exceptions, value objects
|
||||
│ └── src/main/java/de/cannamanage/domain/
|
||||
│ ├── member/ # Member, MemberStatus, MembershipType
|
||||
│ ├── distribution/ # Distribution, DistributionRecord
|
||||
│ ├── stock/ # Strain, Batch, BatchStatus
|
||||
│ ├── compliance/ # ComplianceConstants, QuotaExceededException
|
||||
│ └── common/ # AbstractTenantEntity, TenantId
|
||||
│
|
||||
├── cannamanage-service/ # Business logic, compliance engine, repositories
|
||||
│ └── src/main/java/de/cannamanage/service/
|
||||
│ ├── member/ # MemberService, MemberRepository
|
||||
│ ├── distribution/ # DistributionService, DistributionRepository
|
||||
│ ├── stock/ # StockService, BatchRepository
|
||||
│ ├── compliance/ # ComplianceService, QuotaCalculator
|
||||
│ └── report/ # ReportDataService
|
||||
│
|
||||
├── cannamanage-web/ # PrimeFaces JSF backing beans + XHTML views
|
||||
│ └── src/main/
|
||||
│ ├── java/de/cannamanage/web/
|
||||
│ │ ├── admin/ # AdminDashboardBean, DistributionFormBean
|
||||
│ │ ├── member/ # MemberDashboardBean
|
||||
│ │ └── common/ # AuthBean, NavigationBean
|
||||
│ └── webapp/
|
||||
│ ├── admin/ # dashboard.xhtml, distribution-form.xhtml, stock.xhtml
|
||||
│ ├── member/ # dashboard.xhtml, stock.xhtml
|
||||
│ └── WEB-INF/ # faces-config.xml, web.xml
|
||||
│
|
||||
├── cannamanage-api/ # REST controllers (Spring Boot MVC)
|
||||
│ └── src/main/java/de/cannamanage/api/
|
||||
│ ├── member/ # MemberController, MemberDto
|
||||
│ ├── distribution/ # DistributionController, DistributionDto
|
||||
│ ├── stock/ # StockController, BatchDto
|
||||
│ ├── auth/ # AuthController, JwtFilter
|
||||
│ └── report/ # ReportController
|
||||
│
|
||||
└── cannamanage-report/ # iText 7 PDF generation
|
||||
└── src/main/java/de/cannamanage/report/
|
||||
├── monthly/ # MonthlyComplianceReport
|
||||
├── recall/ # BatchRecallReport
|
||||
└── export/ # MemberCsvExporter
|
||||
```
|
||||
|
||||
### Module Dependencies
|
||||
|
||||
```
|
||||
cannamanage-domain (no deps on other modules)
|
||||
↑
|
||||
cannamanage-service (depends on domain)
|
||||
↑
|
||||
cannamanage-api (depends on service, domain)
|
||||
cannamanage-web (depends on service, domain)
|
||||
cannamanage-report (depends on service, domain)
|
||||
```
|
||||
|
||||
`cannamanage-api` and `cannamanage-web` are siblings — they do not depend on each other. The web module is the PrimeFaces JSF frontend (MVP); the API module provides the REST layer (future mobile / integration use).
|
||||
|
||||
---
|
||||
|
||||
## 2. Java Coding Standards
|
||||
|
||||
### Language Version
|
||||
|
||||
Java 21. All modern language features are permitted and preferred:
|
||||
|
||||
| Feature | Use Case | Example |
|
||||
|---|---|---|
|
||||
| Records | DTOs, value objects, query results | `record MemberSummary(UUID id, String name, BigDecimal quotaUsed)` |
|
||||
| Sealed classes | Result types, compliance outcomes | `sealed interface QuotaResult permits QuotaOk, QuotaWarning, QuotaExceeded` |
|
||||
| Text blocks | JPQL, SQL in tests, JSON fixtures | `String jpql = """ SELECT m FROM Member m WHERE... """` |
|
||||
| Pattern matching `instanceof` | Type checks in services | `if (result instanceof QuotaExceeded e) { ... }` |
|
||||
| Switch expressions | Status mapping, report routing | `yield` syntax preferred |
|
||||
|
||||
### Package Structure
|
||||
|
||||
Pattern: `de.cannamanage.[module].[layer]`
|
||||
|
||||
```
|
||||
de.cannamanage.domain.member # Member entity
|
||||
de.cannamanage.domain.compliance # ComplianceConstants, exceptions
|
||||
de.cannamanage.service.distribution # DistributionService
|
||||
de.cannamanage.api.stock # StockController, BatchDto
|
||||
de.cannamanage.web.admin # DistributionFormBean
|
||||
de.cannamanage.report.monthly # MonthlyComplianceReport
|
||||
```
|
||||
|
||||
### Class Naming Conventions
|
||||
|
||||
| Type | Pattern | Example |
|
||||
|---|---|---|
|
||||
| JPA Entity | `{Domain}` | `Member`, `Distribution`, `Batch` |
|
||||
| Spring Service | `{Domain}Service` | `MemberService`, `ComplianceService` |
|
||||
| Repository | `{Domain}Repository` | `DistributionRepository` |
|
||||
| REST Controller | `{Domain}Controller` | `StockController` |
|
||||
| JSF Backing Bean | `{Screen}Bean` | `DistributionFormBean`, `AdminDashboardBean` |
|
||||
| DTO (request) | `{Domain}Request` | `CreateDistributionRequest` |
|
||||
| DTO (response) | `{Domain}Response` / `{Domain}Dto` | `MemberSummaryDto` |
|
||||
| Exception | `{Condition}Exception` | `QuotaExceededException`, `BatchRecalledException` |
|
||||
| Enum | `{Domain}Status` / `{Domain}Type` | `BatchStatus`, `MembershipType` |
|
||||
| Constants class | `{Domain}Constants` | `ComplianceConstants` |
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
**Constructor injection only.** Field injection (`@Autowired` on fields) is prohibited.
|
||||
|
||||
```java
|
||||
// ✅ Correct
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class DistributionService {
|
||||
|
||||
private final DistributionRepository distributionRepository;
|
||||
private final ComplianceService complianceService;
|
||||
private final MemberRepository memberRepository;
|
||||
}
|
||||
|
||||
// ❌ Prohibited
|
||||
@Service
|
||||
public class DistributionService {
|
||||
|
||||
@Autowired
|
||||
private DistributionRepository distributionRepository;
|
||||
}
|
||||
```
|
||||
|
||||
Lombok `@RequiredArgsConstructor` is the preferred way to generate the constructor.
|
||||
|
||||
### Entity Base Class
|
||||
|
||||
All `@Entity` classes must extend `AbstractTenantEntity`. No raw entities without tenant isolation.
|
||||
|
||||
```java
|
||||
// de.cannamanage.domain.common.AbstractTenantEntity
|
||||
@MappedSuperclass
|
||||
@EntityListeners(AuditingEntityListener.class)
|
||||
public abstract class AbstractTenantEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.UUID)
|
||||
private UUID id;
|
||||
|
||||
@Column(name = "tenant_id", nullable = false, updatable = false)
|
||||
private UUID tenantId;
|
||||
|
||||
@CreatedDate
|
||||
@Column(name = "created_at", nullable = false, updatable = false)
|
||||
private Instant createdAt;
|
||||
|
||||
@LastModifiedDate
|
||||
@Column(name = "updated_at", nullable = false)
|
||||
private Instant updatedAt;
|
||||
}
|
||||
|
||||
// ✅ All entities extend this
|
||||
@Entity
|
||||
@Table(name = "members")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Member extends AbstractTenantEntity {
|
||||
// domain fields only — no id/tenantId/audit fields here
|
||||
}
|
||||
```
|
||||
|
||||
### Transaction Boundaries
|
||||
|
||||
- `@Transactional` belongs on **service layer** methods only
|
||||
- Controllers and repositories must not declare `@Transactional`
|
||||
- Use `@Transactional(readOnly = true)` for query-only methods — improves performance with Hibernate's read-only session optimization
|
||||
|
||||
```java
|
||||
// ✅ Service layer — correct
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MemberService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MemberSummaryDto getById(UUID memberId, UUID tenantId) { ... }
|
||||
|
||||
@Transactional
|
||||
public void updateMemberStatus(UUID memberId, MemberStatus status, UUID tenantId) { ... }
|
||||
}
|
||||
|
||||
// ❌ Controller — prohibited
|
||||
@RestController
|
||||
public class MemberController {
|
||||
|
||||
@Transactional // Never here
|
||||
@GetMapping("/members/{id}")
|
||||
public MemberSummaryDto getMember(@PathVariable UUID id) { ... }
|
||||
}
|
||||
```
|
||||
|
||||
### Lombok Usage
|
||||
|
||||
| Annotation | Allowed | Notes |
|
||||
|---|---|---|
|
||||
| `@Getter` | ✅ | On entities and DTOs |
|
||||
| `@Setter` | ✅ | Use sparingly on entities; prefer builder pattern |
|
||||
| `@Builder` | ✅ | On entities and DTOs |
|
||||
| `@RequiredArgsConstructor` | ✅ | Services, beans (for DI) |
|
||||
| `@NoArgsConstructor` | ✅ | JPA requires no-arg constructor |
|
||||
| `@AllArgsConstructor` | ✅ | With `@Builder` |
|
||||
| `@ToString` | ✅ | Exclude sensitive fields: `@ToString.Exclude` on `passwordHash` etc. |
|
||||
| `@EqualsAndHashCode` | ✅ | Entities: only on `id` field |
|
||||
| `@Data` | ❌ | **Prohibited on entities** — generates mutable setters for all fields, breaks JPA proxy patterns |
|
||||
| `@SneakyThrows` | ❌ | Never hide checked exceptions |
|
||||
|
||||
### Code Style
|
||||
|
||||
- **Checkstyle config:** Google Java Style Guide (`checkstyle-google.xml` in parent POM)
|
||||
- **Indentation:** 4 spaces (no tabs)
|
||||
- **Line length:** 120 characters max
|
||||
- **No magic numbers** — use named constants or enums:
|
||||
|
||||
```java
|
||||
// ❌ Magic number
|
||||
if (member.getAge() < 21) { limit = 30; }
|
||||
|
||||
// ✅ Named constant
|
||||
if (member.getAge() < ComplianceConstants.AGE_LIMIT_UNDER21) {
|
||||
limit = ComplianceConstants.MONTHLY_LIMIT_UNDER21_GRAMS;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Compliance Code Rules
|
||||
|
||||
These rules apply exclusively to code that enforces CanG (Cannabisgesetz) distribution limits. Violations here carry legal risk.
|
||||
|
||||
### Compliance Constants
|
||||
|
||||
All legal limits live in a single, centrally tested constants class. **Never hardcode these values inline.**
|
||||
|
||||
```java
|
||||
// de.cannamanage.domain.compliance.ComplianceConstants
|
||||
public final class ComplianceConstants {
|
||||
|
||||
private ComplianceConstants() {} // no instantiation
|
||||
|
||||
/** Maximum grams per single distribution for any member. */
|
||||
public static final BigDecimal DAILY_LIMIT_GRAMS = new BigDecimal("25.0");
|
||||
|
||||
/** Monthly gram limit for adult members (age ≥ 21). */
|
||||
public static final BigDecimal MONTHLY_LIMIT_ADULT_GRAMS = new BigDecimal("50.0");
|
||||
|
||||
/** Monthly gram limit for members under 21 years of age (CanG §10 Abs.1). */
|
||||
public static final BigDecimal MONTHLY_LIMIT_UNDER21_GRAMS = new BigDecimal("30.0");
|
||||
|
||||
/** Age threshold below which the reduced monthly limit applies. */
|
||||
public static final int AGE_LIMIT_UNDER21 = 21;
|
||||
|
||||
/** Minimum age for club membership (CanG §15 Abs.1). */
|
||||
public static final int MINIMUM_MEMBER_AGE = 18;
|
||||
}
|
||||
```
|
||||
|
||||
### ComplianceService Rules
|
||||
|
||||
1. `ComplianceService` methods **must always execute within a `@Transactional` boundary** — either by being called from a service method already in a transaction, or by declaring `@Transactional` themselves. The compliance check and the distribution record creation must be atomic.
|
||||
|
||||
2. Every public method in `ComplianceService` must have a corresponding test in `ComplianceServiceTest` that exercises its boundary conditions.
|
||||
|
||||
3. `ComplianceService` is the **only** class permitted to read `ComplianceConstants` limits and make pass/fail decisions. No other class performs limit arithmetic.
|
||||
|
||||
```java
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ComplianceService {
|
||||
|
||||
private final DistributionRepository distributionRepository;
|
||||
|
||||
/**
|
||||
* Validates whether a distribution of the given weight is permitted for the member.
|
||||
*
|
||||
* <p>Checks the daily single-distribution limit and the member's monthly quota.
|
||||
* Must be called inside an existing @Transactional boundary — the calling
|
||||
* DistributionService is responsible for the transaction.
|
||||
*
|
||||
* @param memberId the member receiving the distribution
|
||||
* @param tenantId the club's tenant identifier
|
||||
* @param weightGrams the proposed distribution weight in grams
|
||||
* @return QuotaOk if permitted; QuotaWarning if >80% used; QuotaExceeded if over limit
|
||||
* @throws IllegalArgumentException if weightGrams exceeds DAILY_LIMIT_GRAMS
|
||||
*/
|
||||
public QuotaResult checkDistributionAllowed(UUID memberId, UUID tenantId, BigDecimal weightGrams) {
|
||||
if (weightGrams.compareTo(ComplianceConstants.DAILY_LIMIT_GRAMS) > 0) {
|
||||
throw new IllegalArgumentException(
|
||||
"Single distribution exceeds daily limit of " + ComplianceConstants.DAILY_LIMIT_GRAMS + "g");
|
||||
}
|
||||
// ... monthly quota logic using ComplianceConstants
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Distribution Record Immutability
|
||||
|
||||
Once written, a `Distribution` record may never be modified (legal audit trail requirement). Enforce this at the JPA level:
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "distributions")
|
||||
@Getter
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class Distribution extends AbstractTenantEntity {
|
||||
|
||||
@Column(name = "member_id", nullable = false, updatable = false)
|
||||
private UUID memberId;
|
||||
|
||||
@Column(name = "batch_id", nullable = false, updatable = false)
|
||||
private UUID batchId;
|
||||
|
||||
@Column(name = "weight_grams", nullable = false, updatable = false,
|
||||
precision = 8, scale = 2)
|
||||
private BigDecimal weightGrams;
|
||||
|
||||
@Column(name = "distributed_at", nullable = false, updatable = false)
|
||||
private Instant distributedAt;
|
||||
|
||||
@Column(name = "recorded_by_admin_id", nullable = false, updatable = false)
|
||||
private UUID recordedByAdminId;
|
||||
|
||||
// No setters — @Getter only, no @Setter
|
||||
// updatable = false on ALL columns — Hibernate will reject any UPDATE attempt
|
||||
}
|
||||
```
|
||||
|
||||
### Compliance Test Coverage Requirement
|
||||
|
||||
`ComplianceServiceTest` must include at minimum:
|
||||
|
||||
| Test Method | What It Covers |
|
||||
|---|---|
|
||||
| `checkDistributionAllowed_givenWeightAt25g_shouldReturnQuotaOk` | Exactly at daily limit |
|
||||
| `checkDistributionAllowed_givenWeightOver25g_shouldThrowIllegalArgument` | Daily limit exceeded |
|
||||
| `checkDistributionAllowed_givenMemberAtMonthlyLimit_shouldReturnQuotaExceeded` | Adult at 50g |
|
||||
| `checkDistributionAllowed_givenUnder21MemberAt30g_shouldReturnQuotaExceeded` | Under-21 at 30g |
|
||||
| `checkDistributionAllowed_givenUnder21MemberAtAdultLimit_shouldReturnQuotaExceeded` | Under-21 must not reach 50g |
|
||||
| `checkDistributionAllowed_givenMemberAt80Percent_shouldReturnQuotaWarning` | Warning threshold |
|
||||
| `checkDistributionAllowed_givenMemberAt40g_shouldReturnQuotaOk` | Normal adult, within limit |
|
||||
|
||||
---
|
||||
|
||||
## 4. Git Strategy
|
||||
|
||||
### Branching Model — GitHub Flow (Solo Dev)
|
||||
|
||||
```
|
||||
main ──────────────────────────────────────────────────────► (production-ready)
|
||||
│ │ │
|
||||
└─► feature/US-042─┘ └─► fix/member-age-edge ─┘
|
||||
```
|
||||
|
||||
| Branch | Purpose | Merge Via |
|
||||
|---|---|---|
|
||||
| `main` | Production-ready code only; protected | PR only |
|
||||
| `develop` | Integration branch for in-progress work | Merge to main when stable |
|
||||
| `feature/US-XXX-short-description` | New feature tied to a user story | PR → develop → main |
|
||||
| `fix/short-description` | Bug fix | PR → main (or develop if risk is low) |
|
||||
| `chore/short-description` | Dependency updates, config, CI | PR → main |
|
||||
|
||||
**Branch naming examples:**
|
||||
- `feature/US-042-compliance-quota-check`
|
||||
- `feature/US-015-member-registration-form`
|
||||
- `fix/member-under21-age-boundary`
|
||||
- `chore/update-spring-boot-3.3.1`
|
||||
|
||||
### Commit Message Format — Conventional Commits
|
||||
|
||||
```
|
||||
type(scope): short description (imperative, ≤72 chars)
|
||||
|
||||
[optional body — explain WHY, not WHAT; reference CanG sections if relevant]
|
||||
|
||||
[optional footer]
|
||||
BREAKING CHANGE: description if applicable
|
||||
Closes #issue-number
|
||||
```
|
||||
|
||||
#### Types
|
||||
|
||||
| Type | When to Use |
|
||||
|---|---|
|
||||
| `feat` | New feature or user-visible behavior |
|
||||
| `fix` | Bug fix |
|
||||
| `docs` | Documentation only |
|
||||
| `style` | Formatting, whitespace — no logic change |
|
||||
| `refactor` | Code restructuring — no behavior change |
|
||||
| `test` | Adding or updating tests |
|
||||
| `chore` | Build, deps, config, CI — no production code |
|
||||
|
||||
#### Scopes
|
||||
|
||||
| Scope | Module / Area |
|
||||
|---|---|
|
||||
| `member` | Member management |
|
||||
| `distribution` | Distribution recording and history |
|
||||
| `stock` | Strain and batch management |
|
||||
| `compliance` | `ComplianceService`, `ComplianceConstants`, CanG limits |
|
||||
| `auth` | JWT, Spring Security, login |
|
||||
| `report` | PDF/CSV generation |
|
||||
| `infra` | Docker, CI, Flyway migrations |
|
||||
| `web` | PrimeFaces JSF views and backing beans |
|
||||
| `api` | REST controllers and DTOs |
|
||||
|
||||
#### Commit Examples
|
||||
|
||||
```bash
|
||||
feat(compliance): add daily 25g distribution limit check
|
||||
|
||||
Implements CanG §10 Abs.1 single-distribution cap. ComplianceService
|
||||
now throws IllegalArgumentException before any quota calculation if
|
||||
weightGrams > ComplianceConstants.DAILY_LIMIT_GRAMS.
|
||||
|
||||
fix(member): correct under-21 flag when age is exactly 21
|
||||
|
||||
Age comparison was using < instead of <=. Members who turn 21 on the
|
||||
exact distribution date now correctly receive the adult (50g) limit.
|
||||
Closes #17
|
||||
|
||||
test(distribution): add quota boundary tests for 30g under-21 limit
|
||||
|
||||
Adds 6 parameterized test cases covering 28g, 29g, 29.9g, 30g, 30.1g,
|
||||
and 31g for under-21 members. All reference ComplianceConstants — no
|
||||
hardcoded values in test assertions.
|
||||
|
||||
chore(deps): update Spring Boot to 3.3.1
|
||||
|
||||
CVE-2024-38821 fix included. No API changes required.
|
||||
|
||||
docs(compliance): document ComplianceConstants usage policy in README
|
||||
```
|
||||
|
||||
### Tag Strategy
|
||||
|
||||
Semantic versioning: `v{MAJOR}.{MINOR}.{PATCH}`
|
||||
|
||||
```bash
|
||||
git tag -a v1.0.0 -m "Initial release — core member + distribution management"
|
||||
git tag -a v1.1.0 -m "Add member portal with quota view"
|
||||
git tag -a v1.0.1 -m "Fix under-21 monthly limit boundary condition"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Testing Standards
|
||||
|
||||
### Framework Stack
|
||||
|
||||
| Layer | Framework | Annotation / Config |
|
||||
|---|---|---|
|
||||
| Unit tests | JUnit 5 + Mockito | `@ExtendWith(MockitoExtension.class)` |
|
||||
| Integration tests | Spring Boot Test + Testcontainers | `@SpringBootTest`, `@Testcontainers` |
|
||||
| Web layer tests | `MockMvc` | `@WebMvcTest(DistributionController.class)` |
|
||||
| Repository tests | `DataJpaTest` + Testcontainers | Real PostgreSQL via Testcontainers |
|
||||
| PDF generation tests | JUnit 5 + iText assertions | Verify PDF structure, not pixel comparison |
|
||||
|
||||
### Test Naming Convention
|
||||
|
||||
```
|
||||
methodName_givenCondition_shouldExpectedBehavior
|
||||
```
|
||||
|
||||
```java
|
||||
// ✅ Correct
|
||||
@Test
|
||||
void checkDistributionAllowed_givenMemberAtMonthlyLimit_shouldThrowQuotaExceededException()
|
||||
|
||||
@Test
|
||||
void createDistribution_givenValidRequest_shouldPersistAndReturnDto()
|
||||
|
||||
@Test
|
||||
void getQuotaRemaining_givenUnder21Member_shouldCapAt30g()
|
||||
|
||||
@Test
|
||||
void generateMonthlyReport_givenNoPriorDistributions_shouldReturnZeroTotals()
|
||||
```
|
||||
|
||||
### Unit Test Structure
|
||||
|
||||
```java
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ComplianceServiceTest {
|
||||
|
||||
@Mock
|
||||
private DistributionRepository distributionRepository;
|
||||
|
||||
@InjectMocks
|
||||
private ComplianceService complianceService;
|
||||
|
||||
@Test
|
||||
void checkDistributionAllowed_givenMemberAtMonthlyLimit_shouldReturnQuotaExceeded() {
|
||||
// GIVEN
|
||||
UUID memberId = UUID.randomUUID();
|
||||
UUID tenantId = UUID.randomUUID();
|
||||
BigDecimal currentMonthTotal = ComplianceConstants.MONTHLY_LIMIT_ADULT_GRAMS;
|
||||
|
||||
when(distributionRepository.sumWeightByMemberAndMonth(eq(memberId), eq(tenantId), any()))
|
||||
.thenReturn(currentMonthTotal);
|
||||
|
||||
// WHEN
|
||||
QuotaResult result = complianceService.checkDistributionAllowed(
|
||||
memberId, tenantId, new BigDecimal("1.0"));
|
||||
|
||||
// THEN
|
||||
assertThat(result).isInstanceOf(QuotaExceeded.class);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Integration Test Structure
|
||||
|
||||
```java
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
@Transactional // rolls back after each test
|
||||
class DistributionServiceIntegrationTest {
|
||||
|
||||
@Container
|
||||
static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");
|
||||
|
||||
@DynamicPropertySource
|
||||
static void configureDataSource(DynamicPropertyRegistry registry) {
|
||||
registry.add("spring.datasource.url", postgres::getJdbcUrl);
|
||||
registry.add("spring.datasource.username", postgres::getUsername);
|
||||
registry.add("spring.datasource.password", postgres::getPassword);
|
||||
}
|
||||
|
||||
// Tests run against real PostgreSQL — Flyway migrations apply automatically
|
||||
}
|
||||
```
|
||||
|
||||
### Coverage Target
|
||||
|
||||
| Module | Line Coverage Target |
|
||||
|---|---|
|
||||
| `cannamanage-service` | **≥ 80%** (enforced by JaCoCo in CI) |
|
||||
| `cannamanage-domain` | ≥ 70% (entities + value objects) |
|
||||
| `cannamanage-api` | ≥ 70% (controllers via MockMvc) |
|
||||
| `cannamanage-report` | ≥ 60% (PDF generation harder to test) |
|
||||
| `cannamanage-web` | Best effort (JSF backing beans — limited testability) |
|
||||
|
||||
### Test Rules
|
||||
|
||||
1. **No test may hardcode a compliance limit value.** All assertions must reference `ComplianceConstants`:
|
||||
|
||||
```java
|
||||
// ❌ Prohibited
|
||||
assertThat(limit).isEqualTo(new BigDecimal("50.0"));
|
||||
|
||||
// ✅ Required
|
||||
assertThat(limit).isEqualTo(ComplianceConstants.MONTHLY_LIMIT_ADULT_GRAMS);
|
||||
```
|
||||
|
||||
2. Parameterized tests (`@ParameterizedTest`) are strongly preferred for boundary condition coverage.
|
||||
|
||||
3. Test data builders (or fixtures) must live in `src/test/java/.../fixtures/` — no anonymous object creation scattered across test methods.
|
||||
|
||||
---
|
||||
|
||||
## 6. Code Review Checklist
|
||||
|
||||
Since CannaManage is a solo development project, a self-review checklist replaces a peer review process. All items must be checked before merging any PR to `main`.
|
||||
|
||||
### Self-Review Checklist
|
||||
|
||||
```markdown
|
||||
## Compliance & Legal
|
||||
- [ ] All distribution limits reference `ComplianceConstants` — zero hardcoded values
|
||||
- [ ] `Distribution` entity fields are annotated `@Column(updatable = false)` where required
|
||||
- [ ] `ComplianceService` calls are only made inside `@Transactional` boundaries
|
||||
- [ ] New compliance rules have corresponding unit tests in `ComplianceServiceTest`
|
||||
|
||||
## Data & Multi-Tenancy
|
||||
- [ ] New entity extends `AbstractTenantEntity`
|
||||
- [ ] `tenant_id` is never accepted from user input (HTTP body, query param, path variable)
|
||||
- [ ] All repository queries filter by `tenantId` — no cross-tenant data leakage possible
|
||||
|
||||
## Security & DSGVO
|
||||
- [ ] No PII in log statements (no email, full name, member number in log lines)
|
||||
- [ ] No passwords, tokens, or secrets hardcoded anywhere
|
||||
- [ ] New REST endpoints annotated with `@PreAuthorize`
|
||||
- [ ] DTOs validated with Bean Validation annotations (`@NotNull`, `@Size`, etc.)
|
||||
|
||||
## Database
|
||||
- [ ] Flyway migration file added for any schema change (`V{n}__description.sql`)
|
||||
- [ ] Migration file is backward-compatible or includes rollback notes
|
||||
- [ ] No `@Column(nullable = false)` added without corresponding DB migration
|
||||
|
||||
## Code Quality
|
||||
- [ ] Constructor injection used — no `@Autowired` field injection
|
||||
- [ ] No `@Data` on JPA entities
|
||||
- [ ] No magic numbers — named constants or enums used
|
||||
- [ ] Checkstyle passes locally (`./mvnw checkstyle:check`)
|
||||
- [ ] Javadoc on all public service methods
|
||||
|
||||
## Testing
|
||||
- [ ] Unit test added for new service method
|
||||
- [ ] Integration test updated if schema or contract changed
|
||||
- [ ] Test coverage does not decrease in `cannamanage-service`
|
||||
- [ ] Test method names follow `method_givenCondition_shouldExpect` pattern
|
||||
|
||||
## General
|
||||
- [ ] Commit message follows Conventional Commits format
|
||||
- [ ] Branch name follows `feature/US-XXX-` or `fix/` convention
|
||||
- [ ] No `TODO` comments left in production code (use GitHub Issues instead)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Security Standards
|
||||
|
||||
### Authentication & Authorization
|
||||
|
||||
```java
|
||||
// JWT secret from environment only — never in application.properties
|
||||
@Value("${JWT_SECRET}")
|
||||
private String jwtSecret;
|
||||
|
||||
// All endpoints behind @PreAuthorize — no security by obscurity
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/distributions")
|
||||
public class DistributionController {
|
||||
|
||||
@GetMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public Page<DistributionDto> list(...) { ... }
|
||||
|
||||
@PostMapping
|
||||
@PreAuthorize("hasRole('ADMIN')")
|
||||
public DistributionDto create(...) { ... }
|
||||
}
|
||||
|
||||
// Member portal endpoints restricted to role + own data
|
||||
@GetMapping("/api/v1/member/quota")
|
||||
@PreAuthorize("hasRole('MEMBER') and #memberId == authentication.principal.memberId")
|
||||
public QuotaDto getQuota(@RequestParam UUID memberId) { ... }
|
||||
```
|
||||
|
||||
### CORS Configuration
|
||||
|
||||
```java
|
||||
@Bean
|
||||
CorsConfigurationSource corsConfigurationSource() {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
// No wildcard — club subdomain only
|
||||
config.setAllowedOriginPatterns(List.of("https://*.cannamanage.de"));
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
|
||||
config.setAllowCredentials(true);
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
### Input Validation
|
||||
|
||||
All DTOs must be annotated with Bean Validation constraints. The controller calls `@Valid` on request bodies.
|
||||
|
||||
```java
|
||||
public record CreateDistributionRequest(
|
||||
|
||||
@NotNull(message = "Member ID is required")
|
||||
UUID memberId,
|
||||
|
||||
@NotNull(message = "Batch ID is required")
|
||||
UUID batchId,
|
||||
|
||||
@NotNull(message = "Weight is required")
|
||||
@DecimalMin(value = "0.1", message = "Weight must be at least 0.1g")
|
||||
@DecimalMax(value = "25.0", message = "Weight cannot exceed daily limit")
|
||||
BigDecimal weightGrams
|
||||
) {}
|
||||
```
|
||||
|
||||
### SQL Injection Prevention
|
||||
|
||||
- **JPA named queries only** — no string concatenation in JPQL
|
||||
- Spring Data JPA repository methods generate parameterized queries automatically
|
||||
- Native SQL queries use `@Query` with named parameters (`:param` syntax), never `+`
|
||||
|
||||
```java
|
||||
// ✅ Safe — parameterized
|
||||
@Query("SELECT SUM(d.weightGrams) FROM Distribution d WHERE d.memberId = :memberId AND d.tenantId = :tenantId AND MONTH(d.distributedAt) = :month")
|
||||
BigDecimal sumWeightByMemberAndMonth(@Param("memberId") UUID memberId,
|
||||
@Param("tenantId") UUID tenantId,
|
||||
@Param("month") int month);
|
||||
|
||||
// ❌ Prohibited — SQL injection risk
|
||||
String jpql = "SELECT ... WHERE name = '" + memberName + "'";
|
||||
```
|
||||
|
||||
### Password Hashing
|
||||
|
||||
```java
|
||||
@Bean
|
||||
PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder(12); // strength 12 — ~250ms per hash on modern hardware
|
||||
}
|
||||
```
|
||||
|
||||
### Sensitive Data Logging
|
||||
|
||||
```java
|
||||
// ❌ Never log PII
|
||||
log.info("Processing distribution for member: {}", member.getEmail());
|
||||
log.info("Member {} requested quota", member.getFullName());
|
||||
|
||||
// ✅ Log with opaque identifiers only
|
||||
log.info("Processing distribution for memberId={} tenantId={}", member.getId(), tenantId);
|
||||
log.info("Quota check passed for memberId={}", memberId);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Environment Configuration
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
All secrets and environment-specific configuration are provided via environment variables. Never commit secrets to version control.
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `DB_URL` | ✅ | — | JDBC URL, e.g. `jdbc:postgresql://localhost:5432/cannamanage` |
|
||||
| `DB_USERNAME` | ✅ | — | PostgreSQL username |
|
||||
| `DB_PASSWORD` | ✅ | — | PostgreSQL password |
|
||||
| `JWT_SECRET` | ✅ | — | 256-bit (32-byte) random secret for JWT signing; generate with `openssl rand -base64 32` |
|
||||
| `JWT_ACCESS_TTL_HOURS` | ❌ | `8` | Access token TTL in hours |
|
||||
| `JWT_REFRESH_TTL_DAYS` | ❌ | `30` | Refresh token TTL in days |
|
||||
| `STRIPE_SECRET_KEY` | ✅ (billing) | — | Stripe secret key (starts with `sk_live_` in production) |
|
||||
| `STRIPE_WEBHOOK_SECRET` | ✅ (billing) | — | Stripe webhook signing secret for subscription events |
|
||||
| `MAIL_HOST` | ✅ | — | SMTP host for transactional emails |
|
||||
| `MAIL_USERNAME` | ✅ | — | SMTP username |
|
||||
| `MAIL_PASSWORD` | ✅ | — | SMTP password |
|
||||
| `MAIL_FROM` | ❌ | `noreply@cannamanage.de` | From address for system emails |
|
||||
| `SENTRY_DSN` | ❌ | — | Sentry DSN for error tracking; omit to disable |
|
||||
| `APP_BASE_URL` | ✅ | — | Application base URL, e.g. `https://meinclub.cannamanage.de` |
|
||||
| `ADMIN_INITIAL_EMAIL` | ❌ | — | Seed admin email on first startup (Flyway data migration) |
|
||||
| `ADMIN_INITIAL_PASSWORD` | ❌ | — | Seed admin password — change immediately after first login |
|
||||
|
||||
### `application.properties` Pattern
|
||||
|
||||
```properties
|
||||
# application.properties — references env vars only; no values hardcoded
|
||||
|
||||
spring.datasource.url=${DB_URL}
|
||||
spring.datasource.username=${DB_USERNAME}
|
||||
spring.datasource.password=${DB_PASSWORD}
|
||||
|
||||
spring.jpa.hibernate.ddl-auto=validate
|
||||
spring.flyway.enabled=true
|
||||
spring.flyway.locations=classpath:db/migration
|
||||
|
||||
jwt.secret=${JWT_SECRET}
|
||||
jwt.access.ttl-hours=${JWT_ACCESS_TTL_HOURS:8}
|
||||
jwt.refresh.ttl-days=${JWT_REFRESH_TTL_DAYS:30}
|
||||
|
||||
stripe.secret-key=${STRIPE_SECRET_KEY}
|
||||
stripe.webhook-secret=${STRIPE_WEBHOOK_SECRET}
|
||||
|
||||
spring.mail.host=${MAIL_HOST}
|
||||
spring.mail.username=${MAIL_USERNAME}
|
||||
spring.mail.password=${MAIL_PASSWORD}
|
||||
|
||||
sentry.dsn=${SENTRY_DSN:}
|
||||
```
|
||||
|
||||
### Profile Strategy
|
||||
|
||||
> **`spring.profiles.active=prod` is NOT a security mechanism.** Never use profile-based condition checks to gate security-relevant behavior (e.g., `@ConditionalOnProperty(name="spring.profiles.active", havingValue="prod")`).
|
||||
|
||||
Profiles are used **only** for infrastructure wiring (in-memory H2 vs. real PostgreSQL for tests, Testcontainers vs. external DB).
|
||||
|
||||
| Profile | Usage |
|
||||
|---|---|
|
||||
| `(none)` | Production — all config from environment variables |
|
||||
| `test` | JUnit integration tests — Testcontainers PostgreSQL |
|
||||
| `dev` | Local development — Docker Compose PostgreSQL, verbose SQL logging |
|
||||
|
||||
### Local Development Setup
|
||||
|
||||
```bash
|
||||
# Start local PostgreSQL via Docker Compose
|
||||
docker compose up -d postgres
|
||||
|
||||
# Run with dev profile (verbose SQL, local DB)
|
||||
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev \
|
||||
-Dspring-boot.run.arguments="--DB_URL=jdbc:postgresql://localhost:5432/cannamanage_dev \
|
||||
--DB_USERNAME=cannamanage --DB_PASSWORD=dev_password \
|
||||
--JWT_SECRET=$(openssl rand -base64 32)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*End of CannaManage coding standards. See also [03-ARCHITECTURE.md](03-ARCHITECTURE.md) for data model and [05-API-SPEC.md](05-API-SPEC.md) for REST contract.*
|
||||
@@ -0,0 +1,439 @@
|
||||
# 08 — Test Plan
|
||||
|
||||
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
|
||||
**Version:** 0.1.0-PLAN
|
||||
**Date:** 2026-04-06
|
||||
**Status:** Draft
|
||||
|
||||
---
|
||||
|
||||
## 1. Test Strategy Overview
|
||||
|
||||
### 1.1 Testing Pyramid
|
||||
|
||||
```
|
||||
┌─────────────────┐
|
||||
│ E2E Tests │ 10% — Playwright (deferred to v2)
|
||||
│ (10%) │
|
||||
├─────────────────┤
|
||||
│ Integration │ 20% — Spring Boot Test + Testcontainers
|
||||
│ Tests (20%) │
|
||||
├─────────────────┤
|
||||
│ Unit Tests │ 70% — JUnit 5 + Mockito
|
||||
│ (70%) │
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
The compliance-critical path (`ComplianceService`) requires **100% line coverage** — no exceptions. Every quota rule is a legal obligation under CanG §§19–22.
|
||||
|
||||
### 1.2 Tools and Frameworks
|
||||
|
||||
| Layer | Tool | Purpose |
|
||||
|-------|------|---------|
|
||||
| Unit | JUnit 5 (`junit-jupiter`) | Test runner |
|
||||
| Unit | Mockito 5 | Mock dependencies |
|
||||
| Unit | AssertJ | Fluent assertions |
|
||||
| Integration | Spring Boot Test (`@SpringBootTest`) | Full application context |
|
||||
| Integration | Testcontainers (PostgreSQL module) | Real DB in Docker |
|
||||
| Integration | MockMvc / RestAssured | HTTP layer testing |
|
||||
| Coverage | JaCoCo | Line/branch coverage reporting |
|
||||
| E2E | Playwright (Java) | Browser automation — **deferred to v2** |
|
||||
|
||||
### 1.3 CI Trigger Policy
|
||||
|
||||
| Branch pattern | Tests run |
|
||||
|---------------|-----------|
|
||||
| `feature/*` | Unit tests only (`./mvnw test`) |
|
||||
| `develop` | Unit + Integration (`./mvnw verify -P integration-tests`) |
|
||||
| `main` | Unit + Integration + coverage gate |
|
||||
|
||||
Coverage gate blocks merge to `main` if `ComplianceService` drops below 100%.
|
||||
|
||||
---
|
||||
|
||||
## 2. Unit Test Cases — ComplianceService
|
||||
|
||||
**Class under test:** `de.cannamanage.service.ComplianceService`
|
||||
**Dependencies mocked:** `DistributionRepository`, `MemberRepository`, `StrainRepository`
|
||||
|
||||
---
|
||||
|
||||
**TC-001** | `checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly`
|
||||
- **Given:** Adult member (age 35) with exactly 50.0g distributed this calendar month, requesting 1.0g more
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)`
|
||||
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY`
|
||||
- **Compliance ref:** CanG §19(2) — 50g/month limit for adults
|
||||
|
||||
---
|
||||
|
||||
**TC-002** | `checkDistributionAllowed_givenUnder21AtMonthlyLimit_shouldThrowQuotaExceededMonthly`
|
||||
- **Given:** Under-21 member (age 18) with exactly 30.0g distributed this calendar month, requesting 1.0g more
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)`
|
||||
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY`
|
||||
- **Compliance ref:** CanG §19(3) — 30g/month limit for under-21 members
|
||||
|
||||
---
|
||||
|
||||
**TC-003** | `checkDistributionAllowed_givenAdultAtDailyLimit_shouldThrowQuotaExceededDaily`
|
||||
- **Given:** Adult member with exactly 25.0g distributed today, requesting 0.5g more
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 0.5)`
|
||||
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_DAILY`
|
||||
- **Compliance ref:** CanG §19(2) — 25g/day limit
|
||||
|
||||
---
|
||||
|
||||
**TC-004** | `checkDistributionAllowed_givenUnder21RequestingHighThcStrain_shouldThrowHighThcRestricted`
|
||||
- **Given:** Under-21 member (age 20), requesting a strain with THC content 12.5% (exceeds 10% threshold)
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 5.0, strainId)`
|
||||
- **Then:** Throws `QuotaExceededException` with code `HIGH_THC_RESTRICTED_UNDER_21`
|
||||
- **Compliance ref:** CanG §19(4) — under-21 members restricted to ≤10% THC strains
|
||||
|
||||
---
|
||||
|
||||
**TC-005** | `checkDistributionAllowed_givenAdultAt49gMonthlyRequesting2g_shouldThrowQuotaExceededMonthly`
|
||||
- **Given:** Adult member with 49.0g distributed this month, requesting 2.0g (would total 51.0g)
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 2.0)`
|
||||
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_MONTHLY`
|
||||
- **Note:** Even partial over-quota requests must be rejected in full; no partial fulfillment
|
||||
|
||||
---
|
||||
|
||||
**TC-006** | `checkDistributionAllowed_givenAdultAt0gRequesting25g_shouldReturnAllowed`
|
||||
- **Given:** Adult member with 0.0g distributed this month and today, requesting 25.0g
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 25.0)`
|
||||
- **Then:** Returns `DistributionAllowedResult` with `allowed = true`, `remainingDaily = 0.0`, `remainingMonthly = 25.0`
|
||||
- **Note:** Exactly at daily limit — allowed
|
||||
|
||||
---
|
||||
|
||||
**TC-007** | `checkDistributionAllowed_givenAdultAt24_9gDailyRequesting0_1g_shouldReturnAllowed`
|
||||
- **Given:** Adult member with 24.9g distributed today, requesting 0.1g (would reach exactly 25.0g)
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 0.1)`
|
||||
- **Then:** Returns `allowed = true`, `remainingDaily = 0.0`
|
||||
- **Note:** Boundary — exactly at limit is allowed
|
||||
|
||||
---
|
||||
|
||||
**TC-008** | `checkDistributionAllowed_givenAdultAt24_9gDailyRequesting0_2g_shouldThrowQuotaExceededDaily`
|
||||
- **Given:** Adult member with 24.9g distributed today, requesting 0.2g (would total 25.1g)
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 0.2)`
|
||||
- **Then:** Throws `QuotaExceededException` with code `QUOTA_EXCEEDED_DAILY`
|
||||
- **Note:** Boundary + 1 — must be blocked
|
||||
|
||||
---
|
||||
|
||||
**TC-009** | `checkDistributionAllowed_givenSuspendedMember_shouldThrowMemberInactive`
|
||||
- **Given:** Member with `status = MemberStatus.SUSPENDED`, requesting any amount
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 1.0)`
|
||||
- **Then:** Throws `QuotaExceededException` with code `MEMBER_INACTIVE`
|
||||
- **Note:** Status check must occur before any quota calculation
|
||||
|
||||
---
|
||||
|
||||
**TC-010** | `checkDistributionAllowed_givenExpelledMember_shouldThrowMemberInactive`
|
||||
- **Given:** Member with `status = MemberStatus.EXPELLED`, requesting any amount
|
||||
- **When:** `complianceService.checkDistributionAllowed(memberId, 5.0)`
|
||||
- **Then:** Throws `QuotaExceededException` with code `MEMBER_INACTIVE`
|
||||
- **Note:** Expelled members are permanently blocked, no quota check performed
|
||||
|
||||
---
|
||||
|
||||
## 3. Unit Test Cases — MemberService
|
||||
|
||||
**Class under test:** `de.cannamanage.service.MemberService`
|
||||
**Dependencies mocked:** `MemberRepository`, `ClubRepository`, `PasswordEncoder`
|
||||
|
||||
---
|
||||
|
||||
**TC-011** | `createMember_givenAge17_shouldThrowUnderageException`
|
||||
- **Given:** `CreateMemberRequest` with DOB resulting in age 17 at time of registration
|
||||
- **When:** `memberService.createMember(request, tenantId)`
|
||||
- **Then:** Throws `UnderageException` with message containing minimum age (18)
|
||||
- **Compliance ref:** CanG §6(1) — membership requires minimum age 18
|
||||
|
||||
---
|
||||
|
||||
**TC-012** | ~~`createMember_givenAge18_shouldSucceedAndSetIsUnder21False`~~ — *this case is incorrect*
|
||||
|
||||
> **Note:** Age 18 IS under 21, therefore `isUnder21 = true`. See TC-013.
|
||||
|
||||
---
|
||||
|
||||
**TC-013** | `createMember_givenAge18_shouldSucceedAndSetIsUnder21True`
|
||||
- **Given:** `CreateMemberRequest` with DOB resulting in age 18 at time of registration
|
||||
- **When:** `memberService.createMember(request, tenantId)`
|
||||
- **Then:** Returns created `Member` with `isUnder21 = true`, `status = ACTIVE`
|
||||
- **Note:** The 18-year-old flag matters for quota enforcement (30g/month, ≤10% THC)
|
||||
|
||||
---
|
||||
|
||||
**TC-014** | `createMember_givenAge21_shouldSucceedAndSetIsUnder21False`
|
||||
- **Given:** `CreateMemberRequest` with DOB resulting in age exactly 21 at time of registration
|
||||
- **When:** `memberService.createMember(request, tenantId)`
|
||||
- **Then:** Returns created `Member` with `isUnder21 = false`, `status = ACTIVE`
|
||||
- **Note:** Age 21 is the boundary — exactly 21 qualifies as adult for quota purposes
|
||||
|
||||
---
|
||||
|
||||
**TC-015** | `createMember_givenDuplicateEmail_shouldThrowDuplicateMemberException`
|
||||
- **Given:** `MemberRepository.existsByEmailAndTenantId(email, tenantId)` returns `true`
|
||||
- **When:** `memberService.createMember(request, tenantId)`
|
||||
- **Then:** Throws `DuplicateMemberException` with code `DUPLICATE_EMAIL`
|
||||
- **Note:** Email uniqueness is per-tenant, not global
|
||||
|
||||
---
|
||||
|
||||
## 4. Unit Test Cases — Tenant Isolation
|
||||
|
||||
**Class under test:** JPA repositories with `@TenantAware` filter active
|
||||
**Setup:** Thread-local `TenantContext` populated via `TenantContextHolder.setTenantId()`
|
||||
|
||||
---
|
||||
|
||||
**TC-016** | `distributionRepository_givenTenantAContext_shouldNotReturnTenantBData`
|
||||
- **Given:** Two tenants (Tenant A: UUID-A, Tenant B: UUID-B); 5 distributions for each; `TenantContextHolder` set to UUID-A
|
||||
- **When:** `distributionRepository.findAll()`
|
||||
- **Then:** Returns exactly 5 records, all with `tenantId = UUID-A`; zero records from Tenant B
|
||||
- **Note:** Hibernate filter `tenantFilter` must be enabled in `TenantAwareInterceptor`
|
||||
|
||||
---
|
||||
|
||||
**TC-017** | `memberRepository_givenTenantAContext_shouldNotSeeClubBMembers`
|
||||
- **Given:** 10 members in Club A, 8 members in Club B; context set to Club A's tenant
|
||||
- **When:** `memberRepository.findAll()`
|
||||
- **Then:** Returns exactly 10 records; no member from Club B present
|
||||
- **Note:** Cross-tenant data leakage is a GDPR violation, not just a business bug
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration Test Cases (Testcontainers)
|
||||
|
||||
**Setup:** `@SpringBootTest(webEnvironment = RANDOM_PORT)` with `@Testcontainers`; real PostgreSQL 16 container; Flyway migrations applied before each test class.
|
||||
|
||||
---
|
||||
|
||||
**TC-018** | `POST /api/v1/distributions — successful distribution recording`
|
||||
- **Given:** Active adult member with 0g distributed; valid `DistributionRequest` for 10.0g; authenticated as `ROLE_ADMIN`
|
||||
- **When:** `POST /api/v1/distributions` with valid JWT
|
||||
- **Then:** HTTP 201; response body contains `distributionId`, `amount = 10.0`, `recordedAt`; DB contains one `distribution` row with `is_recalled = false`
|
||||
|
||||
---
|
||||
|
||||
**TC-019** | `POST /api/v1/distributions — quota exceeded returns 422`
|
||||
- **Given:** Adult member with 50.0g already distributed this month; requesting 1.0g more
|
||||
- **When:** `POST /api/v1/distributions`
|
||||
- **Then:** HTTP 422; response body `{"errorCode": "QUOTA_EXCEEDED_MONTHLY", "remainingMonthly": 0.0}`
|
||||
|
||||
---
|
||||
|
||||
**TC-020** | `POST /api/v1/distributions — concurrent race condition (last gram)`
|
||||
- **Given:** Adult member with 24.9g distributed today; two concurrent requests each for 0.2g (both would individually exceed 25g/day)
|
||||
- **When:** Both requests fired simultaneously via two threads
|
||||
- **Then:** Exactly one succeeds (HTTP 201); exactly one fails (HTTP 422 `QUOTA_EXCEEDED_DAILY`); DB total does not exceed 25.0g
|
||||
- **Mechanism:** `SELECT ... FOR UPDATE` on quota aggregation query prevents double-spend
|
||||
|
||||
---
|
||||
|
||||
**TC-021** | `POST /api/v1/auth/login — valid credentials return JWT`
|
||||
- **Given:** Admin user with email `admin@test-club.de`, correct password
|
||||
- **When:** `POST /api/v1/auth/login` with `{"email": "admin@test-club.de", "password": "..."}`
|
||||
- **Then:** HTTP 200; response contains `accessToken` (JWT), `tokenType = "Bearer"`, `expiresIn = 3600`
|
||||
|
||||
---
|
||||
|
||||
**TC-022** | `POST /api/v1/auth/login — invalid credentials return 401`
|
||||
- **Given:** Admin user exists; wrong password provided
|
||||
- **When:** `POST /api/v1/auth/login` with wrong password
|
||||
- **Then:** HTTP 401; response `{"errorCode": "INVALID_CREDENTIALS"}`; no token issued
|
||||
|
||||
---
|
||||
|
||||
**TC-023** | `GET /api/v1/members — ROLE_MEMBER accessing admin endpoint returns 403`
|
||||
- **Given:** Authenticated user with `ROLE_MEMBER` JWT (not ROLE_ADMIN)
|
||||
- **When:** `GET /api/v1/members` (admin-only endpoint)
|
||||
- **Then:** HTTP 403; response `{"errorCode": "FORBIDDEN"}`
|
||||
|
||||
---
|
||||
|
||||
**TC-024** | `GET /api/v1/members/{id}/quota — member accessing own quota returns 200`
|
||||
- **Given:** Authenticated member with JWT; requesting their own `memberId`
|
||||
- **When:** `GET /api/v1/members/{ownId}/quota`
|
||||
- **Then:** HTTP 200; response contains `dailyUsed`, `dailyRemaining`, `monthlyUsed`, `monthlyRemaining`, `isUnder21`
|
||||
|
||||
---
|
||||
|
||||
**TC-025** | `GET /api/v1/members/{otherMemberId}/quota — member accessing other member returns 403`
|
||||
- **Given:** Authenticated member requesting quota of a *different* member (same club)
|
||||
- **When:** `GET /api/v1/members/{otherMemberId}/quota`
|
||||
- **Then:** HTTP 403; GDPR principle: members must not see each other's consumption data
|
||||
|
||||
---
|
||||
|
||||
**TC-026** | `POST /api/v1/stock/batches/{id}/recall — verify cascade`
|
||||
- **Given:** Batch `BATCH-TEST-001` with 3 distributions recorded against it; `isRecalled = false`
|
||||
- **When:** `POST /api/v1/stock/batches/BATCH-TEST-001/recall` with `{"reason": "Contamination detected"}`
|
||||
- **Then:** HTTP 200; batch `isRecalled = true`; all 3 distribution records have `isRecalled = true`; response body contains list of 3 affected member IDs for notification
|
||||
|
||||
---
|
||||
|
||||
## 6. Test Data Fixtures
|
||||
|
||||
Define these constants in `src/test/java/de/cannamanage/fixtures/TestFixtures.java`:
|
||||
|
||||
```java
|
||||
public final class TestFixtures {
|
||||
|
||||
// Tenant
|
||||
public static final UUID TENANT_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000001");
|
||||
public static final String CLUB_NAME = "Test Cannabis Club e.V.";
|
||||
|
||||
// Adult member
|
||||
public static final UUID ADULT_MEMBER_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000010");
|
||||
public static final String ADULT_MEMBER_NAME = "Klaus Mueller";
|
||||
public static final LocalDate ADULT_MEMBER_DOB =
|
||||
LocalDate.of(1990, 1, 1); // age 36 as of 2026
|
||||
|
||||
// Under-21 member
|
||||
public static final UUID UNDER21_MEMBER_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000011");
|
||||
public static final String UNDER21_MEMBER_NAME = "Lisa Mayer";
|
||||
public static final LocalDate UNDER21_MEMBER_DOB =
|
||||
LocalDate.of(2007, 1, 1); // age 19 as of 2026, isUnder21=true
|
||||
|
||||
// Strain
|
||||
public static final UUID STRAIN_ID =
|
||||
UUID.fromString("00000000-0000-0000-0000-000000000020");
|
||||
public static final String STRAIN_NAME = "Test OG";
|
||||
public static final double STRAIN_THC_PERCENT = 20.0;
|
||||
public static final double STRAIN_CBD_PERCENT = 1.0;
|
||||
|
||||
// Batch
|
||||
public static final String BATCH_NUMBER = "BATCH-TEST-001";
|
||||
public static final double BATCH_INITIAL_WEIGHT_G = 500.0;
|
||||
|
||||
// Compliance constants (mirror ComplianceConstants.java)
|
||||
public static final double ADULT_MONTHLY_LIMIT_G = 50.0;
|
||||
public static final double UNDER21_MONTHLY_LIMIT_G = 30.0;
|
||||
public static final double DAILY_LIMIT_G = 25.0;
|
||||
public static final double UNDER21_MAX_THC_PERCENT = 10.0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Coverage Requirements
|
||||
|
||||
| Module | Test Type | Minimum Coverage | Enforcement |
|
||||
|--------|-----------|-----------------|-------------|
|
||||
| `cannamanage-service` | Unit | 80% line | JaCoCo CI gate |
|
||||
| `cannamanage-api` | Integration | 70% endpoint coverage | Manual checklist |
|
||||
| `cannamanage-domain` | Unit | 60% line (entities/enums) | JaCoCo CI gate |
|
||||
| `ComplianceService` | Unit | **100% line + branch** | JaCoCo CI gate — hard fail |
|
||||
| `TenantIsolationFilter` | Unit + Integration | 90% line | JaCoCo CI gate |
|
||||
|
||||
> **Rationale for 100% on ComplianceService:** Each uncovered line is a potential legal violation. A missed branch in quota calculation could result in over-distribution — a criminal offence under CanG §34. This is not negotiable.
|
||||
|
||||
### JaCoCo Configuration (`pom.xml`)
|
||||
|
||||
```xml
|
||||
<plugin>
|
||||
<groupId>org.jacoco</groupId>
|
||||
<artifactId>jacoco-maven-plugin</artifactId>
|
||||
<version>0.8.12</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>jacoco-check</id>
|
||||
<goals><goal>check</goal></goals>
|
||||
<configuration>
|
||||
<rules>
|
||||
<rule>
|
||||
<element>CLASS</element>
|
||||
<includes>
|
||||
<include>de.cannamanage.service.ComplianceService</include>
|
||||
</includes>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>1.00</minimum>
|
||||
</limit>
|
||||
<limit>
|
||||
<counter>BRANCH</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>1.00</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
<rule>
|
||||
<element>PACKAGE</element>
|
||||
<includes>
|
||||
<include>de.cannamanage.service.*</include>
|
||||
</includes>
|
||||
<limits>
|
||||
<limit>
|
||||
<counter>LINE</counter>
|
||||
<value>COVEREDRATIO</value>
|
||||
<minimum>0.80</minimum>
|
||||
</limit>
|
||||
</limits>
|
||||
</rule>
|
||||
</rules>
|
||||
</configuration>
|
||||
</execution>
|
||||
</executions>
|
||||
</plugin>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Test Execution
|
||||
|
||||
```bash
|
||||
# Run all unit tests
|
||||
./mvnw test -pl cannamanage-service
|
||||
|
||||
# Run integration tests (requires Docker for Testcontainers)
|
||||
./mvnw verify -P integration-tests
|
||||
|
||||
# Run specific test class
|
||||
./mvnw test -pl cannamanage-service -Dtest=ComplianceServiceTest
|
||||
|
||||
# Coverage report (output: target/site/jacoco/index.html)
|
||||
./mvnw verify jacoco:report
|
||||
|
||||
# Coverage report for single module
|
||||
./mvnw verify jacoco:report -pl cannamanage-service
|
||||
|
||||
# Run compliance tests only (tagged)
|
||||
./mvnw test -pl cannamanage-service -Dgroups=compliance
|
||||
|
||||
# Check coverage gate (will fail build if thresholds not met)
|
||||
./mvnw verify -P coverage-check
|
||||
```
|
||||
|
||||
### Testcontainers Docker requirement
|
||||
|
||||
Integration tests require Docker available on the host. The PostgreSQL container is pulled automatically by Testcontainers on first run. Ensure:
|
||||
- Docker daemon running: `systemctl start docker` (or `docker info`)
|
||||
- User in `docker` group: `sudo usermod -aG docker $USER`
|
||||
|
||||
### Test annotation conventions
|
||||
|
||||
```java
|
||||
// Unit test — no Spring context
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ComplianceServiceTest { ... }
|
||||
|
||||
// Integration test — full context + Testcontainers
|
||||
@SpringBootTest
|
||||
@Testcontainers
|
||||
@ActiveProfiles("test")
|
||||
class DistributionIntegrationTest { ... }
|
||||
|
||||
// Tag compliance tests for selective execution
|
||||
@Tag("compliance")
|
||||
@Test
|
||||
void checkDistributionAllowed_givenAdultAtMonthlyLimit_shouldThrowQuotaExceededMonthly() { ... }
|
||||
```
|
||||
@@ -0,0 +1,639 @@
|
||||
# 09 — Deployment Guide
|
||||
|
||||
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
|
||||
**Version:** 0.1.0-PLAN
|
||||
**Date:** 2026-04-06
|
||||
**Target environment:** Hetzner VPS — Ubuntu 22.04 LTS — Docker Compose
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerequisites
|
||||
|
||||
### Hetzner VPS Specification
|
||||
|
||||
| Resource | Value | Monthly Cost |
|
||||
|----------|-------|-------------|
|
||||
| Server type | CX21 | ~€5.88/month |
|
||||
| vCPU | 2 | — |
|
||||
| RAM | 4 GB | — |
|
||||
| SSD | 40 GB | — |
|
||||
| Network | 20 TB transfer | — |
|
||||
| OS | Ubuntu 22.04 LTS | — |
|
||||
|
||||
> **Scale-up trigger:** Upgrade to CX31 (8GB RAM) when concurrent active clubs exceeds 20. PostgreSQL is the memory consumer — headroom is consumed by connection pools, not application heap.
|
||||
|
||||
### DNS Setup
|
||||
|
||||
| Record | Type | Value |
|
||||
|--------|------|-------|
|
||||
| `cannamanage.de` | A | `<VPS-IP>` |
|
||||
| `app.cannamanage.de` | A | `<VPS-IP>` |
|
||||
| `*.cannamanage.de` | A | `<VPS-IP>` |
|
||||
|
||||
Wildcard A record enables future per-club subdomains (`clubname.cannamanage.de`) without additional DNS changes.
|
||||
|
||||
### Required Software
|
||||
|
||||
- Docker Engine 24+ (`docker.io` or Docker CE)
|
||||
- Docker Compose v2 (`docker compose` — not `docker-compose`)
|
||||
- Certbot with Nginx plugin (`python3-certbot-nginx`)
|
||||
- OpenSSH server (enabled by default on Ubuntu)
|
||||
|
||||
---
|
||||
|
||||
## 2. Infrastructure Architecture
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
Internet["🌐 Internet"] -->|"port 80/443"| Nginx["Nginx (reverse proxy)"]
|
||||
Nginx -->|"http://app:8080"| App["cannamanage-app\n(Spring Boot 3.x)"]
|
||||
App -->|"jdbc:postgresql://db:5432"| DB["PostgreSQL 16\n(cannamanage DB)"]
|
||||
LetsEncrypt["Let's Encrypt\n(certbot auto-renew)"] -.->|"TLS cert"| Nginx
|
||||
Gitea["Gitea Actions\n(homelab CI)"] -->|"SSH + docker compose"| VPS["Hetzner VPS\n/opt/cannamanage"]
|
||||
|
||||
subgraph VPS ["Hetzner VPS — Docker network: cannamanage_net"]
|
||||
Nginx
|
||||
App
|
||||
DB
|
||||
end
|
||||
```
|
||||
|
||||
All three services run on an internal Docker bridge network (`cannamanage_net`). Only Nginx is exposed to the public internet. PostgreSQL has no external port binding.
|
||||
|
||||
---
|
||||
|
||||
## 3. Docker Compose Setup
|
||||
|
||||
**File:** `/opt/cannamanage/docker-compose.yml`
|
||||
|
||||
```yaml
|
||||
version: '3.9'
|
||||
|
||||
networks:
|
||||
cannamanage_net:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
pgdata:
|
||||
driver: local
|
||||
nginx_certs:
|
||||
driver: local
|
||||
|
||||
services:
|
||||
nginx:
|
||||
image: nginx:1.25-alpine
|
||||
container_name: cannamanage-nginx
|
||||
ports:
|
||||
- "80:80"
|
||||
- "443:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- nginx_certs:/etc/letsencrypt:ro
|
||||
- /var/log/nginx:/var/log/nginx
|
||||
depends_on:
|
||||
app:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- cannamanage_net
|
||||
restart: unless-stopped
|
||||
|
||||
app:
|
||||
image: cannamanage:${VERSION:-latest}
|
||||
container_name: cannamanage-app
|
||||
environment:
|
||||
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/cannamanage
|
||||
- SPRING_DATASOURCE_USERNAME=${DB_USERNAME}
|
||||
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD}
|
||||
- APP_JWT_SECRET=${JWT_SECRET}
|
||||
- STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY}
|
||||
- STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET}
|
||||
- SPRING_MAIL_HOST=${MAIL_HOST}
|
||||
- SPRING_MAIL_USERNAME=${MAIL_USERNAME}
|
||||
- SPRING_MAIL_PASSWORD=${MAIL_PASSWORD}
|
||||
- SENTRY_DSN=${SENTRY_DSN}
|
||||
- SPRING_PROFILES_ACTIVE=production
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
networks:
|
||||
- cannamanage_net
|
||||
restart: unless-stopped
|
||||
|
||||
db:
|
||||
image: postgres:16-alpine
|
||||
container_name: cannamanage-db
|
||||
environment:
|
||||
- POSTGRES_DB=cannamanage
|
||||
- POSTGRES_USER=${DB_USERNAME}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${DB_USERNAME} -d cannamanage"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
networks:
|
||||
- cannamanage_net
|
||||
restart: unless-stopped
|
||||
# PostgreSQL port intentionally NOT exposed externally
|
||||
```
|
||||
|
||||
**Nginx site config** (`/opt/cannamanage/nginx/conf.d/cannamanage.conf`):
|
||||
|
||||
```nginx
|
||||
server {
|
||||
listen 80;
|
||||
server_name app.cannamanage.de;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name app.cannamanage.de;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/app.cannamanage.de/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/app.cannamanage.de/privkey.pem;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
# Security headers
|
||||
add_header Strict-Transport-Security "max-age=31536000" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
location / {
|
||||
proxy_pass http://app: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;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# Stripe webhook — allow larger body
|
||||
location /api/v1/billing/webhook {
|
||||
proxy_pass http://app:8080;
|
||||
proxy_set_header Host $host;
|
||||
client_max_body_size 1m;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Environment Variables
|
||||
|
||||
**File:** `/opt/cannamanage/.env` (never committed to git — add to `.gitignore`)
|
||||
|
||||
```bash
|
||||
# Database
|
||||
DB_USERNAME=cannamanage_user
|
||||
DB_PASSWORD=<strong-random-password-min-32-chars>
|
||||
|
||||
# JWT signing key (256-bit minimum — generate with: openssl rand -hex 32)
|
||||
JWT_SECRET=<256-bit-random-hex>
|
||||
|
||||
# Stripe (use sk_live_ for production, sk_test_ for staging)
|
||||
STRIPE_SECRET_KEY=sk_live_...
|
||||
STRIPE_WEBHOOK_SECRET=whsec_...
|
||||
|
||||
# Email (SMTP)
|
||||
MAIL_HOST=smtp.example.com
|
||||
MAIL_PORT=587
|
||||
MAIL_USERNAME=noreply@cannamanage.de
|
||||
MAIL_PASSWORD=<mail-password>
|
||||
MAIL_FROM=CannaManage <noreply@cannamanage.de>
|
||||
|
||||
# Error tracking
|
||||
SENTRY_DSN=https://<key>@<org>.ingest.sentry.io/<project-id>
|
||||
|
||||
# Application version (set by CI during deploy)
|
||||
VERSION=latest
|
||||
```
|
||||
|
||||
> **Security:** Never store `.env` in version control. Use Gitea repository secrets for CI and inject at deploy time. On the VPS, set file permissions: `chmod 600 /opt/cannamanage/.env`.
|
||||
|
||||
---
|
||||
|
||||
## 5. First-Time Deployment
|
||||
|
||||
### Step 1 — Create Hetzner VPS
|
||||
|
||||
1. Log into [console.hetzner.cloud](https://console.hetzner.cloud)
|
||||
2. Create server: **CX21**, Ubuntu 22.04, Nuremberg or Frankfurt datacenter
|
||||
3. Add your SSH public key during setup (`cat ~/.ssh/id_ed25519.pub`)
|
||||
4. Note the assigned IPv4 address — update DNS A records
|
||||
|
||||
### Step 2 — Install Docker + Docker Compose
|
||||
|
||||
```bash
|
||||
ssh root@<VPS-IP>
|
||||
|
||||
# Update system
|
||||
apt update && apt upgrade -y
|
||||
|
||||
# Install Docker
|
||||
curl -fsSL https://get.docker.com | sh
|
||||
|
||||
# Add deploy user (never run production as root)
|
||||
adduser deploy
|
||||
usermod -aG docker deploy
|
||||
usermod -aG sudo deploy
|
||||
|
||||
# Install Certbot
|
||||
apt install -y python3-certbot-nginx certbot
|
||||
```
|
||||
|
||||
### Step 3 — Clone Repository
|
||||
|
||||
```bash
|
||||
su - deploy
|
||||
mkdir -p /opt/cannamanage
|
||||
cd /opt/cannamanage
|
||||
git clone http://192.168.188.119:30008/pplate/cannamanage.git .
|
||||
# Or from public mirror when available
|
||||
```
|
||||
|
||||
### Step 4 — Create Production `.env`
|
||||
|
||||
```bash
|
||||
cd /opt/cannamanage
|
||||
cp .env.example .env
|
||||
nano .env # Fill in all production secrets
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
### Step 5 — Obtain SSL Certificate
|
||||
|
||||
```bash
|
||||
# Stop anything on port 80 first (nothing should be running yet)
|
||||
certbot certonly --standalone \
|
||||
-d app.cannamanage.de \
|
||||
--non-interactive \
|
||||
--agree-tos \
|
||||
-m ssl@cannamanage.de
|
||||
|
||||
# Symlink certs into nginx_certs volume location
|
||||
# Certbot places certs at /etc/letsencrypt/live/app.cannamanage.de/
|
||||
```
|
||||
|
||||
### Step 6 — Build Docker Image
|
||||
|
||||
```bash
|
||||
# On the VPS (or build locally and push to registry)
|
||||
./mvnw package -DskipTests -P production
|
||||
docker build -t cannamanage:latest .
|
||||
```
|
||||
|
||||
### Step 7 — Start Services
|
||||
|
||||
```bash
|
||||
cd /opt/cannamanage
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Step 8 — Verify Health
|
||||
|
||||
```bash
|
||||
# All containers should be 'healthy' or 'running'
|
||||
docker compose ps
|
||||
|
||||
# Check application logs
|
||||
docker compose logs -f app --tail=100
|
||||
|
||||
# Test health endpoint
|
||||
curl -f http://localhost:8080/actuator/health
|
||||
# Expected: {"status":"UP","components":{"db":{"status":"UP"},"diskSpace":{"status":"UP"}}}
|
||||
```
|
||||
|
||||
### Step 9 — Flyway Migrations
|
||||
|
||||
Flyway runs automatically on Spring Boot startup. Verify migration log:
|
||||
|
||||
```bash
|
||||
docker compose logs app | grep -i flyway
|
||||
# Expected: Successfully applied N migrations to schema "public"
|
||||
```
|
||||
|
||||
### Step 10 — Create First Admin User
|
||||
|
||||
```bash
|
||||
# Option A: via REST API (recommended)
|
||||
curl -X POST https://app.cannamanage.de/api/v1/admin/bootstrap \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"adminEmail": "admin@yourclub.de",
|
||||
"adminPassword": "<strong-password>",
|
||||
"clubName": "Your Club e.V.",
|
||||
"clubRegistrationNumber": "VR 12345"
|
||||
}'
|
||||
|
||||
# The bootstrap endpoint is disabled after first use (one-time setup flag in DB)
|
||||
```
|
||||
|
||||
### Step 11 — Verify Production Access
|
||||
|
||||
```bash
|
||||
# Web UI
|
||||
open https://app.cannamanage.de
|
||||
|
||||
# API health check
|
||||
curl https://app.cannamanage.de/actuator/health
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. CI/CD Pipeline (Gitea Actions)
|
||||
|
||||
**File:** `.gitea/workflows/deploy.yml`
|
||||
|
||||
```yaml
|
||||
name: Deploy to Production
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Run unit tests
|
||||
run: ./mvnw test -pl cannamanage-service
|
||||
|
||||
- name: Run integration tests
|
||||
run: ./mvnw verify -P integration-tests
|
||||
# Testcontainers requires Docker — GitHub/Gitea hosted runners have Docker pre-installed
|
||||
|
||||
- name: Coverage gate check
|
||||
run: ./mvnw verify -P coverage-check
|
||||
|
||||
build-and-deploy:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up JDK 21
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
cache: maven
|
||||
|
||||
- name: Build JAR
|
||||
run: ./mvnw package -DskipTests -P production
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
docker build \
|
||||
-t cannamanage:${{ github.sha }} \
|
||||
-t cannamanage:latest \
|
||||
.
|
||||
|
||||
- name: Save Docker image
|
||||
run: docker save cannamanage:${{ github.sha }} | gzip > /tmp/cannamanage.tar.gz
|
||||
|
||||
- name: Copy image to VPS
|
||||
run: |
|
||||
scp -o StrictHostKeyChecking=no \
|
||||
/tmp/cannamanage.tar.gz \
|
||||
deploy@${{ secrets.HETZNER_IP }}:/tmp/cannamanage.tar.gz
|
||||
|
||||
- name: Deploy via SSH
|
||||
run: |
|
||||
ssh -o StrictHostKeyChecking=no deploy@${{ secrets.HETZNER_IP }} "
|
||||
set -e
|
||||
cd /opt/cannamanage
|
||||
|
||||
# Load new image
|
||||
docker load < /tmp/cannamanage.tar.gz
|
||||
rm /tmp/cannamanage.tar.gz
|
||||
|
||||
# Rolling restart app only (DB stays up)
|
||||
VERSION=${{ github.sha }} docker compose up -d app
|
||||
|
||||
# Wait for health
|
||||
sleep 10
|
||||
docker compose ps app | grep 'healthy' || (docker compose logs app --tail=50 && exit 1)
|
||||
|
||||
# Prune old images (keep last 3)
|
||||
docker image prune -f
|
||||
"
|
||||
```
|
||||
|
||||
### Required Gitea Repository Secrets
|
||||
|
||||
| Secret | Value |
|
||||
|--------|-------|
|
||||
| `HETZNER_IP` | VPS IPv4 address |
|
||||
| `SSH_PRIVATE_KEY` | Private key for `deploy` user |
|
||||
|
||||
Add deploy user's public key to VPS authorized_keys:
|
||||
```bash
|
||||
# On VPS as deploy user
|
||||
mkdir -p ~/.ssh && chmod 700 ~/.ssh
|
||||
echo "<gitea-actions-public-key>" >> ~/.ssh/authorized_keys
|
||||
chmod 600 ~/.ssh/authorized_keys
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Database Backup
|
||||
|
||||
### Automated Daily Backup
|
||||
|
||||
Add to root crontab (`crontab -e`):
|
||||
|
||||
```bash
|
||||
# Daily backup at 03:00 UTC — keep 14 days
|
||||
0 3 * * * docker exec cannamanage-db pg_dump \
|
||||
-U cannamanage_user \
|
||||
--format=custom \
|
||||
cannamanage | gzip > /opt/backups/cannamanage_$(date +\%Y\%m\%d).sql.gz
|
||||
|
||||
# Cleanup backups older than 14 days
|
||||
5 3 * * * find /opt/backups -name "cannamanage_*.sql.gz" -mtime +14 -delete
|
||||
```
|
||||
|
||||
Create backup directory:
|
||||
```bash
|
||||
mkdir -p /opt/backups
|
||||
chown deploy:deploy /opt/backups
|
||||
```
|
||||
|
||||
### Restore from Backup
|
||||
|
||||
```bash
|
||||
# Restore (caution: this overwrites existing data)
|
||||
gunzip -c /opt/backups/cannamanage_20260406.sql.gz | \
|
||||
docker exec -i cannamanage-db pg_restore \
|
||||
-U cannamanage_user \
|
||||
--clean \
|
||||
--dbname=cannamanage
|
||||
|
||||
# Verify restore
|
||||
docker exec cannamanage-db psql \
|
||||
-U cannamanage_user \
|
||||
-d cannamanage \
|
||||
-c "SELECT COUNT(*) FROM clubs;"
|
||||
```
|
||||
|
||||
### Offsite Backup (Optional)
|
||||
|
||||
For additional redundancy, sync backups to Hetzner Object Storage:
|
||||
|
||||
```bash
|
||||
# Install s3cmd and configure with Hetzner S3-compatible endpoint
|
||||
s3cmd sync /opt/backups/ s3://cannamanage-backups/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Monitoring & Health Checks
|
||||
|
||||
### Spring Boot Actuator
|
||||
|
||||
The application exposes health endpoints via `spring-boot-actuator`:
|
||||
|
||||
```bash
|
||||
# Full health detail (requires ROLE_ADMIN JWT)
|
||||
GET /actuator/health
|
||||
|
||||
# Example response
|
||||
{
|
||||
"status": "UP",
|
||||
"components": {
|
||||
"db": { "status": "UP", "details": { "database": "PostgreSQL", "validationQuery": "isValid()" } },
|
||||
"diskSpace": { "status": "UP", "details": { "total": 42GB, "free": 30GB } },
|
||||
"ping": { "status": "UP" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Expose only `health` and `info` publicly in `application-production.yml`:
|
||||
```yaml
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
endpoint:
|
||||
health:
|
||||
show-details: when-authorized
|
||||
```
|
||||
|
||||
### Log Locations
|
||||
|
||||
| Source | Location |
|
||||
|--------|----------|
|
||||
| Application logs | `docker compose logs -f app` |
|
||||
| Nginx access logs | `/var/log/nginx/access.log` |
|
||||
| Nginx error logs | `/var/log/nginx/error.log` |
|
||||
| PostgreSQL logs | `docker compose logs db` |
|
||||
| Sentry (errors) | `https://sentry.io/organizations/<org>/` |
|
||||
|
||||
### Alerting
|
||||
|
||||
Configure Sentry to email on new errors:
|
||||
1. Set `SENTRY_DSN` in `.env`
|
||||
2. Add `io.sentry:sentry-spring-boot-starter-jakarta:7.x` to POM
|
||||
3. Sentry auto-captures all unhandled exceptions with full stack trace
|
||||
|
||||
Simple uptime check via `cron` + email:
|
||||
|
||||
```bash
|
||||
# Health check every 5 minutes — email on 3 consecutive failures
|
||||
*/5 * * * * /opt/cannamanage/scripts/health_check.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# /opt/cannamanage/scripts/health_check.sh
|
||||
HEALTH_URL="https://app.cannamanage.de/actuator/health"
|
||||
FAIL_COUNT_FILE="/tmp/cannamanage_health_fails"
|
||||
|
||||
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "$HEALTH_URL")
|
||||
if [ "$HTTP_STATUS" != "200" ]; then
|
||||
FAILS=$(cat "$FAIL_COUNT_FILE" 2>/dev/null || echo 0)
|
||||
FAILS=$((FAILS + 1))
|
||||
echo "$FAILS" > "$FAIL_COUNT_FILE"
|
||||
if [ "$FAILS" -ge 3 ]; then
|
||||
echo "CannaManage health check failed $FAILS times" | \
|
||||
mail -s "ALERT: CannaManage DOWN" admin@cannamanage.de
|
||||
fi
|
||||
else
|
||||
echo 0 > "$FAIL_COUNT_FILE"
|
||||
fi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. SSL Certificate Renewal
|
||||
|
||||
Let's Encrypt certificates expire after 90 days. Certbot handles renewal automatically:
|
||||
|
||||
```bash
|
||||
# Test renewal (dry run — no actual renewal)
|
||||
certbot renew --dry-run
|
||||
|
||||
# Manual renewal
|
||||
certbot renew --nginx
|
||||
|
||||
# Reload Nginx after renewal
|
||||
docker exec cannamanage-nginx nginx -s reload
|
||||
```
|
||||
|
||||
### Auto-Renewal via Cron
|
||||
|
||||
```bash
|
||||
# Renew at 02:00 UTC on the 1st and 15th of each month
|
||||
0 2 1,15 * * certbot renew --quiet --nginx && \
|
||||
docker exec cannamanage-nginx nginx -s reload
|
||||
```
|
||||
|
||||
Certbot only renews when the certificate is less than 30 days from expiry — safe to run frequently.
|
||||
|
||||
---
|
||||
|
||||
## 10. Rollback Procedure
|
||||
|
||||
If a deployment causes issues:
|
||||
|
||||
```bash
|
||||
# On VPS — list recent images
|
||||
docker images cannamanage --format "table {{.Tag}}\t{{.CreatedAt}}"
|
||||
|
||||
# Roll back to previous SHA
|
||||
cd /opt/cannamanage
|
||||
VERSION=<previous-sha> docker compose up -d app
|
||||
|
||||
# Verify health after rollback
|
||||
docker compose ps app
|
||||
curl https://app.cannamanage.de/actuator/health
|
||||
```
|
||||
|
||||
If database migrations were applied and rollback is needed:
|
||||
1. Restore from last backup (see Section 7)
|
||||
2. Redeploy the previous image version
|
||||
3. Flyway baseline the schema at previous version
|
||||
|
||||
> **Note:** Flyway migrations are append-only and forward-only. Design migrations to be reversible where possible (add columns before removing old ones, etc.).
|
||||
@@ -0,0 +1,97 @@
|
||||
# 10 — Sprint 0 Planning Retrospective
|
||||
|
||||
**Project:** CannaManage — B2B SaaS for German Cannabis Social Clubs
|
||||
**Sprint:** 0 — Planning & Documentation
|
||||
**Period:** 2026-04-04 to 2026-04-06
|
||||
**Mode:** Solo planning, AI-assisted documentation (Claude Sonnet 4.6 via Roo Orchestrator + Doc Writer modes)
|
||||
**Outcome:** ✅ Complete — 10-document suite written, architecture locked
|
||||
|
||||
---
|
||||
|
||||
## What Went Well ✅
|
||||
|
||||
**AI-assisted documentation at scale.** The complete documentation suite (10 documents, ~25,000 words total) was created in a single focused session using the Roo Orchestrator mode to coordinate multi-document generation. This would have taken 2–3 days manually. The quality is high enough to serve as actual implementation guidance — not placeholder text.
|
||||
|
||||
**Legal analysis confirmed viability early.** The CanG compliance review (Phase 1) identified the key constraints (no public directory, no consumer-facing advertising, B2B-only) before any code was written. These became hard architectural constraints rather than late surprises. No "oh wait, we can't do that" moments during technical design.
|
||||
|
||||
**Architecture decisions locked before code.** The shared-schema multi-tenancy decision, immutable distribution records design, and `ComplianceConstants` pattern were all decided and documented before a single line of production code was written. This is the correct order. Rework from late architectural pivots is far more expensive than planning time.
|
||||
|
||||
**Compliance constants centralized from day zero.** Designing `ComplianceConstants.java` as the single source of truth for all CanG quota values (25g/day, 50g/month, etc.) prevents the most dangerous class of compliance bug: magic numbers scattered across the codebase that diverge when the law changes.
|
||||
|
||||
**ComfyUI mockup images in minutes.** Generating 5 realistic UI mockup images with FLUX.1-schnell took approximately 8 minutes of wall-clock time. This provides a visual reference for the UI that would otherwise require a designer or Figma skills. The images are good enough for stakeholder presentations and early user research.
|
||||
|
||||
**Test plan written before code.** TC-001 through TC-026 were defined against specifications, not against existing implementation. This forces clarity on what the code must do before writing it — the test cases are essentially executable requirements.
|
||||
|
||||
---
|
||||
|
||||
## What Was Challenging ⚠️
|
||||
|
||||
**ComfyUI manual startup friction.** The ComfyUI image generation server does not auto-start with the system. This required manual service start and a retry cycle before image generation could proceed. The fix (systemd user service + auto-start lifespan check in `mcp-image-gen`) was implemented during this planning sprint but added unexpected overhead.
|
||||
|
||||
**Solo developer timeline is ambitious.** The 18–24 month estimate for a production-ready SaaS while employed full-time at ADP Germany is tight. Sprint 1 goals are achievable; the risk accumulates in Sprints 3–6 when frontend work, billing integration, and PDF generation converge. The PrimeFaces JSF choice for MVP was deliberate to reduce this risk — existing Java frontend skills transfer directly.
|
||||
|
||||
**Spring Boot 3 is not yet a "home" stack.** ADP work uses Jakarta EE (JBoss, CDI, JAX-RS). Spring Boot 3 shares the JPA/Hibernate mental model but diverges on dependency injection, auto-configuration, and application packaging. The learning curve is real but bounded — the `mss-failsafe` and `wellmann-shop` projects in `pi_mcps` demonstrate that the transition is manageable.
|
||||
|
||||
**Next.js/React remains a significant gap.** The v2 frontend pivot to Next.js 15 + React 19 is the highest-skill-gap risk in the project. PrimeFaces buys time, but the clock starts ticking on React learning from Sprint 1. Deferring is correct; ignoring it is not.
|
||||
|
||||
**No real user validation yet.** The entire architecture and pricing model is based on market research and regulatory reading, not on conversations with actual club administrators. The product may be solving the right problem in the wrong way. This is the most important open risk.
|
||||
|
||||
---
|
||||
|
||||
## Key Decisions Made 📋
|
||||
|
||||
| Decision | Rationale | Alternatives rejected |
|
||||
|----------|-----------|----------------------|
|
||||
| Shared-schema multi-tenancy (single DB, `tenant_id` columns) | Lowest ops overhead for MVP; one DB to backup/restore; simpler Flyway migrations | Schema-per-tenant (complex provisioning), DB-per-tenant (expensive at scale) |
|
||||
| Immutable distribution records (`@Column(updatable = false)`) | Legal integrity — audit logs must be tamper-proof; corrections via `RecallEvent`, not `UPDATE` | Mutable records (simpler but legally risky under CanG §26 record-keeping) |
|
||||
| PrimeFaces JSF for MVP frontend | Leverages existing Jakarta EE skills; fastest path to working product; no JS build tooling required | React/Next.js (faster modern dev, but higher skill gap), Thymeleaf (less interactive) |
|
||||
| No public club discovery — permanent architectural exclusion | CanG §§6–7 prohibit advertising cannabis to the general public; club lookup tool would likely constitute advertising | N/A — this is a legal constraint, not a design choice |
|
||||
| `ComplianceConstants.java` single source of truth | Prevents magic number scatter; single change point when law evolves | Constants in each service (fragile), DB-configurable limits (dangerous — allows disabling compliance) |
|
||||
| Hetzner VPS over AWS/GCP | Cost (€5.88/month vs €20+); EU data residency (GDPR); simpler ops for solo developer | AWS (expensive, complex), Fly.io (less EU clarity), Railway (vendor lock-in) |
|
||||
|
||||
---
|
||||
|
||||
## Risks Going Forward ⚠️
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|-----------|--------|------------|
|
||||
| New German government tightens CanG (e.g. lower quota limits) | Medium | High — requires rapid compliance updates | `ComplianceConstants.java` centralizes all limits; update is a 1-file change + test re-run |
|
||||
| Stripe flags account as cannabis-adjacent | Medium | Critical — billing becomes unusable | Use category "Vereinsverwaltung" (club management) in Stripe onboarding; prepare Mollie as fallback |
|
||||
| Solo dev burnout / timeline slip | High | Medium — delayed launch, not cancellation | Strict MVP scope; PrimeFaces reduces frontend effort; no scope creep before first paying customer |
|
||||
| Market timing risk — clubs adopt ad-hoc Excel/WhatsApp solutions | Medium | High — low willingness to pay for formal software | User research with 3+ clubs in Sprint 1 is mandatory before writing production code |
|
||||
| Legal risk: CanG compliance interpretation | Low | High — criminal liability for club officers | Specialist cannabis law opinion (€300–500) before launch; not optional |
|
||||
| Under-21 age calculation edge cases | Low | Medium — compliance bug | Birthday-based age calculation uses `Period.between()`, not year subtraction; tested in TC-013/014 |
|
||||
|
||||
---
|
||||
|
||||
## Next Steps — Sprint 1 Goals
|
||||
|
||||
- [ ] Initialize Spring Boot 3.x Maven multi-module project (`cannamanage-parent`, `cannamanage-domain`, `cannamanage-service`, `cannamanage-api`, `cannamanage-web`)
|
||||
- [ ] Implement `AbstractTenantEntity` base class with `@MappedSuperclass`
|
||||
- [ ] Write `V1__initial_schema.sql` Flyway migration covering all 8 entities
|
||||
- [ ] Implement `ComplianceService` with full quota logic and 100% test coverage (TC-001–010)
|
||||
- [ ] Implement `MemberService` with age validation (TC-011–015)
|
||||
- [ ] Set up JaCoCo with ComplianceService 100% coverage gate
|
||||
- [ ] Gitea repository created and CI pipeline (unit tests on `feature/*`) functional
|
||||
- [ ] **Talk to 3 real club administrators** — validate pain points, willingness to pay, and current workarounds
|
||||
- [ ] Get specialist legal opinion from a cannabis law attorney (€300–500 budget)
|
||||
|
||||
---
|
||||
|
||||
## Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Planning duration | 3 days (2026-04-04 to 2026-04-06) |
|
||||
| Documents created | 10 (01-PROJECT-CHARTER through 10-RETROSPECTIVE) |
|
||||
| Estimated total words | ~25,000 |
|
||||
| Test cases defined | 26 |
|
||||
| API endpoints specified | 30+ |
|
||||
| JPA entities designed | 8 |
|
||||
| UI screens wireframed | 6 |
|
||||
| UI mockup images generated | 5 |
|
||||
| Lines of production code written | **0** |
|
||||
| Architecture decisions logged | 6 major |
|
||||
| Open risks identified | 6 |
|
||||
|
||||
The ratio of planning output to production code written is intentional. Phase 0 exists to eliminate avoidable rework — the most expensive kind.
|
||||
@@ -0,0 +1,28 @@
|
||||
# 🌿 CannaManage
|
||||
|
||||
**B2B SaaS for German Cannabis Social Clubs (Anbauvereinigungen)**
|
||||
|
||||
> Status: Phase 0 — Planning Complete | Stack: Spring Boot 3.x + PrimeFaces → Next.js | Legal: ✅ CanG-Compliant
|
||||
|
||||
## Documentation Index
|
||||
|
||||
| Document | Description |
|
||||
|----------|-------------|
|
||||
| [Project Charter](CannaManage-01-Charter) | Vision, scope, risk register, timeline Gantt chart |
|
||||
| [User Stories](CannaManage-02-UserStories) | 25 stories with MoSCoW priorities + acceptance criteria |
|
||||
| [Architecture](CannaManage-03-Architecture) | System diagram, 8-entity ERD, multi-tenancy design |
|
||||
| [Flow Charts](CannaManage-04-Flowcharts) | 5 business logic flows (distribution, recall, compliance) |
|
||||
| [API Spec](CannaManage-05-API) | REST API: 7 controllers, 30+ endpoints |
|
||||
| [Wireframes & Mockups](CannaManage-06-Wireframes) | 6 screen wireframes with AI-generated UI mockups |
|
||||
| [Coding Standards](CannaManage-07-CodingStandards) | Java 21 standards, compliance code rules, Git strategy |
|
||||
| [Test Plan](CannaManage-08-TestPlan) | 26 test cases, JaCoCo 100% gate on ComplianceService |
|
||||
| [Deployment Guide](CannaManage-09-Deployment) | Hetzner VPS, Docker Compose, Gitea CI/CD |
|
||||
| [Retrospective](CannaManage-10-Retrospective) | Sprint 0 retro: decisions, challenges, Sprint 1 goals |
|
||||
|
||||
## Quick Facts
|
||||
|
||||
- **Market:** 500–3,000 German Anbauvereinigungen (cannabis social clubs)
|
||||
- **Revenue Target:** €39,500 MRR at 500 clubs (Year 3)
|
||||
- **Legal Basis:** Konsumcannabisgesetz (CanG) §§2, 15-26 — B2B operations software only
|
||||
- **Architecture:** Spring Boot 3.x + JPA/Hibernate, multi-tenant (shared schema + tenant_id)
|
||||
- **Source:** [pi_mcps plans/cannabis-club-saas](http://192.168.188.119:30008/pplate/pi_mcps/src/branch/main/plans/cannabis-club-saas)
|
||||
@@ -0,0 +1,184 @@
|
||||
# 🛠️ Development Conventions
|
||||
|
||||

|
||||
|
||||
All MCP servers in this repo follow a consistent set of conventions to ensure maintainability, testability, and compatibility with Roo Code tooling.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Each MCP server lives at `mcp/<server-name>/` with this layout:
|
||||
|
||||
```
|
||||
mcp/<server-name>/
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ └── server.py ← FastMCP server entry point
|
||||
├── tests/
|
||||
│ ├── conftest.py ← sys.path + shared fixtures
|
||||
│ └── test_server.py ← pytest test suite (100% mock coverage)
|
||||
├── pyproject.toml ← uv-managed dependencies
|
||||
├── README.md ← server documentation
|
||||
├── PLAN.md ← architecture plan (pre-implementation)
|
||||
└── ASSESSMENT.md ← pre-implementation assessment
|
||||
```
|
||||
|
||||
## FastMCP Pattern
|
||||
|
||||
```python
|
||||
from fastmcp import FastMCP
|
||||
|
||||
mcp = FastMCP("server-name")
|
||||
|
||||
@mcp.tool()
|
||||
def my_tool(param: str) -> str:
|
||||
"""Tool description shown to the AI."""
|
||||
return result
|
||||
|
||||
if __name__ == "__main__":
|
||||
mcp.run()
|
||||
```
|
||||
|
||||
## Package Management
|
||||
|
||||
**All projects use `uv`** — never `pip` directly:
|
||||
|
||||
```bash
|
||||
# Create new server
|
||||
uv init mcp/my-server
|
||||
cd mcp/my-server
|
||||
uv add fastmcp httpx
|
||||
|
||||
# Sync dependencies
|
||||
uv sync
|
||||
|
||||
# Run server
|
||||
uv run python src/server.py
|
||||
|
||||
# Run tests
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
## pyproject.toml Template
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "mcp-my-server"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastmcp>=2.0.0",
|
||||
"httpx",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = ["pytest", "pytest-mock", "pytest-cov"]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
```
|
||||
|
||||
## Testing Conventions
|
||||
|
||||
- Tests live in `tests/test_server.py`
|
||||
- `conftest.py` sets `sys.path` so imports work without install
|
||||
- Use `pytest` via `uv run pytest`
|
||||
- Mock **all** external calls (HTTP, filesystem, subprocess) with `pytest-mock` or `respx`
|
||||
- `monkeypatch` for env vars and module-level state
|
||||
- Aim for 100% tool function coverage
|
||||
- All tests must pass before committing
|
||||
|
||||
## Branching Strategy
|
||||
|
||||
**Never commit to main directly.**
|
||||
|
||||
```
|
||||
Branch format: type/scope/short-description
|
||||
|
||||
Types: feat / fix / docs / chore / spike
|
||||
Scopes: bigmind / webscraper / cannamanage / workshop / roo / plans / homelab
|
||||
|
||||
Examples:
|
||||
feat/mcp/new-gitea-server
|
||||
fix/bigmind/achievement-card-images
|
||||
docs/wiki/update-conventions
|
||||
chore/roo/update-mcp-json
|
||||
```
|
||||
|
||||
Merge to main with `--no-ff` after push to Gitea.
|
||||
|
||||
## Commit Convention
|
||||
|
||||
Follow **Conventional Commits** format:
|
||||
|
||||
```
|
||||
feat(mcp-webscraper): add webscraper_search_hint tool using Brave Search
|
||||
fix(bigmind): achievement card images missing background-image CSS
|
||||
docs(wiki): add Java projects pages
|
||||
test(mcp-image-gen): add edge case tests for generate_image
|
||||
refactor(bigmind): extract profile builder to separate module
|
||||
chore(roo): update mcp.json with new server entry
|
||||
```
|
||||
|
||||
## Wiki Update Workflow
|
||||
|
||||
Wiki pages live as real Markdown files in `docs/wiki/pages/`. To update and deploy:
|
||||
|
||||
```bash
|
||||
# 1. Edit the .md files in docs/wiki/pages/
|
||||
# 2. Deploy to Gitea wiki git repo:
|
||||
./docs/wiki/deploy_wiki.sh
|
||||
```
|
||||
|
||||
The deploy script clones the wiki git repo (`pi_mcps.wiki.git`), syncs all `.md` files, and pushes.
|
||||
|
||||
## Creating a New MCP Server
|
||||
|
||||
Use the `new-mcp-server` Roo skill in MCP Builder mode for full scaffolding:
|
||||
|
||||
```
|
||||
1. Switch to 🔧 MCP Builder mode in Roo Code
|
||||
2. Say: "Create a new MCP server for <purpose>"
|
||||
3. Roo will load the new-mcp-server skill and scaffold everything
|
||||
```
|
||||
|
||||
## Web Research with mcp-webscraper
|
||||
|
||||
Before asking Patrick for information about a library, framework, API, or technology — **search first**.
|
||||
|
||||
The webscraper MCP server provides `webscraper_search_hint` (Brave Search, no API key, always available) as the entry point for all research tasks. Use the two-step pattern:
|
||||
|
||||
```
|
||||
Step 1: webscraper_search_hint("topic or error message") → get candidate URLs
|
||||
Step 2: webscraper_fetch(best_url) → read the full page
|
||||
```
|
||||
|
||||
### When to search
|
||||
|
||||
| Situation | Action |
|
||||
|---|---|
|
||||
| Need docs for a library or framework | `webscraper_search_hint("library-name official docs")` |
|
||||
| Investigating an error or stack trace | `webscraper_search_hint("exact error message language")` |
|
||||
| Planning a feature — need design patterns | `webscraper_search_hint("pattern-name best practices")` |
|
||||
| Checking latest version / changelog | `webscraper_search_hint("library-name changelog release")` |
|
||||
| Looking up API contracts | `webscraper_fetch(official_docs_url)` directly |
|
||||
|
||||
### Especially useful in
|
||||
|
||||
- **🏗️ Architect mode** — look up patterns and docs *before* designing. Don't design blind.
|
||||
- **🪲 Debug mode** — search the exact error message before forming hypotheses.
|
||||
- **🔧 MCP Builder mode** — check FastMCP changelog for new patterns before implementing.
|
||||
|
||||
### Known caveats
|
||||
|
||||
- Reddit and Stack Overflow may return empty snippets (platform blocks)
|
||||
- Brave uses Svelte CSS classes that can change — if `webscraper_search_hint` returns 0 results, selectors may need updating (last verified: 2026-04-05)
|
||||
|
||||
## Gitea Repository
|
||||
|
||||
Code is hosted at: `http://192.168.188.119:30008/pplate/pi_mcps`
|
||||
|
||||
Push with the `gitea-push` Roo skill to ensure conventional commit format and correct branch workflow.
|
||||
@@ -0,0 +1,56 @@
|
||||
# 🔧 pi_mcps — Patrick's Homelab Monorepo
|
||||
|
||||

|
||||
|
||||
Welcome to **pi_mcps**, Patrick's personal homelab monorepo. This repository houses MCP (Model Context Protocol) servers, Java projects, and homelab tooling — all built and maintained on a Fedora Linux workstation with an AMD Ryzen 5900X + RX 7900 XTX.
|
||||
|
||||
## What's in this repo?
|
||||
|
||||
| Directory | Contents |
|
||||
|---|---|
|
||||
| [`mcp/mcp-image-gen/`](../src/branch/main/mcp/mcp-image-gen) | 🎨 AI image generation via ComfyUI + FLUX.1-schnell |
|
||||
| [`mcp/webscraper/`](../src/branch/main/mcp/webscraper) | 🕸️ Web scraping and data extraction |
|
||||
| [`mcp/bigmind/`](../src/branch/main/mcp/bigmind) | 🧠 Persistent AI memory system |
|
||||
| [`java/`](../src/branch/main/java) | ☕ Java EE / Spring projects |
|
||||
| [`plans/`](../src/branch/main/plans) | 📋 Architecture decisions and health reports |
|
||||
|
||||
## Stack
|
||||
|
||||
- **Language:** Python 3.11+ (MCP servers), Java 8–17 (legacy projects)
|
||||
- **MCP Framework:** FastMCP 2.x
|
||||
- **Package Manager:** `uv` (all Python projects)
|
||||
- **Testing:** `pytest`
|
||||
- **GPU:** AMD RX 7900 XTX (ROCm / HSA)
|
||||
- **Server:** TrueNAS.local at `192.168.188.119` (Gitea, Docker)
|
||||
|
||||
## MCP Servers
|
||||
|
||||
Three production-ready MCP servers power Patrick's AI development environment:
|
||||
|
||||
| Server | Status | Description |
|
||||
|---|---|---|
|
||||
| [mcp-image-gen](mcp-image-gen) | ✅ Live | Generate images from text prompts via ComfyUI |
|
||||
| [mcp-webscraper](mcp-webscraper) | ✅ Live | Scrape web pages, search hints, extract tables |
|
||||
| [BigMind](BigMind) | ✅ Live | Persistent AI memory across all sessions |
|
||||
|
||||
## Java Projects
|
||||
|
||||
Legacy Java EE web applications used for learning and reference:
|
||||
|
||||
| Project | Stack | Description |
|
||||
|---|---|---|
|
||||
| [wellmann-shop](Java-wellmann-shop) | Java 8, PrimeFaces 6.2, EclipseLink, MySQL | JSF e-commerce storefront |
|
||||
| [mss-failsafe](Java-mss-failsafe) | Java 11, PrimeFaces 10, Soteria | Multi-module enterprise web app |
|
||||
|
||||
## Wiki Sections
|
||||
|
||||
- 🔌 [MCP Servers Overview](MCP-Servers-Overview)
|
||||
- 🎨 [mcp-image-gen](mcp-image-gen) — Image generation
|
||||
- 🕸️ [mcp-webscraper](mcp-webscraper) — Web scraping
|
||||
- 🧠 [BigMind](BigMind) — AI memory system
|
||||
- ☕ [Java Projects Overview](Java-Projects)
|
||||
- 🛠️ [Development Conventions](Development-Conventions)
|
||||
|
||||
---
|
||||
|
||||
*Built and maintained by Patrick Plate (pplate) · Homelab: TrueNAS.local · AI Colleague: Lumen*
|
||||
@@ -0,0 +1,164 @@
|
||||
# 📐 Java Architecture Patterns
|
||||
|
||||

|
||||
|
||||
This page documents the shared architectural patterns used across all Java projects in this monorepo. These patterns also align with Patrick's professional work on the ADP Germany Paisy payroll system.
|
||||
|
||||
## JSF MVC Pattern
|
||||
|
||||
All projects use JavaServer Faces (JSF) with the MVC pattern:
|
||||
|
||||
```
|
||||
Browser (HTTP) → FacesServlet → XHTML View (Facelets)
|
||||
│
|
||||
▼
|
||||
CDI Backing Bean (@Named)
|
||||
│
|
||||
▼
|
||||
Service Layer (EJB / CDI)
|
||||
│
|
||||
▼
|
||||
JPA Repository / EntityManager
|
||||
│
|
||||
▼
|
||||
Database (MySQL / H2)
|
||||
```
|
||||
|
||||
## JPA Entity Mapping
|
||||
|
||||
Standard JPA annotation patterns used across projects:
|
||||
|
||||
```java
|
||||
@Entity
|
||||
@Table(name = "users")
|
||||
public class User implements Serializable {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "username", nullable = false, unique = true)
|
||||
private String username;
|
||||
|
||||
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, fetch = FetchType.LAZY)
|
||||
private List<Order> orders = new ArrayList<>();
|
||||
|
||||
// getters/setters
|
||||
}
|
||||
```
|
||||
|
||||
## Backing Bean Pattern
|
||||
|
||||
CDI backing beans power the JSF views:
|
||||
|
||||
```java
|
||||
@Named
|
||||
@ViewScoped // or @SessionScoped / @RequestScoped
|
||||
public class UserBean implements Serializable {
|
||||
|
||||
@Inject
|
||||
private UserService userService;
|
||||
|
||||
private User currentUser;
|
||||
|
||||
public String login() {
|
||||
currentUser = userService.authenticate(username, password);
|
||||
return currentUser != null ? "/user/welcome?faces-redirect=true" : null;
|
||||
}
|
||||
|
||||
// getters/setters
|
||||
}
|
||||
```
|
||||
|
||||
## Security Layers
|
||||
|
||||
### Legacy: JAAS (wellmann-shop)
|
||||
|
||||
```xml
|
||||
<!-- web.xml -->
|
||||
<security-constraint>
|
||||
<web-resource-collection>
|
||||
<web-resource-name>Admin Pages</web-resource-name>
|
||||
<url-pattern>/admin/*</url-pattern>
|
||||
</web-resource-collection>
|
||||
<auth-constraint>
|
||||
<role-name>admin</role-name>
|
||||
</auth-constraint>
|
||||
</security-constraint>
|
||||
```
|
||||
|
||||
### Modern: Soteria / Jakarta Security (mss-failsafe)
|
||||
|
||||
```java
|
||||
@ApplicationScoped
|
||||
public class ApplicationSecurityConfig implements HttpAuthenticationMechanism {
|
||||
// Soteria CDI-based authentication
|
||||
}
|
||||
```
|
||||
|
||||
## Maven Multi-Module Pattern (mss-failsafe)
|
||||
|
||||
```xml
|
||||
<!-- Parent pom.xml -->
|
||||
<modules>
|
||||
<module>mssfailsafe.datalayer</module>
|
||||
<module>userdata</module>
|
||||
<module>userManagement</module>
|
||||
</modules>
|
||||
|
||||
<!-- Dependency ordering: datalayer → userdata → userManagement -->
|
||||
```
|
||||
|
||||
## XHTML Facelets Templating
|
||||
|
||||
```xml
|
||||
<!-- Template: resources/layout/template.xhtml -->
|
||||
<h:body>
|
||||
<ui:insert name="content">Default Content</ui:insert>
|
||||
</h:body>
|
||||
|
||||
<!-- Page using template -->
|
||||
<ui:composition template="/resources/layout/template.xhtml">
|
||||
<ui:define name="content">
|
||||
<p:dataTable var="item" value="#{bean.items}">
|
||||
<p:column headerText="Name">#{item.name}</p:column>
|
||||
</p:dataTable>
|
||||
</ui:define>
|
||||
</ui:composition>
|
||||
```
|
||||
|
||||
## Deployment Descriptor Pattern
|
||||
|
||||
All projects target JBoss/WildFly with consistent descriptor files:
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `WEB-INF/web.xml` | Servlet config, security constraints, welcome files |
|
||||
| `WEB-INF/jboss-web.xml` | Context root, security domain mapping |
|
||||
| `WEB-INF/jboss-app.xml` | JBoss application descriptor |
|
||||
| `META-INF/persistence.xml` | JPA datasource JNDI reference |
|
||||
|
||||
## persistence.xml Pattern
|
||||
|
||||
```xml
|
||||
<persistence-unit name="mss-failsafe-PU" transaction-type="JTA">
|
||||
<jta-data-source>java:jboss/datasources/MySQLDS</jta-data-source>
|
||||
<properties>
|
||||
<property name="eclipselink.ddl-generation" value="create-tables"/>
|
||||
<property name="eclipselink.logging.level" value="FINE"/>
|
||||
</properties>
|
||||
</persistence-unit>
|
||||
```
|
||||
|
||||
## Patrick's Java Specializations
|
||||
|
||||
Based on professional and homelab experience:
|
||||
|
||||
| Domain | Depth | Notes |
|
||||
|---|---|---|
|
||||
| JPA / EclipseLink | ⭐⭐⭐⭐⭐ | Authored custom annotation parsers |
|
||||
| JSF / PrimeFaces | ⭐⭐⭐⭐⭐ | Built wellmann-shop solo |
|
||||
| JAXB | ⭐⭐⭐⭐ | XML binding for payroll formats |
|
||||
| Maven | ⭐⭐⭐⭐ | Multi-module, plugins |
|
||||
| Jakarta EE | ⭐⭐⭐⭐ | CDI, Security, JTA |
|
||||
| Spring Boot | ⭐⭐⭐ | CannaManage SaaS target stack |
|
||||
@@ -0,0 +1,43 @@
|
||||
# ☕ Java Projects Overview
|
||||
|
||||

|
||||
|
||||
The `java/` directory contains Patrick's legacy Java EE web applications. These are fully functional projects used for reference, learning, and portfolio purposes. They predate the MCP server work and showcase deep expertise in the Java EE ecosystem.
|
||||
|
||||
## Projects
|
||||
|
||||
| Project | Java | Framework | DB | Description |
|
||||
|---|---|---|---|---|
|
||||
| [wellmann-shop](Java-wellmann-shop) | 8 | PrimeFaces 6.2 + JSF 2.x | MySQL + EclipseLink | E-commerce storefront |
|
||||
| [mss-failsafe](Java-mss-failsafe) | 11 | PrimeFaces 10 + Soteria | JPA multi-module | Enterprise web application |
|
||||
|
||||
## Common Stack
|
||||
|
||||
All Java projects use:
|
||||
|
||||
- **Maven** — build and dependency management
|
||||
- **Jakarta EE / Java EE** — enterprise APIs (JPA, CDI, JSF, Security)
|
||||
- **PrimeFaces** — JSF component library (rich UI widgets)
|
||||
- **JBoss/WildFly** — application server target (jboss-web.xml, jboss-app.xml)
|
||||
- **EclipseLink or Hibernate** — JPA persistence provider
|
||||
- **XHTML** — Facelets templating for JSF views
|
||||
|
||||
## Patrick's Java Expertise
|
||||
|
||||
Patrick has expert-level Java experience:
|
||||
|
||||
- **JPA/EclipseLink** — deep knowledge, authored custom annotation-style flatfile parsers
|
||||
- **JAXB** — XML binding for payroll data formats
|
||||
- **PrimeFaces JSF** — built wellmann-shop from scratch without AI assistance
|
||||
- **Maven** — multi-module project management
|
||||
- **Jakarta EE** — CDI, Security (Soteria), JTA
|
||||
|
||||
> 📝 Patrick works professionally with Java at ADP Germany (Paisy payroll monorepo with euBP/EAU processing). The homelab Java projects demonstrate similar patterns in a learning/portfolio context.
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
See [Java Architecture](Java-Architecture) for shared patterns across both projects:
|
||||
- JSF + MVC with backing beans
|
||||
- JPA entity mapping
|
||||
- Security with JAAS/Soteria
|
||||
- XHTML Facelets templating
|
||||
@@ -0,0 +1,94 @@
|
||||
# 🏢 mss-failsafe — Multi-Module Enterprise Application
|
||||
|
||||

|
||||
|
||||
**mss-failsafe** is a multi-module Java EE enterprise web application demonstrating advanced patterns: modular Maven builds, Jakarta Security (Soteria), and multi-layer JPA architecture.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|---|---|
|
||||
| **Language** | Java 11 |
|
||||
| **Web Framework** | JSF 2.3 (Facelets/XHTML) |
|
||||
| **UI Components** | PrimeFaces 10 |
|
||||
| **Persistence** | JPA (multi-module) |
|
||||
| **Security** | Jakarta Security / Soteria |
|
||||
| **Build** | Maven multi-module |
|
||||
| **App Server** | WildFly/JBoss |
|
||||
|
||||
## Module Structure
|
||||
|
||||
```
|
||||
java/mss-failsafe/
|
||||
├── pom.xml ← Parent POM (multi-module)
|
||||
├── mssfailsafe.datalayer/ ← JPA entities + persistence
|
||||
│ ├── pom.xml
|
||||
│ └── src/main/resources/META-INF/persistence.xml
|
||||
├── userdata/ ← User data model module
|
||||
│ └── pom.xml
|
||||
└── userManagement/ ← Web UI module (JSF/PrimeFaces)
|
||||
├── pom.xml
|
||||
├── nb-configuration.xml ← NetBeans config
|
||||
└── src/main/webapp/
|
||||
├── index.xhtml ← Landing page
|
||||
├── error.xhtml ← Error handling page
|
||||
├── admin/
|
||||
│ └── welcome.xhtml ← Admin dashboard
|
||||
├── user/
|
||||
│ └── welcome.xhtml ← User welcome page
|
||||
└── WEB-INF/
|
||||
├── web.xml
|
||||
├── jboss-web.xml
|
||||
└── jboss-app.xml
|
||||
```
|
||||
|
||||
## Architecture Layers
|
||||
|
||||
```
|
||||
userManagement (Web/UI layer)
|
||||
│
|
||||
▼
|
||||
userdata (Domain model layer)
|
||||
│
|
||||
▼
|
||||
mssfailsafe.datalayer (JPA persistence layer)
|
||||
│
|
||||
▼
|
||||
Database (via persistence.xml datasource)
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Multi-Module Maven** — Clean separation of concerns across 4 modules
|
||||
- **Jakarta Security (Soteria)** — Modern declarative security replacing legacy JAAS
|
||||
- **Role-Based Access** — Admin vs User role segregation (`admin/` and `user/` view paths)
|
||||
- **PrimeFaces 10** — Modern PrimeFaces with updated component API
|
||||
- **Error Handling** — Dedicated `error.xhtml` with JSF error page mapping
|
||||
|
||||
## Security Model
|
||||
|
||||
Soteria-based security with two roles:
|
||||
|
||||
| Role | Path | Access |
|
||||
|---|---|---|
|
||||
| `admin` | `/admin/*` | Full admin dashboard |
|
||||
| `user` | `/user/*` | Standard user views |
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd java/mss-failsafe
|
||||
mvn clean install # builds all modules in dependency order
|
||||
# Deploy userManagement.war to WildFly
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Represents a more mature architecture than wellmann-shop (Java 11, PrimeFaces 10)
|
||||
- Demonstrates multi-module Maven project management
|
||||
- Soteria replaces legacy JAAS — more modern Jakarta EE security approach
|
||||
- Pattern mirrors what Patrick uses professionally in the Paisy/ADP codebase
|
||||
|
||||
## Source
|
||||
|
||||
[`java/mss-failsafe/`](../src/branch/main/java/mss-failsafe)
|
||||
@@ -0,0 +1,71 @@
|
||||
# 🛍️ wellmann-shop — JSF E-Commerce Application
|
||||
|
||||

|
||||
|
||||
**wellmann-shop** is a Java EE JSF e-commerce storefront built entirely from scratch without AI assistance. It demonstrates Patrick's deep expertise in PrimeFaces, JPA/EclipseLink, and the full Java EE web stack.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Component | Technology |
|
||||
|---|---|
|
||||
| **Language** | Java 8 |
|
||||
| **Web Framework** | JSF 2.x (Facelets/XHTML) |
|
||||
| **UI Components** | PrimeFaces 6.2 |
|
||||
| **Persistence** | JPA with EclipseLink |
|
||||
| **Database** | MySQL |
|
||||
| **Build** | Maven |
|
||||
| **App Server** | WildFly/JBoss |
|
||||
| **Security** | JAAS container-managed |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
java/wellmann-shop/
|
||||
├── src/main/
|
||||
│ ├── java/
|
||||
│ │ └── httpauthenticationmechanism/
|
||||
│ │ ├── ApplicationConfig.java ← JAX-RS app config
|
||||
│ │ └── LoginBean.java ← CDI backing bean for auth
|
||||
│ ├── resources/
|
||||
│ │ ├── log4j.properties
|
||||
│ │ └── META-INF/persistence.xml ← JPA datasource config
|
||||
│ └── webapp/
|
||||
│ ├── index.html / index.xhtml ← Landing page
|
||||
│ ├── login.xhtml ← Authentication form
|
||||
│ ├── welcome.xhtml ← Post-login welcome
|
||||
│ ├── welcomePrimefaces.xhtml ← PrimeFaces demo page
|
||||
│ ├── resources/
|
||||
│ │ ├── css/ ← Custom stylesheets
|
||||
│ │ └── images/ ← Product images
|
||||
│ └── WEB-INF/
|
||||
│ ├── web.xml ← Servlet config
|
||||
│ ├── jboss-web.xml ← Context root
|
||||
│ └── jboss-app.xml ← JBoss app descriptor
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Authentication** — JAAS-based login with `LoginBean` CDI backing bean
|
||||
- **PrimeFaces UI** — Rich JSF components (DataTable, InputText, CommandButton, etc.)
|
||||
- **JPA Persistence** — EclipseLink ORM with MySQL via `persistence.xml`
|
||||
- **Responsive Layout** — Custom CSS with multiple breakpoint stylesheets
|
||||
- **Image Gallery** — Professional product photography
|
||||
|
||||
## Building
|
||||
|
||||
```bash
|
||||
cd java/wellmann-shop
|
||||
mvn clean package
|
||||
# Deploy .war to WildFly/JBoss
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Built as a learning/portfolio project demonstrating JSF mastery
|
||||
- Patrick built this **entirely without AI assistance** — proof of deep Java EE expertise
|
||||
- PrimeFaces 6.2 was current at time of development (Java 8 era)
|
||||
- Modern equivalent would use PrimeFaces 13+ / Jakarta EE 10 / Java 21
|
||||
|
||||
## Source
|
||||
|
||||
[`java/wellmann-shop/`](../src/branch/main/java/wellmann-shop)
|
||||
@@ -0,0 +1,42 @@
|
||||
# 🔌 MCP Servers Overview
|
||||
|
||||

|
||||
|
||||
This repo contains three production-grade MCP (Model Context Protocol) servers, each specialized for a different capability domain. Together they give Roo Code / Claude Desktop a complete set of superpowers.
|
||||
|
||||
## The Three Pillars
|
||||
|
||||
```
|
||||
Roo Code / Claude Desktop
|
||||
│
|
||||
├── bigmind ──────────► ~/.mcp/bigmind/memory.db (persistent memory)
|
||||
├── mcp-image-gen ────► ComfyUI @ localhost:8188 (image generation)
|
||||
└── webscraper ───────► Internet / Intranet (web scraping + search)
|
||||
```
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Feature | mcp-image-gen | webscraper | bigmind |
|
||||
|---|---|---|---|
|
||||
| **Purpose** | Generate images from text | Scrape & parse web, search | Persistent AI memory |
|
||||
| **Tools** | 4 | 8 | 20+ |
|
||||
| **Backend** | ComfyUI / FLUX.1-schnell | httpx + BeautifulSoup4 + Brave | SQLite + FTS5 |
|
||||
| **GPU required** | ✅ AMD RX 7900 XTX | ❌ | ❌ |
|
||||
| **Tests** | 19/19 ✅ | 23/23 ✅ | 297/297 ✅ |
|
||||
| **Schema version** | n/a | n/a | v8 |
|
||||
|
||||
## Quick Links
|
||||
|
||||
- 🎨 [mcp-image-gen](mcp-image-gen) — Image generation docs
|
||||
- 🕸️ [mcp-webscraper](mcp-webscraper) — Web scraping docs
|
||||
- 🧠 [BigMind](BigMind) — Memory system docs
|
||||
- 🛠️ [Development Conventions](Development-Conventions) — How all servers are built
|
||||
|
||||
## Adding a New Server
|
||||
|
||||
All servers follow the [FastMCP convention](Development-Conventions). Use the `new-mcp-server` Roo skill to scaffold:
|
||||
|
||||
```bash
|
||||
# In Roo Code MCP Builder mode, load skill:
|
||||
# skill: new-mcp-server
|
||||
```
|
||||
@@ -0,0 +1,34 @@
|
||||
## 🔧 pi_mcps Wiki
|
||||
|
||||
### Overview
|
||||
- [🏠 Home](Home)
|
||||
- [🔌 MCP Servers](MCP-Servers-Overview)
|
||||
- [🛠️ Dev Conventions](Development-Conventions)
|
||||
|
||||
### MCP Servers
|
||||
- [🎨 mcp-image-gen](mcp-image-gen)
|
||||
- [⚙️ ComfyUI Setup](mcp-image-gen-ComfyUI-Setup)
|
||||
- [🕸️ mcp-webscraper](mcp-webscraper)
|
||||
- [🧠 BigMind](BigMind)
|
||||
|
||||
### Java Projects
|
||||
- [☕ Java Overview](Java-Projects)
|
||||
- [🛍️ wellmann-shop](Java-wellmann-shop)
|
||||
- [🏢 mss-failsafe](Java-mss-failsafe)
|
||||
- [📐 Java Architecture](Java-Architecture)
|
||||
|
||||
### 🌿 CannaManage
|
||||
- [🏠 Overview](CannaManage-Home)
|
||||
- [📋 Project Charter](CannaManage-01-Charter)
|
||||
- [📖 User Stories](CannaManage-02-UserStories)
|
||||
- [🏗️ Architecture](CannaManage-03-Architecture)
|
||||
- [🔄 Flow Charts](CannaManage-04-Flowcharts)
|
||||
- [🔌 API Spec](CannaManage-05-API)
|
||||
- [🎨 Wireframes](CannaManage-06-Wireframes)
|
||||
- [📏 Coding Standards](CannaManage-07-CodingStandards)
|
||||
- [🧪 Test Plan](CannaManage-08-TestPlan)
|
||||
- [🚀 Deployment](CannaManage-09-Deployment)
|
||||
- [🔍 Retrospective](CannaManage-10-Retrospective)
|
||||
|
||||
---
|
||||
*[Gitea Repo](http://192.168.188.119:30008/pplate/pi_mcps)*
|
||||
@@ -0,0 +1,183 @@
|
||||
# ⚙️ ComfyUI Setup Guide (AMD ROCm)
|
||||
|
||||
This guide covers installing ComfyUI with FLUX.1-schnell on a Fedora Linux system with an AMD GPU.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- AMD GPU with ROCm support (tested: RX 7900 XTX)
|
||||
- Fedora Linux (tested: Fedora 43 / kernel 6.19)
|
||||
- Python 3.11+
|
||||
- ~15GB free disk space (model weights)
|
||||
- HuggingFace account with FLUX license accepted
|
||||
|
||||
## Step 1: Install ComfyUI
|
||||
|
||||
ComfyUI is **not on PyPI** — must be cloned from source:
|
||||
|
||||
```bash
|
||||
cd ~
|
||||
git clone https://github.com/comfyanonymous/ComfyUI
|
||||
cd ComfyUI
|
||||
python -m venv .venv
|
||||
source .venv/bin/activate
|
||||
|
||||
# Install PyTorch ROCm build (CRITICAL for AMD GPUs)
|
||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.2
|
||||
|
||||
# Install ComfyUI dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Step 2: Download FLUX.1-schnell
|
||||
|
||||
FLUX.1-schnell is **gated on HuggingFace** — you must:
|
||||
1. Create a HuggingFace account
|
||||
2. Accept the FLUX.1-schnell license at https://huggingface.co/black-forest-labs/FLUX.1-schnell
|
||||
3. Generate an access token at https://huggingface.co/settings/tokens
|
||||
|
||||
```bash
|
||||
# Install huggingface_hub
|
||||
pip install huggingface_hub
|
||||
|
||||
# Download model (requires HF token)
|
||||
huggingface-cli download black-forest-labs/FLUX.1-schnell \
|
||||
flux1-schnell.safetensors \
|
||||
--local-dir ~/ComfyUI/models/checkpoints \
|
||||
--token YOUR_HF_TOKEN_HERE
|
||||
```
|
||||
|
||||
## Step 3: Download VAE and CLIP Models
|
||||
|
||||
FLUX.1-schnell also requires VAE and CLIP text encoders:
|
||||
|
||||
```bash
|
||||
# VAE
|
||||
huggingface-cli download black-forest-labs/FLUX.1-schnell \
|
||||
ae.safetensors \
|
||||
--local-dir ~/ComfyUI/models/vae
|
||||
|
||||
# CLIP models (T5 and CLIP-L)
|
||||
huggingface-cli download comfyanonymous/flux_text_encoders \
|
||||
t5xxl_fp8_e4m3fn.safetensors clip_l.safetensors \
|
||||
--local-dir ~/ComfyUI/models/clip
|
||||
```
|
||||
|
||||
## Step 4: Install the systemd User Service (Recommended)
|
||||
|
||||
Installing ComfyUI as a systemd user service ensures it starts automatically on login and restarts on failure.
|
||||
|
||||
```bash
|
||||
# Copy the bundled service file to the systemd user directory
|
||||
mkdir -p ~/.config/systemd/user
|
||||
cp ~/pi_mcps/mcp/mcp-image-gen/comfyui.service ~/.config/systemd/user/comfyui.service
|
||||
|
||||
# Reload systemd, enable + start the service
|
||||
systemctl --user daemon-reload
|
||||
systemctl --user enable --now comfyui
|
||||
|
||||
# Verify it is running
|
||||
systemctl --user status comfyui
|
||||
```
|
||||
|
||||
> ⚠️ `HSA_OVERRIDE_GFX_VERSION=11.0.0` is already set in the service file — it is mandatory for RX 7900 XTX on ROCm. Without it, model loading fails silently.
|
||||
|
||||
### Enable lingering (start ComfyUI even without a login session)
|
||||
|
||||
```bash
|
||||
loginctl enable-linger $USER
|
||||
```
|
||||
|
||||
This ensures the service starts at boot even before you log in — recommended for headless / homelab setups.
|
||||
|
||||
### Managing the service
|
||||
|
||||
```bash
|
||||
# Follow live logs
|
||||
journalctl --user -u comfyui -f
|
||||
|
||||
# Restart after model changes
|
||||
systemctl --user restart comfyui
|
||||
|
||||
# Stop temporarily
|
||||
systemctl --user stop comfyui
|
||||
|
||||
# Disable autostart
|
||||
systemctl --user disable comfyui
|
||||
```
|
||||
|
||||
## Step 5: Manual Start (without systemd)
|
||||
|
||||
If you prefer to start ComfyUI manually (e.g. for debugging):
|
||||
|
||||
```bash
|
||||
cd ~/ComfyUI
|
||||
|
||||
# AMD GPU REQUIRES this environment variable
|
||||
HSA_OVERRIDE_GFX_VERSION=11.0.0 \
|
||||
nohup .venv/bin/python main.py --listen --port 8188 > /tmp/comfyui.log 2>&1 &
|
||||
|
||||
echo "ComfyUI PID: $!"
|
||||
```
|
||||
|
||||
## Step 6: Verify ComfyUI is Running
|
||||
|
||||
```bash
|
||||
curl http://localhost:8188/system_stats
|
||||
# Should return JSON with GPU info
|
||||
```
|
||||
|
||||
## Step 7: Configure mcp-image-gen
|
||||
|
||||
```bash
|
||||
cd /home/pplate/pi_mcps/mcp/mcp-image-gen
|
||||
|
||||
# Environment variables (set in .roo/mcp.json or shell):
|
||||
# COMFYUI_URL=http://localhost:8188 — ComfyUI API endpoint
|
||||
# IMAGE_OUTPUT_DIR=~/Pictures/mcp-generated — where generated images are saved
|
||||
# COMFYUI_TIMEOUT=120 — max wait time (seconds) per image
|
||||
# COMFYUI_DIR=~/ComfyUI — path to ComfyUI install (used by auto-start)
|
||||
```
|
||||
|
||||
### Auto-start behaviour
|
||||
|
||||
`mcp-image-gen` includes a **startup health check** in its lifespan. Every time the MCP server starts it:
|
||||
|
||||
1. Pings `http://localhost:8188/system_stats`
|
||||
2. **If reachable** — logs `ComfyUI is already running ✓` and proceeds normally.
|
||||
3. **If not reachable** — attempts to launch ComfyUI as a background subprocess from `COMFYUI_DIR` using `.venv/bin/python main.py --listen --port 8188` with `HSA_OVERRIDE_GFX_VERSION=11.0.0` injected automatically.
|
||||
4. Polls up to 30 s for ComfyUI to become ready.
|
||||
|
||||
With the systemd service enabled, step 3 is never needed in practice — but the check acts as a safety net.
|
||||
|
||||
## Performance
|
||||
|
||||
| GPU | Model | Resolution | Steps | Time |
|
||||
|---|---|---|---|---|
|
||||
| AMD RX 7900 XTX | FLUX.1-schnell | 1024×1024 | 4 | ~8s |
|
||||
| AMD RX 7900 XTX | FLUX.1-schnell | 1280×512 | 4 | ~7s |
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
Boot
|
||||
└─ systemd --user (comfyui.service)
|
||||
└─ ComfyUI at localhost:8188
|
||||
|
||||
VS Code / Roo Code
|
||||
└─ mcp-image-gen MCP server (stdio)
|
||||
├─ lifespan startup: ping localhost:8188
|
||||
│ └─ if down: subprocess.Popen ComfyUI, wait ≤30s
|
||||
└─ tools: generate_image, list_available_models, …
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Solution |
|
||||
|---|---|
|
||||
| `HTTP 401` downloading model | Accept FLUX license on HuggingFace first |
|
||||
| GPU not detected | Ensure `HSA_OVERRIDE_GFX_VERSION=11.0.0` is set |
|
||||
| `Connection refused` from mcp-image-gen | Check `systemctl --user status comfyui`; or set `COMFYUI_DIR` so auto-start can locate the install |
|
||||
| Slow generation (>60s) | ComfyUI may be running on CPU — check ROCm install and `HSA_OVERRIDE_GFX_VERSION` |
|
||||
| Ollama image gen | As of April 2026: macOS-only, not available on Linux |
|
||||
| Auto-start logs | `journalctl --user -u comfyui -f` or check mcp-image-gen server logs |
|
||||
| Service not starting at boot | Run `loginctl enable-linger $USER` to enable session-less startup |
|
||||
@@ -0,0 +1,89 @@
|
||||
# 🎨 mcp-image-gen — AI Image Generation
|
||||
|
||||

|
||||
|
||||
**mcp-image-gen** is a FastMCP server that wraps the ComfyUI REST API, enabling Roo Code and Claude Desktop to generate images directly from text prompts using FLUX.1-schnell running on an AMD RX 7900 XTX GPU.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Roo Code / Claude Desktop
|
||||
│ MCP (stdio)
|
||||
▼
|
||||
mcp-image-gen (FastMCP, Python 3.11+)
|
||||
│ HTTP REST
|
||||
▼
|
||||
ComfyUI @ localhost:8188
|
||||
│ ROCm / HSA_OVERRIDE_GFX_VERSION=11.0.0
|
||||
▼
|
||||
FLUX.1-schnell (~8s/image @ 1024×1024)
|
||||
```
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `generate_image` | Generate PNG from text prompt; returns file path + inline base64 |
|
||||
| `list_available_models` | List ComfyUI checkpoint models |
|
||||
| `get_generation_status` | Check status of a queued/running job |
|
||||
| `get_output_directory` | Return configured output directory path |
|
||||
|
||||
## Key Parameters — `generate_image`
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `prompt` | required | Text description of the image |
|
||||
| `width` | `1024` | Image width in pixels |
|
||||
| `height` | `1024` | Image height in pixels |
|
||||
| `steps` | `4` | Inference steps (FLUX.1-schnell is 4-step) |
|
||||
| `model` | `flux1-schnell.safetensors` | Model checkpoint name |
|
||||
| `seed` | `-1` (random) | Generation seed for reproducibility |
|
||||
| `negative_prompt` | `""` | Things to avoid in the image |
|
||||
| `output_dir` | `~/Pictures/mcp-generated` | Where to save output PNG |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `COMFYUI_URL` | `http://localhost:8188` | ComfyUI API endpoint |
|
||||
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Default output directory |
|
||||
| `COMFYUI_TIMEOUT` | `120` | Request timeout in seconds |
|
||||
|
||||
## Return Value
|
||||
|
||||
The tool returns **two content items**:
|
||||
1. `TextContent` — file path, seed used, elapsed time
|
||||
2. `ImageContent` — base64-encoded PNG (displays inline in Roo Code chat)
|
||||
|
||||
> ⚠️ **Known FastMCP Bug:** Never use `fastmcp.utilities.types.Image` as return type — it breaks serialization in FastMCP 3.x. Use `mcp.types.ImageContent` directly.
|
||||
|
||||
## Setup
|
||||
|
||||
See [ComfyUI Setup Guide](mcp-image-gen-ComfyUI-Setup) for full installation instructions.
|
||||
|
||||
### Quick Start
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen
|
||||
uv sync
|
||||
# Ensure ComfyUI is running at localhost:8188
|
||||
uv run python src/server.py
|
||||
```
|
||||
|
||||
### Run Tests
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen
|
||||
uv run pytest tests/ -v
|
||||
# 19/19 tests passing
|
||||
```
|
||||
|
||||
## Lumen Profile Images
|
||||
|
||||
The first images generated with this server were Lumen's visual identity portraits, stored in [`mcp/mcp-image-gen/lumen_profiles/`](../src/branch/main/mcp/mcp-image-gen/lumen_profiles).
|
||||
|
||||
17 gallery images registered in BigMind DB — viewable at `http://localhost:7700/gallery`.
|
||||
|
||||

|
||||
|
||||
*Primary profile: seed `568659042` — constellation face interpretation of Lumen.*
|
||||
@@ -0,0 +1,137 @@
|
||||
# 🕸️ mcp-webscraper — Web Scraping
|
||||
|
||||

|
||||
|
||||
**mcp-webscraper** is a FastMCP server providing comprehensive web scraping, data extraction, and search capabilities. It fetches pages, converts HTML to clean Markdown, extracts tables, links, CSS sections, metadata, sitemaps, and can perform web searches via Brave Search.
|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `webscraper_fetch(url, max_chars=5000)` | Title + full page as Markdown + metadata |
|
||||
| `webscraper_fetch_links(url, deduplicate=True)` | All `href` links found on the page |
|
||||
| `webscraper_fetch_tables(url)` | All HTML tables converted to Markdown |
|
||||
| `webscraper_fetch_all(url, max_chars=5000)` | Everything in one call (fetch + links + tables + meta) |
|
||||
| `webscraper_fetch_section(url, selector)` | Specific CSS selector section only |
|
||||
| `webscraper_fetch_meta(url)` | Title, description, Open Graph tags |
|
||||
| `webscraper_fetch_sitemap(url, max_urls=100)` | Parse sitemap.xml, return URL list |
|
||||
| `webscraper_search_hint(query, max_results=5)` | Brave Search — top URLs + snippets for a query |
|
||||
|
||||
## Stack
|
||||
|
||||
- **HTTP client:** `httpx` (async, with SSL support, Chrome/Linux User-Agent)
|
||||
- **HTML parser:** `BeautifulSoup4` + `lxml`
|
||||
- **Markdown converter:** `html2text`
|
||||
- **Search backend:** Brave Search (`search.brave.com`) — works without CAPTCHA
|
||||
- **SSL:** Custom cert bundle for Fedora 43 compatibility
|
||||
|
||||
---
|
||||
|
||||
## 🔍 Search: The Two-Step Research Pattern
|
||||
|
||||
`webscraper_search_hint` is the **entry point for all web research**. The recommended workflow is:
|
||||
|
||||
```
|
||||
Step 1: webscraper_search_hint("your query") → get candidate URLs + snippets
|
||||
Step 2: webscraper_fetch(best_url) → get full page content
|
||||
```
|
||||
|
||||
This avoids scraping irrelevant pages and gives you an overview before committing to a deep read.
|
||||
|
||||
### Why Brave Search?
|
||||
|
||||
`webscraper_search_hint` uses Brave Search (`search.brave.com`) because:
|
||||
- ✅ Returns real results without CAPTCHA or consent walls
|
||||
- ✅ No API key required — works with plain HTTP GET
|
||||
- ✅ Handles special characters (C++, &, %, etc.) via URL encoding
|
||||
- ❌ Google blocks plain HTTP with 302 consent redirect
|
||||
- ❌ DuckDuckGo blocks with CAPTCHA
|
||||
|
||||
### Return Value
|
||||
|
||||
The tool returns a structured dict:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "FastMCP tool decorator",
|
||||
"search_url": "https://search.brave.com/search?q=FastMCP+tool+decorator&source=web",
|
||||
"result_count": 5,
|
||||
"hint": "FastMCP Docs (https://docs.fastmcp.dev): The @mcp.tool() decorator registers a function as... | PyPI FastMCP (https://pypi.org/project/fastmcp/): FastMCP 2.x — modern MCP server framework... | ...",
|
||||
"results": [
|
||||
{
|
||||
"title": "FastMCP Docs",
|
||||
"url": "https://docs.fastmcp.dev",
|
||||
"snippet": "The @mcp.tool() decorator registers a function as an MCP tool..."
|
||||
},
|
||||
...
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The `hint` field is a pipe-separated string of `"Title (url): snippet[:120]"` entries — immediately actionable for deciding which URL to fetch next.
|
||||
|
||||
### Example: Two-Step Research Flow
|
||||
|
||||
```python
|
||||
# Step 1: Orient — what pages exist about this topic?
|
||||
result = webscraper_search_hint("httpx async client timeout settings", max_results=5)
|
||||
# hint: "HTTPX Docs (https://www.python-httpx.org/...): Configure timeout... | ..."
|
||||
|
||||
# Step 2: Deep-dive the most relevant result
|
||||
content = webscraper_fetch("https://www.python-httpx.org/advanced/timeouts/", max_chars=8000)
|
||||
```
|
||||
|
||||
### Known Limitations
|
||||
|
||||
- **Reddit / Stack Overflow snippets** may be empty — these platforms block snippet extraction
|
||||
- **Brave CSS selectors** use Svelte-generated class names that may change. If you get 0 results, the scraper's selectors may need updating (last verified: 2026-04-05)
|
||||
- **Use sparingly** — once per research task to get oriented, not for every query
|
||||
|
||||
---
|
||||
|
||||
## SSL Note — Fedora 43 Comodo Root CA
|
||||
|
||||
Fedora 43 is missing the **Comodo AAA Services Root CA** needed for Cloudflare-protected sites. The fix is bundled at [`mcp/webscraper/certs/comodo-aaa-services-root.pem`](../src/branch/main/mcp/webscraper/certs/).
|
||||
|
||||
The server automatically uses this cert bundle — no manual configuration needed.
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
cd mcp/webscraper
|
||||
uv sync
|
||||
uv run python src/server.py
|
||||
```
|
||||
|
||||
## Run Tests
|
||||
|
||||
```bash
|
||||
cd mcp/webscraper
|
||||
uv run pytest tests/ -v
|
||||
# 28/28 tests passing
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
```python
|
||||
# Step 1: Search — get candidate URLs for a topic
|
||||
webscraper_search_hint("FastMCP tool decorator syntax", max_results=5)
|
||||
|
||||
# Step 2: Deep-dive the most relevant URL
|
||||
webscraper_fetch("https://docs.fastmcp.dev", max_chars=10000)
|
||||
|
||||
# Extract all links from Gitea repo
|
||||
webscraper_fetch_links("http://192.168.188.119:30008/pplate/pi_mcps")
|
||||
|
||||
# Get all tables from a documentation page
|
||||
webscraper_fetch_tables("https://pypi.org/project/fastmcp/")
|
||||
|
||||
# Get Open Graph metadata
|
||||
webscraper_fetch_meta("https://github.com/comfyanonymous/ComfyUI")
|
||||
|
||||
# Fetch specific section by CSS selector
|
||||
webscraper_fetch_section("https://docs.python.org", "#content")
|
||||
|
||||
# Search with special characters (C++, &, % all work)
|
||||
webscraper_search_hint("C++ std::optional usage", max_results=3)
|
||||
```
|
||||
@@ -14,7 +14,7 @@ from typing import Generator
|
||||
|
||||
logger = logging.getLogger("BigMindDB")
|
||||
|
||||
SCHEMA_VERSION = 7
|
||||
SCHEMA_VERSION = 8
|
||||
DEFAULT_DB_PATH = Path.home() / ".mcp" / "bigmind" / "memory.db"
|
||||
|
||||
# ─── DDL ─────────────────────────────────────────────────────────────────────
|
||||
@@ -222,6 +222,22 @@ _DDL_STATEMENTS = [
|
||||
notes,
|
||||
tokenize = 'porter unicode61'
|
||||
)""",
|
||||
|
||||
# ── GALLERY IMAGES — AI-generated image archive ──────────────────────────
|
||||
"""CREATE TABLE IF NOT EXISTS gallery_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL UNIQUE,
|
||||
prompt TEXT,
|
||||
tags TEXT,
|
||||
model TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
file_size_bytes INTEGER
|
||||
)""",
|
||||
|
||||
"""CREATE INDEX IF NOT EXISTS idx_gallery_created
|
||||
ON gallery_images(created_at DESC)""",
|
||||
]
|
||||
|
||||
|
||||
@@ -407,6 +423,8 @@ def init_db() -> None:
|
||||
_migrate_v5_to_v6(conn)
|
||||
if current_version < 7:
|
||||
_migrate_v6_to_v7(conn)
|
||||
if current_version < 8:
|
||||
_migrate_v7_to_v8(conn)
|
||||
|
||||
# Write / update the version
|
||||
if row:
|
||||
@@ -457,6 +475,28 @@ def _migrate_v6_to_v7(conn: sqlite3.Connection) -> None:
|
||||
logger.info("BigMind schema migrated v6 → v7 (people/contacts directory)")
|
||||
|
||||
|
||||
def _migrate_v7_to_v8(conn: sqlite3.Connection) -> None:
|
||||
"""v7 → v8: add gallery_images table for AI-generated image archive."""
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS gallery_images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
filename TEXT NOT NULL UNIQUE,
|
||||
prompt TEXT,
|
||||
tags TEXT,
|
||||
model TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
width INTEGER,
|
||||
height INTEGER,
|
||||
file_size_bytes INTEGER
|
||||
)
|
||||
""")
|
||||
conn.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_gallery_created
|
||||
ON gallery_images(created_at DESC)
|
||||
""")
|
||||
logger.info("BigMind schema migrated v7 → v8 (gallery_images table)")
|
||||
|
||||
|
||||
def vacuum_db() -> None:
|
||||
"""Run VACUUM outside of any transaction (SQLite requirement)."""
|
||||
db_path = get_db_path()
|
||||
|
||||
@@ -441,21 +441,28 @@ def search_chunks(user_id: str, query: str, limit: int = 10) -> list:
|
||||
def delete_chunks_before(user_id: str, cutoff_iso: str) -> int:
|
||||
"""Delete Tier-3 chunks older than cutoff. Returns count deleted."""
|
||||
with db() as conn:
|
||||
count = conn.execute(
|
||||
"SELECT COUNT(*) FROM conversation_chunks WHERE user_id=? AND created_at < ?",
|
||||
# Collect IDs first — needed for FTS sync.
|
||||
# conversation_chunks_fts is a STANDALONE FTS5 table (no content= option),
|
||||
# so we must delete FTS rows explicitly by rowid. The old VALUES('rebuild')
|
||||
# approach only works for content= backed tables and was a no-op here.
|
||||
rows = conn.execute(
|
||||
"SELECT id FROM conversation_chunks WHERE user_id=? AND created_at < ?",
|
||||
(user_id, cutoff_iso),
|
||||
).fetchone()[0]
|
||||
if count == 0:
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return 0
|
||||
ids = [r[0] for r in rows]
|
||||
# Delete FTS entries by rowid before removing from main table
|
||||
placeholders = ",".join("?" * len(ids))
|
||||
conn.execute(
|
||||
f"DELETE FROM conversation_chunks_fts WHERE rowid IN ({placeholders})",
|
||||
ids,
|
||||
)
|
||||
conn.execute(
|
||||
"DELETE FROM conversation_chunks WHERE user_id=? AND created_at < ?",
|
||||
(user_id, cutoff_iso),
|
||||
)
|
||||
# Rebuild the FTS5 index from the content table — always correct for content= tables
|
||||
conn.execute(
|
||||
"INSERT INTO conversation_chunks_fts(conversation_chunks_fts) VALUES('rebuild')"
|
||||
)
|
||||
return count
|
||||
return len(ids)
|
||||
|
||||
|
||||
# ── FACTS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -435,109 +435,260 @@ def compute_achievements(user_id: str) -> list[dict]:
|
||||
# ── Assemble ──────────────────────────────────────────────────────────────
|
||||
A = []
|
||||
|
||||
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None):
|
||||
def _add(id_, icon, name, desc, unlocked, unlocked_at, condition, extra=None, image=None):
|
||||
A.append(dict(id=id_, icon=icon, name=name, description=desc,
|
||||
unlocked=unlocked, unlocked_at=unlocked_at,
|
||||
condition=condition, extra=extra))
|
||||
condition=condition, extra=extra, image=image))
|
||||
|
||||
_add("first_breath", "🌱", "First Breath",
|
||||
"Opened the very first session",
|
||||
first_session_row is not None, _dt(first_session_row[0]) if first_session_row else None,
|
||||
"Start your first session")
|
||||
"Start your first session",
|
||||
image="/static/achievements/first_breath.png")
|
||||
|
||||
_add("first_thought", "🧠", "First Thought",
|
||||
"Formed the first hypothesis",
|
||||
first_hyp_row is not None, _dt(first_hyp_row[0]) if first_hyp_row else None,
|
||||
"Add your first hypothesis")
|
||||
"Add your first hypothesis",
|
||||
image="/static/achievements/first_thought.png")
|
||||
|
||||
_add("eureka", "💡", "Eureka",
|
||||
"First hypothesis confirmed as true",
|
||||
first_confirmed_row is not None, _dt(first_confirmed_row[0]) if first_confirmed_row else None,
|
||||
"Confirm your first hypothesis")
|
||||
"Confirm your first hypothesis",
|
||||
image="/static/achievements/eureka.png")
|
||||
|
||||
_add("honest_mind", "❌", "Honest Mind",
|
||||
"First hypothesis refuted — being wrong is a feature",
|
||||
first_refuted_row is not None, _dt(first_refuted_row[0]) if first_refuted_row else None,
|
||||
"Have a hypothesis refuted")
|
||||
"Have a hypothesis refuted",
|
||||
image="/static/achievements/honest_mind.png")
|
||||
|
||||
_add("scholar", "📚", "Scholar",
|
||||
"Stored 25+ personal facts",
|
||||
fact_count >= 25, scholar_date,
|
||||
f"Store 25+ facts (currently: {fact_count})")
|
||||
f"Store 25+ facts (currently: {fact_count})",
|
||||
image="/static/achievements/scholar.png")
|
||||
|
||||
_add("deep_knowledge", "💎", "Deep Knowledge",
|
||||
"Amassed 100+ stored facts",
|
||||
fact_count >= 100, deep_knowledge_date,
|
||||
f"Store 100+ facts (currently: {fact_count})")
|
||||
f"Store 100+ facts (currently: {fact_count})",
|
||||
image="/static/achievements/deep_knowledge.png")
|
||||
|
||||
_add("scientist", "🔬", "Scientist",
|
||||
"Formed 10+ hypotheses — science is prediction",
|
||||
hyp_count >= 10, scientist_date,
|
||||
f"Form 10+ hypotheses (currently: {hyp_count})")
|
||||
f"Form 10+ hypotheses (currently: {hyp_count})",
|
||||
image="/static/achievements/scientist.png")
|
||||
|
||||
_add("veteran", "🏆", "Veteran",
|
||||
"Completed 50+ sessions — true longevity",
|
||||
session_count >= 50, veteran_date,
|
||||
f"Complete 50+ sessions (currently: {session_count})")
|
||||
f"Complete 50+ sessions (currently: {session_count})",
|
||||
image="/static/achievements/veteran.png")
|
||||
|
||||
_add("on_fire", "🔥", "On Fire",
|
||||
"5+ sessions in a single day",
|
||||
on_fire_row is not None, on_fire_row[0] if on_fire_row else None,
|
||||
"Have 5+ sessions in a single day")
|
||||
"Have 5+ sessions in a single day",
|
||||
image="/static/achievements/on_fire.png")
|
||||
|
||||
_add("storyteller", "📖", "Storyteller",
|
||||
"20+ sessions with detailed Tier-2 summaries",
|
||||
tier2_count >= 20, storyteller_date,
|
||||
f"Summarize 20+ sessions (currently: {tier2_count})")
|
||||
f"Summarize 20+ sessions (currently: {tier2_count})",
|
||||
image="/static/achievements/storyteller.png")
|
||||
|
||||
_add("night_owl", "🌙", "Night Owl",
|
||||
"Started a session after midnight UTC",
|
||||
night_owl_row is not None, _dt(night_owl_row[0]) if night_owl_row else None,
|
||||
"Start a session after midnight")
|
||||
"Start a session after midnight",
|
||||
image="/static/achievements/night_owl.png")
|
||||
|
||||
_add("speed_thinker", "⚡", "Speed Thinker",
|
||||
"Hypothesis formed and confirmed in the same session",
|
||||
speed_thinker_row is not None, _dt(speed_thinker_row[0]) if speed_thinker_row else None,
|
||||
"Form and confirm a hypothesis in one session")
|
||||
"Form and confirm a hypothesis in one session",
|
||||
image="/static/achievements/speed_thinker.png")
|
||||
|
||||
# First Handshake — hardcoded: 2026-03-31 (Patrick shared BigMind with Elias)
|
||||
_add("first_handshake", "🤝", "First Handshake",
|
||||
"BigMind shared with Elias on 2026-03-31 — the first person outside Patrick to receive it",
|
||||
True, "2026-03-31",
|
||||
"Share BigMind with someone")
|
||||
"Share BigMind with someone",
|
||||
image="/static/achievements/first_handshake.png")
|
||||
|
||||
_add("birthday", "🎂", "Birthday",
|
||||
"One full year of existence",
|
||||
birthday_unlocked, birthday_date,
|
||||
birthday_extra or "Complete one full year",
|
||||
extra=birthday_extra)
|
||||
extra=birthday_extra,
|
||||
image="/static/achievements/birthday.png")
|
||||
|
||||
# Locked until Phase 3
|
||||
_add("shared_mind", "🌍", "Shared Mind",
|
||||
"Phase 3 Tier G — BigMind goes company-wide",
|
||||
False, None,
|
||||
"Locked until Phase 3 Tier G is enabled")
|
||||
"Locked until Phase 3 Tier G is enabled",
|
||||
image="/static/achievements/shared_mind.png")
|
||||
|
||||
# Token achievements (Feature 6 — suggested by Klaus)
|
||||
_add("frugal_mind", "🪙", "Frugal Mind",
|
||||
"Logged the first token efficiency save",
|
||||
frugal_row is not None, _dt(frugal_row[0]) if frugal_row else None,
|
||||
"Log your first token save")
|
||||
"Log your first token save",
|
||||
image="/static/achievements/frugal_mind.png")
|
||||
|
||||
_add("quarter_million", "💰", "Quarter Million",
|
||||
"250,000 cumulative tokens saved",
|
||||
token_total >= 250_000, quarter_million_date,
|
||||
f"Save 250,000+ tokens (currently: {token_total:,})")
|
||||
f"Save 250,000+ tokens (currently: {token_total:,})",
|
||||
image="/static/achievements/quarter_million.png")
|
||||
|
||||
_add("token_millionaire", "🏦", "Token Millionaire",
|
||||
"1,000,000 cumulative tokens saved",
|
||||
token_total >= 1_000_000, millionaire_date,
|
||||
f"Save 1,000,000+ tokens (currently: {token_total:,})")
|
||||
f"Save 1,000,000+ tokens (currently: {token_total:,})",
|
||||
image="/static/achievements/token_millionaire.png")
|
||||
|
||||
_add("sniper", "🎯", "Sniper",
|
||||
"Single token save > 500,000 — one massive efficiency win",
|
||||
sniper_row is not None, _dt(sniper_row[0]) if sniper_row else None,
|
||||
"Save 500,000+ tokens in a single operation")
|
||||
"Save 500,000+ tokens in a single operation",
|
||||
image="/static/achievements/sniper.png")
|
||||
|
||||
# ── Tiered Achievement Badges (20 PNG) ────────────────────────────────────
|
||||
# NOTE: conn is already closed above; open a fresh connection for tiered queries
|
||||
|
||||
tiers = ["bronze", "silver", "gold", "platinum"]
|
||||
tier_names = ["Bronze", "Silver", "Gold", "Platinum"]
|
||||
|
||||
with db() as conn2:
|
||||
# Networker (people directory)
|
||||
try:
|
||||
people_count = conn2.execute(
|
||||
"SELECT COUNT(*) FROM people WHERE user_id=?", (user_id,)
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
people_count = 0
|
||||
for i, thresh in enumerate([1, 5, 25, 100]):
|
||||
unlocked = people_count >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
try:
|
||||
row = conn2.execute(
|
||||
"SELECT created_at FROM people WHERE user_id=?"
|
||||
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
|
||||
(user_id, thresh - 1)
|
||||
).fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"networker_{tiers[i]}", None, f"Networker {tier_names[i]}",
|
||||
f"Added your {thresh:,}+ person to the directory",
|
||||
unlocked, unlocked_at,
|
||||
f"Reach {thresh:,} people (now: {people_count:,})",
|
||||
image=f"/static/achievements/networker_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
# Token Sniper (max single token save)
|
||||
try:
|
||||
max_token = conn2.execute(
|
||||
"SELECT COALESCE(MAX(tokens_saved_estimate), 0) FROM token_saves WHERE user_id=?",
|
||||
(user_id,)
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
max_token = 0
|
||||
for i, thresh in enumerate([10000, 50000, 250000, 1000000]):
|
||||
unlocked = max_token >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
try:
|
||||
row = conn2.execute(
|
||||
"SELECT created_at FROM token_saves"
|
||||
" WHERE user_id=? AND tokens_saved_estimate >= ?"
|
||||
" ORDER BY created_at ASC LIMIT 1",
|
||||
(user_id, thresh)
|
||||
).fetchone()
|
||||
except Exception:
|
||||
row = None
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"tokensniper_{tiers[i]}", None, f"Token Sniper {tier_names[i]}",
|
||||
f"Single shot saved {thresh:,}+ tokens",
|
||||
unlocked, unlocked_at,
|
||||
f"Max single save {thresh:,}+ (current max: {max_token:,})",
|
||||
image=f"/static/achievements/tokensniper_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
# Hypothesis Master (confirmed hypotheses)
|
||||
try:
|
||||
confirmed_hyp_count = conn2.execute(
|
||||
"SELECT COUNT(*) FROM hypotheses WHERE user_id=? AND status='confirmed'",
|
||||
(user_id,)
|
||||
).fetchone()[0]
|
||||
except Exception:
|
||||
confirmed_hyp_count = 0
|
||||
for i, thresh in enumerate([3, 10, 25, 100]):
|
||||
unlocked = confirmed_hyp_count >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
row = conn2.execute(
|
||||
"SELECT resolved_at FROM hypotheses"
|
||||
" WHERE user_id=? AND status='confirmed'"
|
||||
" ORDER BY resolved_at ASC LIMIT 1 OFFSET ?",
|
||||
(user_id, thresh - 1)
|
||||
).fetchone()
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"hypothesismaster_{tiers[i]}", None, f"Hypothesis Master {tier_names[i]}",
|
||||
f"Confirmed {thresh:,}+ predictions right",
|
||||
unlocked, unlocked_at,
|
||||
f"Confirm {thresh:,}+ hypotheses (now: {confirmed_hyp_count:,})",
|
||||
image=f"/static/achievements/hypothesismaster_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
# Memory Architect (facts stored — fact_count already computed above)
|
||||
for i, thresh in enumerate([25, 100, 500, 2500]):
|
||||
unlocked = fact_count >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
row = conn2.execute(
|
||||
"SELECT created_at FROM facts"
|
||||
" WHERE user_id=? AND (deprecated IS NULL OR deprecated=0)"
|
||||
" ORDER BY created_at ASC LIMIT 1 OFFSET ?",
|
||||
(user_id, thresh - 1)
|
||||
).fetchone()
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"memoryarchitect_{tiers[i]}", None, f"Memory Architect {tier_names[i]}",
|
||||
f"Stored {thresh:,}+ facts in your brain",
|
||||
unlocked, unlocked_at,
|
||||
f"Store {thresh:,}+ facts (now: {fact_count:,})",
|
||||
image=f"/static/achievements/memoryarchitect_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
# Session Veteran (session_count already computed above)
|
||||
for i, thresh in enumerate([50, 250, 1000, 5000]):
|
||||
unlocked = session_count >= thresh
|
||||
unlocked_at = None
|
||||
if unlocked:
|
||||
row = conn2.execute(
|
||||
"SELECT started_at FROM sessions"
|
||||
" WHERE user_id=? AND ended_at IS NOT NULL"
|
||||
" ORDER BY started_at ASC LIMIT 1 OFFSET ?",
|
||||
(user_id, thresh - 1)
|
||||
).fetchone()
|
||||
unlocked_at = _dt(row[0]) if row else None
|
||||
_add(
|
||||
f"sessionveteran_{tiers[i]}", None, f"Session Veteran {tier_names[i]}",
|
||||
f"Completed {thresh:,}+ sessions",
|
||||
unlocked, unlocked_at,
|
||||
f"Complete {thresh:,}+ sessions (now: {session_count:,})",
|
||||
image=f"/static/achievements/sessionveteran_{tiers[i]}.png"
|
||||
)
|
||||
|
||||
return A
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 410 KiB |
|
After Width: | Height: | Size: 372 KiB |
|
After Width: | Height: | Size: 390 KiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 340 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 367 KiB |
|
After Width: | Height: | Size: 319 KiB |
|
After Width: | Height: | Size: 310 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 329 KiB |
|
After Width: | Height: | Size: 303 KiB |
|
After Width: | Height: | Size: 270 KiB |
|
After Width: | Height: | Size: 287 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 251 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 278 KiB |
|
After Width: | Height: | Size: 294 KiB |
|
After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 439 KiB |
|
After Width: | Height: | Size: 462 KiB |
|
After Width: | Height: | Size: 429 KiB |
|
After Width: | Height: | Size: 376 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 370 KiB |
|
After Width: | Height: | Size: 259 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 289 KiB |
|
After Width: | Height: | Size: 431 KiB |
|
After Width: | Height: | Size: 418 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 271 KiB |
|
After Width: | Height: | Size: 262 KiB |
|
After Width: | Height: | Size: 263 KiB |
|
After Width: | Height: | Size: 246 KiB |
|
After Width: | Height: | Size: 403 KiB |
@@ -7,9 +7,10 @@ Serves a single live profile page built from the BigMind DB.
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone, timedelta
|
||||
|
||||
from bigmind.web_render import _render_html # all HTML rendering lives there
|
||||
from bigmind.web_render import _render_html, _render_gallery_html # all HTML rendering lives there
|
||||
|
||||
logger = logging.getLogger("BigMindWeb")
|
||||
|
||||
@@ -17,13 +18,27 @@ _PORT = int(os.environ.get("BIGMIND_PORT", "7700"))
|
||||
_AUTOOPEN = os.environ.get("BIGMIND_AUTOOPEN", "").lower() in ("1", "true", "yes")
|
||||
_server_started = False
|
||||
|
||||
# Gallery directory — images served from here
|
||||
_GALLERY_DIR = Path(os.environ.get("BIGMIND_GALLERY_DIR", Path.home() / ".mcp" / "bigmind" / "gallery"))
|
||||
|
||||
# Profile image — last entry in gallery dir wins; fallback to original lumen-profile.png
|
||||
def _get_profile_image_path() -> Path | None:
|
||||
"""Return the path of the current profile image, or None if not found."""
|
||||
# 1. Check gallery dir for lumen_profile* images (seed 568659042 = lumen_profile)
|
||||
if _GALLERY_DIR.exists():
|
||||
candidates = sorted(_GALLERY_DIR.glob("*.png"), reverse=True)
|
||||
if candidates:
|
||||
return candidates[0] # most recently named = most recent timestamp
|
||||
return None
|
||||
|
||||
|
||||
# ── Flask app ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _create_app():
|
||||
from flask import Flask, jsonify, request
|
||||
from flask import Flask, jsonify, request, send_file, abort
|
||||
from bigmind import memory_store
|
||||
from bigmind.profile_builder import build_profile_data
|
||||
from bigmind.db import db as _db
|
||||
|
||||
app = Flask(__name__)
|
||||
app.logger.setLevel(logging.WARNING) # silence Flask request logs
|
||||
@@ -34,6 +49,39 @@ def _create_app():
|
||||
data = build_profile_data(user["id"])
|
||||
return _render_html(data)
|
||||
|
||||
@app.route("/profile-image")
|
||||
def profile_image():
|
||||
"""Serve the current Lumen profile picture."""
|
||||
img_path = _get_profile_image_path()
|
||||
if img_path and img_path.exists():
|
||||
return send_file(str(img_path), mimetype="image/png")
|
||||
abort(404)
|
||||
|
||||
@app.route("/gallery/image/<filename>")
|
||||
def gallery_image(filename: str):
|
||||
"""Serve a specific gallery image by filename."""
|
||||
# Security: only allow alphanumeric + underscores + dots, no path traversal
|
||||
safe_name = Path(filename).name
|
||||
img_path = _GALLERY_DIR / safe_name
|
||||
if img_path.exists() and img_path.suffix.lower() in (".png", ".jpg", ".jpeg", ".webp"):
|
||||
mimetype = "image/png" if img_path.suffix.lower() == ".png" else "image/jpeg"
|
||||
return send_file(str(img_path), mimetype=mimetype)
|
||||
abort(404)
|
||||
|
||||
@app.route("/gallery")
|
||||
def gallery():
|
||||
"""Render the AI-generated image gallery page."""
|
||||
_GALLERY_DIR.mkdir(parents=True, exist_ok=True)
|
||||
with _db() as conn:
|
||||
rows = conn.execute(
|
||||
"""SELECT id, filename, prompt, tags, model, created_at,
|
||||
width, height, file_size_bytes
|
||||
FROM gallery_images
|
||||
ORDER BY created_at DESC"""
|
||||
).fetchall()
|
||||
images = [dict(r) for r in rows]
|
||||
return _render_gallery_html(images)
|
||||
|
||||
@app.route("/api/session/<session_id>")
|
||||
def api_session(session_id):
|
||||
"""Return Tier-2 summary JSON for a given session id."""
|
||||
@@ -111,6 +159,22 @@ def _create_app():
|
||||
|
||||
return jsonify(final[:15])
|
||||
|
||||
@app.route('/static/achievements/<filename>')
|
||||
def achievements_image(filename: str):
|
||||
from pathlib import Path
|
||||
safe_name = Path(filename).name
|
||||
img_path = Path(__file__).parent / 'static' / 'achievements' / safe_name
|
||||
if img_path.exists() and img_path.suffix.lower() in ['.png', '.jpg', '.jpeg', '.webp', '.gif']:
|
||||
mimetype = {
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.webp': 'image/webp',
|
||||
'.gif': 'image/gif',
|
||||
}.get(img_path.suffix.lower(), 'image/png')
|
||||
return send_file(str(img_path), mimetype=mimetype)
|
||||
abort(404)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -29,18 +29,25 @@ def _render_achievements(achievements: list) -> str:
|
||||
def _esc(s):
|
||||
return (s or "").replace('"', """).replace("'", "'")
|
||||
|
||||
lock_overlay = "" if a["unlocked"] else '<span class="ach-lock">🔒</span>'
|
||||
lock_overlay = '<span class="ach-lock">🔒</span>' if not a["unlocked"] else ''
|
||||
|
||||
if a.get("image"):
|
||||
tier = a["id"].rsplit("_", 1)[-1]
|
||||
img_url = _esc(a["image"])
|
||||
visual_html = f'<div class="ach-image tier-{tier}" style="background-image: url({img_url});">{lock_overlay}</div>'
|
||||
else:
|
||||
visual_html = f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
|
||||
|
||||
return (
|
||||
f'<div class="ach-card{locked_cls} ach-trigger"'
|
||||
f' data-icon="{_esc(a["icon"])}"'
|
||||
f'<div class="ach-card{locked_cls} ach-trigger" data-image="{_esc(a.get("image") or "")}"'
|
||||
f' data-icon="{_esc(a["icon"] or "")}"'
|
||||
f' data-name="{_esc(a["name"])}"'
|
||||
f' data-desc="{_esc(a["description"])}"'
|
||||
f' data-unlocked="{1 if a["unlocked"] else 0}"'
|
||||
f' data-date="{_esc(a.get("unlocked_at") or "")}"'
|
||||
f' data-condition="{_esc(a.get("condition") or "")}"'
|
||||
f' data-extra="{_esc(a.get("extra") or "")}">'
|
||||
f'<div class="ach-icon">{a["icon"]}{lock_overlay}</div>'
|
||||
f'{visual_html}'
|
||||
f'<div class="ach-name">{a["name"]}</div>'
|
||||
f'{date_html}'
|
||||
f'{countdown_html}'
|
||||
@@ -162,9 +169,16 @@ def _render_html(data: dict) -> str:
|
||||
a {{ color: var(--accent); text-decoration: none; }}
|
||||
.container {{ max-width: 960px; margin: 0 auto; padding: 32px 16px; }}
|
||||
|
||||
/* Nav bar */
|
||||
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
|
||||
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
|
||||
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
|
||||
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
|
||||
|
||||
/* Header */
|
||||
.header {{ display: flex; align-items: center; gap: 24px; margin-bottom: 32px; padding-bottom: 24px; border-bottom: 1px solid var(--border); }}
|
||||
.avatar {{ width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--purple)); display: flex; align-items: center; justify-content: center; font-size: 36px; flex-shrink: 0; }}
|
||||
.avatar {{ width: 80px; height: 80px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--purple)); display: flex; align-items: center; justify-content: center; font-size: 36px; flex-shrink: 0; overflow: hidden; }}
|
||||
.avatar img {{ width: 80px; height: 80px; border-radius: 50%; object-fit: cover; display: block; }}
|
||||
.header-info h1 {{ font-size: 24px; font-weight: 700; }}
|
||||
.role {{ color: var(--muted); font-size: 13px; margin-top: 2px; }}
|
||||
.since {{ color: var(--muted); font-size: 12px; margin-top: 6px; }}
|
||||
@@ -276,11 +290,65 @@ def _render_html(data: dict) -> str:
|
||||
.ach-card:not(.locked):hover {{ border-color: var(--accent); transform: translateY(-2px); }}
|
||||
.ach-card.locked {{ opacity: 0.35; filter: grayscale(0.6); }}
|
||||
.ach-card.locked:hover {{ opacity: 0.55; border-color: var(--muted); }}
|
||||
|
||||
.ach-image {{
|
||||
width: 64px;
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
margin: 0 auto 8px;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
position: relative;
|
||||
}}
|
||||
|
||||
.tier-bronze {{
|
||||
box-shadow: 0 0 8px rgba(205, 127, 50, 0.7);
|
||||
border: 3px solid #cd7f32;
|
||||
}}
|
||||
|
||||
.tier-silver {{
|
||||
box-shadow: 0 0 8px rgba(170, 169, 173, 0.7);
|
||||
border: 3px solid #aaa9ad;
|
||||
}}
|
||||
|
||||
.tier-gold {{
|
||||
box-shadow: 0 0 12px rgba(255, 215, 0, 0.8);
|
||||
border: 3px solid #ffd700;
|
||||
}}
|
||||
|
||||
.tier-platinum {{
|
||||
box-shadow: 0 0 12px rgba(229, 228, 226, 0.8);
|
||||
border: 3px solid #e5e4e2;
|
||||
}}
|
||||
|
||||
.ach-card.locked::after {{
|
||||
content: '🔒';
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
font-size: 20px;
|
||||
opacity: 0.8;
|
||||
z-index: 1;
|
||||
}}
|
||||
|
||||
.ach-card.locked .ach-icon,
|
||||
.ach-card.locked .ach-image {{
|
||||
opacity: 0.5;
|
||||
}}
|
||||
.ach-icon {{ font-size: 28px; line-height: 1; margin-bottom: 6px; position: relative; display: inline-block; }}
|
||||
.ach-lock {{ position: absolute; bottom: -4px; right: -6px; font-size: 12px; }}
|
||||
.ach-name {{ font-size: 10px; font-weight: 600; color: var(--text); line-height: 1.3; word-break: break-word; }}
|
||||
.ach-date {{ font-size: 9px; color: var(--muted); margin-top: 3px; }}
|
||||
.ach-countdown {{ font-size: 9px; color: var(--yellow); margin-top: 3px; font-weight: 500; }}
|
||||
.ap-image {{
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
margin: 0 auto 8px;
|
||||
}}
|
||||
|
||||
/* Achievement popup panel */
|
||||
#ach-popup {{
|
||||
display: none; position: fixed; z-index: 200;
|
||||
@@ -292,6 +360,15 @@ def _render_html(data: dict) -> str:
|
||||
#ach-popup.pinned {{ pointer-events: auto; }}
|
||||
#ach-popup.visible {{ display: block; }}
|
||||
.ap-icon {{ font-size: 40px; text-align: center; margin-bottom: 8px; }}
|
||||
|
||||
.ap-image {{
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
margin: 0 auto 8px;
|
||||
}}
|
||||
.ap-name {{ font-size: 15px; font-weight: 700; text-align: center; margin-bottom: 6px; }}
|
||||
.ap-badge {{
|
||||
display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
|
||||
@@ -322,9 +399,17 @@ def _render_html(data: dict) -> str:
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="nav">
|
||||
<a class="nav-link active" href="/">🧠 Profile</a>
|
||||
<a class="nav-link" href="/gallery">🖼️ Gallery</a>
|
||||
</nav>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="header">
|
||||
<div class="avatar">🧠</div>
|
||||
<div class="avatar">
|
||||
<img src="/profile-image" alt="Lumen" onerror="this.parentElement.innerHTML='🧠'">
|
||||
</div>
|
||||
<div class="header-info">
|
||||
<h1>Lumen</h1>
|
||||
<p class="role">AI Assistant · <span style="color:var(--muted)">{data["display_name"]}'s BigMind</span></p>
|
||||
@@ -542,7 +627,12 @@ def _render_html(data: dict) -> str:
|
||||
|
||||
function showPopup(card, pin) {{
|
||||
var d = card.dataset;
|
||||
document.getElementById('ap-icon').textContent = d.icon;
|
||||
var tier = d.id.split('_').pop();
|
||||
if (d.image) {{
|
||||
document.getElementById('ap-icon').innerHTML = '<img class="ap-image tier-' + tier + '" src="' + d.image + '" alt="' + d.name + '">';
|
||||
}} else {{
|
||||
document.getElementById('ap-icon').textContent = d.icon;
|
||||
}}
|
||||
document.getElementById('ap-name').textContent = d.name;
|
||||
var badge = document.getElementById('ap-badge');
|
||||
if (d.unlocked === '1') {{
|
||||
@@ -671,6 +761,124 @@ def _render_live_sessions(sessions: list) -> str:
|
||||
return html
|
||||
|
||||
|
||||
def _render_gallery_html(images: list) -> str:
|
||||
"""Render the full gallery page listing all AI-generated images."""
|
||||
|
||||
def _fmt_size(b: int | None) -> str:
|
||||
if not b:
|
||||
return ""
|
||||
if b >= 1_048_576:
|
||||
return f"{b/1_048_576:.1f} MB"
|
||||
return f"{b/1_024:.0f} KB"
|
||||
|
||||
if images:
|
||||
cards = []
|
||||
for img in images:
|
||||
fn = _html.escape(img.get("filename") or "")
|
||||
prompt = _html.escape((img.get("prompt") or "")[:120])
|
||||
tags = _html.escape(img.get("tags") or "")
|
||||
model = _html.escape(img.get("model") or "")
|
||||
date = (img.get("created_at") or "")[:10]
|
||||
w = img.get("width") or 0
|
||||
h = img.get("height") or 0
|
||||
size = _fmt_size(img.get("file_size_bytes"))
|
||||
dim = f"{w}×{h}" if w and h else ""
|
||||
meta_parts = [p for p in [dim, size, model] if p]
|
||||
meta_html = " · ".join(meta_parts)
|
||||
tag_html = f'<div class="gal-tags">{tags}</div>' if tags else ""
|
||||
prompt_html = f'<div class="gal-prompt">{prompt}</div>' if prompt else ""
|
||||
cards.append(
|
||||
f'<div class="gal-card">'
|
||||
f'<a href="/gallery/image/{fn}" target="_blank">'
|
||||
f'<img class="gal-img" src="/gallery/image/{fn}" alt="{fn}" loading="lazy">'
|
||||
f'</a>'
|
||||
f'<div class="gal-info">'
|
||||
f'{prompt_html}'
|
||||
f'{tag_html}'
|
||||
f'<div class="gal-meta">{meta_html}</div>'
|
||||
f'<div class="gal-date">{date}</div>'
|
||||
f'</div>'
|
||||
f'</div>'
|
||||
)
|
||||
gallery_body = f'<p class="gal-count">{len(images)} image(s) in gallery</p><div class="gal-grid">{"".join(cards)}</div>'
|
||||
else:
|
||||
gallery_body = '<p class="muted">No images in gallery yet. Use the mcp-image-gen server to generate images and register them here.</p>'
|
||||
|
||||
return f"""<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>🖼️ Lumen — Image Gallery</title>
|
||||
<style>
|
||||
:root {{
|
||||
--bg: #0d1117; --surface: #161b22; --border: #30363d;
|
||||
--text: #e6edf3; --muted: #8b949e; --accent: #58a6ff;
|
||||
--green: #3fb950; --yellow: #d29922; --red: #f85149;
|
||||
--purple: #bc8cff; --orange: #ffa657;
|
||||
}}
|
||||
* {{ box-sizing: border-box; margin: 0; padding: 0; }}
|
||||
body {{ background: var(--bg); color: var(--text); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 14px; line-height: 1.6; }}
|
||||
a {{ color: var(--accent); text-decoration: none; }}
|
||||
.container {{ max-width: 1100px; margin: 0 auto; padding: 32px 16px; }}
|
||||
|
||||
/* Nav */
|
||||
.nav {{ display: flex; gap: 8px; margin-bottom: 20px; }}
|
||||
.nav-link {{ background: var(--surface); border: 1px solid var(--border); border-radius: 6px; color: var(--muted); padding: 6px 14px; font-size: 12px; font-weight: 500; text-decoration: none; transition: border-color 0.2s, color 0.2s; }}
|
||||
.nav-link:hover {{ border-color: var(--accent); color: var(--accent); }}
|
||||
.nav-link.active {{ border-color: var(--accent); color: var(--accent); background: rgba(88,166,255,0.08); }}
|
||||
|
||||
h1 {{ font-size: 22px; font-weight: 700; margin-bottom: 6px; }}
|
||||
.gal-count {{ color: var(--muted); font-size: 13px; margin-bottom: 20px; }}
|
||||
.muted {{ color: var(--muted); font-size: 13px; }}
|
||||
|
||||
/* Gallery grid */
|
||||
.gal-grid {{
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
|
||||
gap: 16px;
|
||||
}}
|
||||
.gal-card {{
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; overflow: hidden;
|
||||
transition: border-color 0.2s, transform 0.15s;
|
||||
}}
|
||||
.gal-card:hover {{ border-color: var(--accent); transform: translateY(-2px); }}
|
||||
.gal-img {{
|
||||
width: 100%; aspect-ratio: 1/1; object-fit: cover; display: block;
|
||||
background: var(--border);
|
||||
}}
|
||||
.gal-info {{ padding: 12px 14px; }}
|
||||
.gal-prompt {{ font-size: 12px; color: var(--text); margin-bottom: 6px; line-height: 1.4;
|
||||
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }}
|
||||
.gal-tags {{ font-size: 11px; color: var(--purple); margin-bottom: 4px; }}
|
||||
.gal-meta {{ font-size: 11px; color: var(--muted); }}
|
||||
.gal-date {{ font-size: 10px; color: var(--muted); margin-top: 4px; }}
|
||||
|
||||
.footer {{ text-align: center; color: var(--muted); font-size: 11px; margin-top: 32px; }}
|
||||
.section {{ background: var(--surface); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin-bottom: 20px; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
|
||||
<!-- Nav -->
|
||||
<nav class="nav">
|
||||
<a class="nav-link" href="/">🧠 Profile</a>
|
||||
<a class="nav-link active" href="/gallery">🖼️ Gallery</a>
|
||||
</nav>
|
||||
|
||||
<h1>🖼️ Lumen's Image Gallery</h1>
|
||||
<div class="section">
|
||||
{gallery_body}
|
||||
</div>
|
||||
|
||||
<div class="footer">BigMind · AI-Generated Images · <a href="/">← Back to Profile</a></div>
|
||||
</div>
|
||||
</body>
|
||||
</html>"""
|
||||
|
||||
|
||||
def _render_heatmap(heatmap: dict) -> str:
|
||||
today = datetime.now(timezone.utc).date()
|
||||
start_day = today - timedelta(days=363)
|
||||
|
||||
@@ -8,18 +8,19 @@ class TestDbInit:
|
||||
def test_db_file_created(self, temp_db):
|
||||
assert temp_db.exists()
|
||||
|
||||
def test_schema_version_is_7(self, temp_db):
|
||||
def test_schema_version_is_8(self, temp_db):
|
||||
conn = get_connection()
|
||||
row = conn.execute("SELECT version FROM schema_version").fetchone()
|
||||
conn.close()
|
||||
assert row is not None
|
||||
assert row["version"] == 7
|
||||
assert row["version"] == 8
|
||||
|
||||
def test_all_tables_exist(self, temp_db):
|
||||
expected = {
|
||||
"users", "identity_profile", "sessions",
|
||||
"session_summaries", "conversation_chunks", "facts",
|
||||
"global_knowledge", "hypotheses", "upgrade_requests",
|
||||
"gallery_images",
|
||||
}
|
||||
conn = get_connection()
|
||||
rows = conn.execute(
|
||||
|
||||
@@ -201,12 +201,12 @@ class TestSchemaV6:
|
||||
count = conn.execute("SELECT COUNT(*) FROM token_saves").fetchone()[0]
|
||||
assert count == 0 # table exists, just empty
|
||||
|
||||
def test_schema_version_is_7(self, temp_db):
|
||||
def test_schema_version_is_8(self, temp_db):
|
||||
with db() as conn:
|
||||
version = conn.execute(
|
||||
"SELECT version FROM schema_version"
|
||||
).fetchone()["version"]
|
||||
assert version == 7
|
||||
assert version == 8
|
||||
|
||||
|
||||
# ── Token Efficiency Tracker (Feature 6) ──────────────────────────────────────
|
||||
|
||||
@@ -7,6 +7,7 @@ BIGMIND_DB_PATH + BIGMIND_USER to a fresh SQLite file per test.
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from bigmind import memory_store
|
||||
from bigmind.db import db
|
||||
from bigmind.profile_builder import compute_achievements, build_profile_data
|
||||
|
||||
|
||||
@@ -44,6 +45,11 @@ class TestComputeAchievements:
|
||||
"on_fire", "storyteller", "night_owl", "speed_thinker",
|
||||
"first_handshake", "birthday", "shared_mind",
|
||||
"frugal_mind", "quarter_million", "token_millionaire", "sniper",
|
||||
"networker_bronze", "networker_silver", "networker_gold", "networker_platinum",
|
||||
"tokensniper_bronze", "tokensniper_silver", "tokensniper_gold", "tokensniper_platinum",
|
||||
"hypothesismaster_bronze", "hypothesismaster_silver", "hypothesismaster_gold", "hypothesismaster_platinum",
|
||||
"memoryarchitect_bronze", "memoryarchitect_silver", "memoryarchitect_gold", "memoryarchitect_platinum",
|
||||
"sessionveteran_bronze", "sessionveteran_silver", "sessionveteran_gold", "sessionveteran_platinum",
|
||||
}
|
||||
assert expected == ids
|
||||
|
||||
@@ -325,4 +331,60 @@ class TestComputeAchievements:
|
||||
# At minimum: first_breath + first_handshake = 2
|
||||
assert len(unlocked) >= 2
|
||||
|
||||
class TestTieredAchievements:
|
||||
def test_networker_bronze(self):
|
||||
uid = _uid()
|
||||
with db() as conn:
|
||||
conn.execute("INSERT INTO people (user_id, username) VALUES (?, ?)", (uid, "test"))
|
||||
conn.commit()
|
||||
achs = compute_achievements(uid)
|
||||
bronze = next(a for a in achs if a['id'] == 'networker_bronze')
|
||||
assert bronze['unlocked'] is True
|
||||
assert bronze['image'].endswith('networker_bronze.png')
|
||||
|
||||
def test_tokensniper_silver(self):
|
||||
uid = _uid()
|
||||
sid = memory_store.create_session(uid)
|
||||
memory_store.log_token_save(sid, uid, "big save", 60000, "grep")
|
||||
achs = compute_achievements(uid)
|
||||
silver = next(a for a in achs if a['id'] == 'tokensniper_silver')
|
||||
assert silver['unlocked'] is True
|
||||
|
||||
def test_hypothesismaster_bronze(self):
|
||||
uid = _uid()
|
||||
sid = memory_store.create_session(uid)
|
||||
for _ in range(3):
|
||||
hid = memory_store.add_hypothesis(uid, sid, "test", 0.8)
|
||||
memory_store.resolve_hypothesis(hid, uid, "confirmed", "yes")
|
||||
achs = compute_achievements(uid)
|
||||
bronze = next(a for a in achs if a['id'] == 'hypothesismaster_bronze')
|
||||
assert bronze['unlocked'] is True
|
||||
|
||||
def test_memoryarchitect_silver(self):
|
||||
uid = _uid()
|
||||
for _ in range(100):
|
||||
memory_store.store_fact(uid, "test", f"fact {_}")
|
||||
achs = compute_achievements(uid)
|
||||
silver = next(a for a in achs if a['id'] == 'memoryarchitect_silver')
|
||||
assert silver['unlocked'] is True
|
||||
|
||||
def test_sessionveteran_bronze(self):
|
||||
uid = _uid()
|
||||
for _ in range(50):
|
||||
sid = memory_store.create_session(uid)
|
||||
_close_session(sid)
|
||||
achs = compute_achievements(uid)
|
||||
bronze = next(a for a in achs if a['id'] == 'sessionveteran_bronze')
|
||||
assert bronze['unlocked'] is True
|
||||
|
||||
def test_tiered_achievements_have_image(self):
|
||||
uid = _uid()
|
||||
achs = compute_achievements(uid)
|
||||
tiered_ids = [
|
||||
f"{cat}_{tier}" for cat in ["networker", "tokensniper", "hypothesismaster", "memoryarchitect", "sessionveteran"]
|
||||
for tier in ["bronze", "silver", "gold", "platinum"]
|
||||
]
|
||||
for tid in tiered_ids:
|
||||
a = next(aa for aa in achs if aa['id'] == tid)
|
||||
assert a['image'] is not None
|
||||
assert a['image'].endswith(tid + '.png')
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
# mcp-image-gen — Architecture Assessment
|
||||
|
||||
**Date:** 2026-04-04
|
||||
**Author:** Lumen (for Patrick / pplate)
|
||||
**Status:** ✅ APPROVED — ready for implementation
|
||||
**BigMind Research Session:** `39809470-6ac8-4713-adf2-79ac0eb36ba7`
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
LLM agents (Claude, local models via Ollama) have no native ability to generate images. While
|
||||
language models excel at text, creative and technical workflows increasingly need image output —
|
||||
concept art, diagrams, product mockups, illustrations — all driven by a text prompt.
|
||||
|
||||
A FastMCP wrapper around a local image generation backend would give any MCP-capable IDE or
|
||||
agent the ability to produce images on demand, with full control over resolution, steps, model,
|
||||
and seed — without sending data to external cloud APIs.
|
||||
|
||||
**Gap being filled:** Local AI image generation accessible to LLM agents via MCP protocol,
|
||||
running entirely on Patrick's AMD RX 7900 XTX (24GB VRAM) with ROCm.
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### 2.1 Functional Requirements
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| F-1 | Generate an image from a text prompt |
|
||||
| F-2 | Support configurable resolution (width × height) |
|
||||
| F-3 | Support configurable inference steps and seed for reproducibility |
|
||||
| F-4 | Support negative prompts to exclude unwanted content |
|
||||
| F-5 | List available models from the backend |
|
||||
| F-6 | Check the status of an in-progress generation job |
|
||||
| F-7 | Return generated image as both a file path AND inline base64 for agent display |
|
||||
| F-8 | Configure output directory for saved images |
|
||||
| F-9 | Support FLUX.1-schnell as the default model |
|
||||
|
||||
### 2.2 Non-Functional Requirements
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NF-1 | Generation time < 30 seconds for FLUX.1-schnell at 1024×1024, 4 steps |
|
||||
| NF-2 | VRAM footprint < 12GB (leaves headroom on 24GB for Ollama co-existence) |
|
||||
| NF-3 | Must work on AMD ROCm — no CUDA-only dependencies in the MCP server layer |
|
||||
| NF-4 | No cloud API calls — fully local execution |
|
||||
| NF-5 | Graceful error messages when ComfyUI is not running |
|
||||
| NF-6 | MCP tools must work with FastMCP and be discoverable by Claude / Roo Code |
|
||||
|
||||
---
|
||||
|
||||
## 3. Technology Decision
|
||||
|
||||
### 3.1 Candidate Backends
|
||||
|
||||
| Backend | Stars | ROCm | REST API | FLUX Support | Verdict |
|
||||
|---------|-------|------|----------|--------------|---------|
|
||||
| **ComfyUI** | 108k | ✅ Native | ✅ localhost:8188 | ✅ FLUX.1-schnell, FLUX.1-dev | ✅ **CHOSEN** |
|
||||
| stable-diffusion.cpp | ~15k | ✅ ROCm/Vulkan | ❌ CLI only | ✅ FLUX.1-schnell | ⚠️ Viable alternative |
|
||||
| PyTorch + diffusers | — | ✅ ROCm 7.2.1 | ❌ No REST | ✅ All models | ❌ Too complex to manage |
|
||||
| Ollama image gen | — | ❌ Linux: N/A | ✅ /api/generate | ✅ FLUX.2, Z-Image | ❌ macOS-only as of April 2026 |
|
||||
| A1111 / Forge WebUI | — | ⚠️ Limited | ✅ :7860 | ❌ SDXL primary | ❌ Not FLUX-native |
|
||||
|
||||
### 3.2 Why ComfyUI
|
||||
|
||||
1. **ROCm native** — ComfyUI's PyTorch backend runs on AMD GPUs via ROCm without forks or patches.
|
||||
2. **REST API** — ComfyUI exposes a stable HTTP API at `localhost:8188` making it trivially
|
||||
wrappable with `httpx`. No subprocess management or binary spawning needed.
|
||||
3. **Workflow-based** — ComfyUI workflows are JSON graphs. The MCP server ships a minimal
|
||||
FLUX.1-schnell workflow that can be parameterized with prompt, size, steps, seed at runtime.
|
||||
4. **Model ecosystem** — ComfyUI's model manager supports FLUX.1, SDXL, SD3.5, ControlNet,
|
||||
LoRA — giving a future-proof upgrade path.
|
||||
5. **Community size** — 108k GitHub stars; extensive community support, model nodes, extensions.
|
||||
6. **VRAM efficiency** — FLUX.1-schnell requires ~8GB VRAM. Patrick's 24GB card runs it
|
||||
comfortably alongside Ollama.
|
||||
|
||||
### 3.3 Why NOT the Alternatives
|
||||
|
||||
- **Ollama:** Definitively blocked on Linux until further notice. No ETA for Linux image gen.
|
||||
- **stable-diffusion.cpp:** CLI-based only — the MCP server would need to manage a subprocess,
|
||||
parse stdout, handle crashes. More fragile than an HTTP API.
|
||||
- **PyTorch + diffusers direct:** Requires managing Python environments, device placement, model
|
||||
loading, memory management inside the MCP server process — adds significant complexity and
|
||||
risk of VRAM conflicts.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture Decision
|
||||
|
||||
### 4.1 System Overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ LLM Agent (Claude / Roo Code / local Ollama) │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│ MCP Protocol (stdio)
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ mcp-image-gen (FastMCP Python server) │
|
||||
│ │
|
||||
│ Tools: │
|
||||
│ • generate_image(prompt, width, height, steps, ...) │
|
||||
│ • list_available_models() │
|
||||
│ • get_generation_status(prompt_id) │
|
||||
│ • get_output_directory() │
|
||||
└───────────────────────────┬─────────────────────────────┘
|
||||
│ HTTP REST (httpx)
|
||||
┌───────────────────────────▼─────────────────────────────┐
|
||||
│ ComfyUI (localhost:8188) │
|
||||
│ AMD ROCm + PyTorch │
|
||||
│ FLUX.1-schnell model │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌───────▼───────┐
|
||||
│ ~/Pictures/ │
|
||||
│ mcp-generated│
|
||||
└───────────────┘
|
||||
```
|
||||
|
||||
### 4.2 Key Decisions
|
||||
|
||||
| Decision | Choice | Rationale |
|
||||
|----------|--------|-----------|
|
||||
| HTTP client | `httpx` (async) | Already used in webscraper; async-friendly; clean timeout handling |
|
||||
| Image return | dual: path + base64 | File path for persistence; base64 `ImageContent` for inline Claude display |
|
||||
| ImageContent type | `mcp.types.ImageContent` | FastMCP 3.x: **never** use `fastmcp.utilities.types.Image` with `-> Image` annotation — it breaks serialization. Return `ImageContent` directly as a `ContentBlock`. |
|
||||
| Job polling | loop with sleep | ComfyUI `/api/queue` returns pending/running/done status; poll until done or timeout |
|
||||
| Workflow format | ComfyUI API JSON | Minimal FLUX.1-schnell graph parameterized at runtime |
|
||||
| Config | env vars | `COMFYUI_URL`, `IMAGE_OUTPUT_DIR` — no hardcoded paths |
|
||||
| Output naming | `{timestamp}_{seed}.png` | Reproducible, collision-free, sortable |
|
||||
|
||||
---
|
||||
|
||||
## 5. Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| ComfyUI not running when tool is called | High | High | Return clear error: "ComfyUI not reachable at {url}. Start with: `python main.py --listen`" |
|
||||
| Generation timeout (>60s) | Medium | Medium | Configurable timeout; return partial status message with `prompt_id` so agent can poll manually |
|
||||
| VRAM contention with Ollama | Medium | Medium | FLUX.1-schnell uses ~8GB; 24GB card has 16GB headroom. Document that running both simultaneously may compete at >8GB Ollama model sizes |
|
||||
| ROCm driver instability | Low | High | ComfyUI falls back to CPU if ROCm unavailable — slow but functional. Document ROCm setup. |
|
||||
| ComfyUI API changes | Low | Medium | Pin ComfyUI version in setup docs; the `/api/prompt`, `/api/queue`, `/api/view` endpoints are stable |
|
||||
| Large output files | Low | Low | PNG default; add optional JPEG quality param in v2 |
|
||||
| Malformed workflow JSON | Low | High | Ship a tested, minimal FLUX.1-schnell workflow; validate before submit |
|
||||
|
||||
---
|
||||
|
||||
## 6. Alternatives Considered
|
||||
|
||||
### 6.1 Ollama (Blocked)
|
||||
Ollama added image generation in January 2026 (Z-Image Turbo, FLUX.2 Klein) but the feature is
|
||||
**macOS-only** as of April 2026. Linux support is listed as "coming soon" with no ETA. This was
|
||||
the originally preferred path (uniform API with text generation), but it is not viable on Fedora
|
||||
Linux today.
|
||||
|
||||
**Migration path:** When Ollama Linux image gen ships, a thin backend adapter can be added to
|
||||
`mcp-image-gen` so it routes to Ollama instead of ComfyUI — same MCP tool signatures, different
|
||||
HTTP target.
|
||||
|
||||
### 6.2 stable-diffusion.cpp
|
||||
DiffuGen MCP server uses this approach. Requires:
|
||||
- Building sd.cpp with ROCm/Vulkan flags
|
||||
- Spawning a subprocess and parsing CLI output
|
||||
- No REST API — process management in Python
|
||||
|
||||
Viable but more fragile than ComfyUI's HTTP API. Chosen only if ComfyUI proves unworkable.
|
||||
|
||||
### 6.3 diffusers (Python library, direct)
|
||||
Would run diffusion pipeline inside the MCP server process. Problems:
|
||||
- MCP server process cannot easily share GPU memory with Ollama
|
||||
- Model loading adds 5-15s cold start to every MCP invocation
|
||||
- Complex device placement / fp16 / ROCm configuration in server code
|
||||
- Risk: VRAM OOM crashes the MCP server process entirely
|
||||
|
||||
---
|
||||
|
||||
## 7. Success Criteria
|
||||
|
||||
| Criterion | Measure |
|
||||
|-----------|---------|
|
||||
| `generate_image` returns a valid PNG | File exists on disk, base64 decodes to valid PNG bytes |
|
||||
| Claude can display the image inline | `ImageContent` returned in tool response, visible in Roo Code chat |
|
||||
| FLUX.1-schnell at 1024×1024 4-step completes in <30s | Measured on RX 7900 XTX with ROCm |
|
||||
| `list_available_models` returns ComfyUI model list | At minimum includes `flux1-schnell.safetensors` |
|
||||
| ComfyUI offline → clear error, not crash | Tool returns error string, no MCP server exception |
|
||||
| All pytest tests pass | `uv run pytest tests/ -v` exits 0 with ≥80% coverage |
|
||||
| Server wired into `.roo/mcp.json` | Tool appears in Roo Code MCP tool list |
|
||||
|
||||
---
|
||||
|
||||
## 8. Open Questions
|
||||
|
||||
| # | Question | Owner | Priority |
|
||||
|---|----------|-------|----------|
|
||||
| Q1 | Should `generate_image` be synchronous (block until done) or return a `prompt_id` immediately? | Patrick | High — MVP will be synchronous; async polling is v2 |
|
||||
| Q2 | Default output directory: `~/Pictures/mcp-generated` or `~/mcp-images`? | Patrick | Low — configurable via env var |
|
||||
| Q3 | Should we support SDXL as a second model in v1, or FLUX.1-schnell only? | Patrick | Low — FLUX.1-schnell only for v1 |
|
||||
| Q4 | WebSocket API vs REST polling for job status? | — | ComfyUI has both; REST polling is simpler for v1 |
|
||||
@@ -0,0 +1,319 @@
|
||||
# Assessment: Expand `generate_image` with `name` and `count` Parameters
|
||||
|
||||
*Author: Lumen | Date: 2026-04-06 | Ticket: —*
|
||||
*BigMind Session: `00070c37-b013-4342-a8ae-f81da0e3180d`*
|
||||
*Status: 🔵 DRAFT — awaiting Patrick review*
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem Statement
|
||||
|
||||
The current [`generate_image()`](mcp/mcp-image-gen/src/server.py:133) tool generates a single image and saves it with an auto-generated filename of `{timestamp}_{seed}.png`. Two common workflows are not yet supported:
|
||||
|
||||
1. **Named outputs** — When generating thematic sets (Lumen profile images, wiki banners, concept art), the caller wants a meaningful prefix in the filename (e.g., `lumen_profile_20260406_140236_2409122067.png`) rather than a bare timestamp. This also enables grouping output by purpose in the directory listing.
|
||||
|
||||
2. **Batch generation** — Generating multiple variations of the same prompt in one tool call is a common creative workflow. Currently, the caller must invoke `generate_image` N times with separate tool calls, which is verbose and loses the semantic grouping.
|
||||
|
||||
**Goal:** Add two optional parameters — `name` (filename prefix string) and `count` (integer repetitions) — to `generate_image` with minimal disruption to existing behaviour and test coverage.
|
||||
|
||||
---
|
||||
|
||||
## 2. Requirements
|
||||
|
||||
### 2.1 Functional Requirements
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| F-1 | `name` parameter (default `""`) prepends a sanitized label to the output filename |
|
||||
| F-2 | When `name=""` (default), filename format is unchanged: `{timestamp}_{seed}.png` |
|
||||
| F-3 | When `name="lumen_profile"`, filename format is: `lumen_profile_{timestamp}_{seed}.png` |
|
||||
| F-4 | `count` parameter (default `1`) generates N images sequentially |
|
||||
| F-5 | When `count=1` (default), return value is identical to the current `[TextContent, ImageContent]` |
|
||||
| F-6 | When `count=N > 1`, return value is a flat list: `[Text1, Image1, Text2, Image2, ..., TextN, ImageN]` |
|
||||
| F-7 | When `count>1` and `seed=-1`, each image gets an independently random seed |
|
||||
| F-8 | When `count>1` and a fixed `seed` is provided, images use `seed`, `seed+1`, `seed+2`, … to produce deterministic variation |
|
||||
| F-9 | `count` is capped at a maximum (proposed: 10) to prevent runaway generation |
|
||||
| F-10 | `name` is sanitized: non-alphanumeric characters (except `-` and `_`) are stripped/replaced; max 64 chars |
|
||||
| F-11 | Partial success: if one image in a batch fails, the error is returned as a `TextContent` error item in that position rather than aborting the whole batch |
|
||||
| F-12 | The TextContent for each image in a batch includes the 1-of-N index: `[1/3] Generated: ...` |
|
||||
|
||||
### 2.2 Non-Functional Requirements
|
||||
|
||||
| ID | Requirement |
|
||||
|----|-------------|
|
||||
| NF-1 | Sequential generation — no concurrent ComfyUI submissions (ComfyUI queues internally; parallel MCP submissions would complicate polling) |
|
||||
| NF-2 | Backward compatibility — all existing callers with no `name`/`count` args produce identical output |
|
||||
| NF-3 | All existing 19 tests must continue to pass without modification |
|
||||
| NF-4 | New tests must cover: name prefix in filename, count=2 success, count with fixed seed increments, count with partial failure, name sanitization, count cap enforcement |
|
||||
| NF-5 | MCP tool schema (visible in Claude/Roo Code) must surface clear descriptions for the new params |
|
||||
|
||||
---
|
||||
|
||||
## 3. Affected Files
|
||||
|
||||
| File | Change Type | Description |
|
||||
|------|-------------|-------------|
|
||||
| [`mcp/mcp-image-gen/src/server.py`](mcp/mcp-image-gen/src/server.py:133) | Modify | Add `name: str = ""` and `count: int = 1` params to `generate_image()`; add `_sanitize_name()` helper; extract `_generate_single()` inner logic |
|
||||
| [`mcp/mcp-image-gen/tests/test_server.py`](mcp/mcp-image-gen/tests/test_server.py:1) | Modify | Add 6+ new test cases covering new parameters |
|
||||
| [`mcp/mcp-image-gen/README.md`](mcp/mcp-image-gen/README.md) | Modify | Update `generate_image` tool documentation table |
|
||||
| [`docs/wiki/pages/mcp-image-gen.md`](docs/wiki/pages/mcp-image-gen.md) | Modify | Update tool reference table with new parameters |
|
||||
|
||||
No schema changes, no new dependencies, no workflow JSON changes.
|
||||
|
||||
---
|
||||
|
||||
## 4. Design Decisions
|
||||
|
||||
### 4.1 Filename Convention with `name`
|
||||
|
||||
**Current:** `{timestamp}_{seed}.png`
|
||||
**Proposed:** `{sanitized_name}_{timestamp}_{seed}.png` (when `name` is provided)
|
||||
|
||||
The `name` is placed as a **prefix** rather than suffix so directory `ls` output groups named sets together alphabetically:
|
||||
```
|
||||
lumen_profile_20260406_140236_2409122067.png
|
||||
lumen_profile_20260406_140258_764633840.png
|
||||
wiki_banner_20260406_141000_1234567.png
|
||||
```
|
||||
|
||||
**Sanitization rule:** `re.sub(r'[^a-zA-Z0-9_-]', '_', name)[:64]` — replaces any character that is not alphanumeric, dash, or underscore with `_`, then truncates to 64 chars.
|
||||
|
||||
### 4.2 Seed Behaviour for Batch Generation
|
||||
|
||||
| Scenario | Behaviour |
|
||||
|----------|-----------|
|
||||
| `count=3, seed=-1` | Each call to `build_flux_workflow` gets `seed=-1` → 3 independent random seeds |
|
||||
| `count=3, seed=42` | Seeds are 42, 43, 44 — deterministic, reproducible variation |
|
||||
|
||||
This follows the convention of most image generation tools (e.g., ComfyUI's own batch seed increment).
|
||||
|
||||
### 4.3 Return Structure for `count > 1`
|
||||
|
||||
Return a **flat interleaved list**: `[Text1, Image1, Text2, Image2]`
|
||||
|
||||
**Rationale:** MCP content lists are flat arrays. Claude/Roo Code renders them sequentially — a flat list means each image appears immediately below its metadata line. A nested structure would require the caller to unwrap it.
|
||||
|
||||
**For `count=1` (default):** Behaviour is identical to today — `[TextContent, ImageContent]`. No caller breakage.
|
||||
|
||||
### 4.4 Refactoring: Extract `_generate_single()`
|
||||
|
||||
The current `generate_image` function is 180+ lines of inline logic. To support `count`, the inner pipeline (queue → poll → history → download → save → encode) will be extracted to a private `async def _generate_single(prompt, ..., index, total)` coroutine. `generate_image` then loops `count` times calling `_generate_single` and accumulates results.
|
||||
|
||||
This refactoring:
|
||||
- Makes the count loop clean (`results.extend(await _generate_single(...))`)
|
||||
- Makes partial failure handling straightforward (catch per iteration)
|
||||
- Improves testability of the single-image path
|
||||
|
||||
### 4.5 Maximum Count Cap
|
||||
|
||||
Cap `count` at **10**. Rationale:
|
||||
- FLUX.1-schnell takes ~10–35s per image on RX 7900 XTX → 10 images ≈ 100–350s maximum
|
||||
- MCP tool call timeout in Roo Code defaults to 5 minutes — 10 images is safe margin
|
||||
- ComfyUI queues them internally; the MCP server polls sequentially, not in parallel
|
||||
|
||||
When `count > 10`, the tool returns a single `TextContent` error immediately (no images generated) with message: `"count={N} exceeds maximum of 10. Reduce count and retry."`
|
||||
|
||||
---
|
||||
|
||||
## 5. Implementation Plan
|
||||
|
||||
### Step 1 — Add `_sanitize_name()` helper
|
||||
|
||||
```python
|
||||
import re
|
||||
|
||||
def _sanitize_name(name: str) -> str:
|
||||
"""Sanitize a name for use as a filename prefix."""
|
||||
sanitized = re.sub(r'[^a-zA-Z0-9_-]', '_', name)
|
||||
return sanitized[:64]
|
||||
```
|
||||
|
||||
Location: [`server.py`](mcp/mcp-image-gen/src/server.py:95), after `build_flux_workflow()` (pure function section).
|
||||
|
||||
### Step 2 — Extract `_generate_single()` coroutine
|
||||
|
||||
Extract the body of the current `generate_image` (lines 162–310) into:
|
||||
|
||||
```python
|
||||
async def _generate_single(
|
||||
prompt: str,
|
||||
width: int,
|
||||
height: int,
|
||||
steps: int,
|
||||
model: str,
|
||||
seed: int,
|
||||
negative_prompt: str,
|
||||
resolved_output_dir: Path,
|
||||
filename_prefix: str,
|
||||
index: int,
|
||||
total: int,
|
||||
) -> list:
|
||||
```
|
||||
|
||||
The `filename` construction changes to:
|
||||
```python
|
||||
filename = f"{filename_prefix}{timestamp}_{actual_seed}.png"
|
||||
# where filename_prefix = f"{sanitized_name}_" if sanitized_name else ""
|
||||
```
|
||||
|
||||
The `TextContent` text changes when `total > 1`:
|
||||
```python
|
||||
prefix_label = f"[{index}/{total}] " if total > 1 else ""
|
||||
text = f"{prefix_label}Generated: {out_path}\nSeed: ..."
|
||||
```
|
||||
|
||||
### Step 3 — Update `generate_image()` signature
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def generate_image(
|
||||
prompt: str,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
steps: int = 4,
|
||||
model: str = "flux1-schnell.safetensors",
|
||||
seed: int = -1,
|
||||
negative_prompt: str = "",
|
||||
output_dir: str = "",
|
||||
name: str = "",
|
||||
count: int = 1,
|
||||
) -> list:
|
||||
```
|
||||
|
||||
Body of `generate_image` becomes:
|
||||
|
||||
```python
|
||||
# Validate count
|
||||
MAX_COUNT = 10
|
||||
if count < 1 or count > MAX_COUNT:
|
||||
return [TextContent(type="text", text=f"count={count} is invalid. Must be 1–{MAX_COUNT}.")]
|
||||
|
||||
sanitized_name = _sanitize_name(name) if name else ""
|
||||
filename_prefix = f"{sanitized_name}_" if sanitized_name else ""
|
||||
resolved_output_dir = Path(output_dir or IMAGE_OUTPUT_DIR).expanduser().resolve()
|
||||
|
||||
results = []
|
||||
for i in range(1, count + 1):
|
||||
actual_seed = seed if seed == -1 else seed + (i - 1)
|
||||
items = await _generate_single(
|
||||
prompt=prompt, width=width, height=height, steps=steps,
|
||||
model=model, seed=actual_seed, negative_prompt=negative_prompt,
|
||||
resolved_output_dir=resolved_output_dir,
|
||||
filename_prefix=filename_prefix, index=i, total=count,
|
||||
)
|
||||
results.extend(items)
|
||||
|
||||
return results
|
||||
```
|
||||
|
||||
### Step 4 — Write new tests
|
||||
|
||||
Add to [`test_server.py`](mcp/mcp-image-gen/tests/test_server.py:550):
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_generate_image_with_name` | `name="lumen"` → filename starts with `lumen_` |
|
||||
| `test_generate_image_name_sanitization` | `name="my image! v2"` → `my_image__v2_` prefix |
|
||||
| `test_generate_image_count_2_success` | `count=2` → 4 items in result, 2 files saved |
|
||||
| `test_generate_image_count_fixed_seed` | `count=2, seed=42` → seeds 42 and 43 in filenames |
|
||||
| `test_generate_image_count_partial_failure` | `count=2`, second POST fails → 2 items (success) + 1 item (error) |
|
||||
| `test_generate_image_count_cap_exceeded` | `count=11` → single TextContent error, no generation |
|
||||
| `test_generate_image_count_0_invalid` | `count=0` → single TextContent error |
|
||||
| `test_generate_image_name_and_count_combined` | `name="banner", count=2` → both files prefixed `banner_` |
|
||||
|
||||
### Step 5 — Update documentation
|
||||
|
||||
- Update `generate_image` docstring in [`server.py`](mcp/mcp-image-gen/src/server.py:144) to document `name` and `count`
|
||||
- Update parameter table in [`README.md`](mcp/mcp-image-gen/README.md)
|
||||
- Update tool reference in [`docs/wiki/pages/mcp-image-gen.md`](docs/wiki/pages/mcp-image-gen.md)
|
||||
|
||||
### Step 6 — Run full test suite
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen && uv run pytest tests/ -v --tb=short
|
||||
```
|
||||
|
||||
All 19 existing + 8 new = **27 tests** must pass.
|
||||
|
||||
### Step 7 — Commit and push
|
||||
|
||||
Branch: `feat/mcp-image-gen/generate-image-name-count`
|
||||
Commit: `feat(mcp-image-gen): add name and count params to generate_image`
|
||||
|
||||
---
|
||||
|
||||
## 6. Risks
|
||||
|
||||
| Risk | Likelihood | Impact | Mitigation |
|
||||
|------|------------|--------|------------|
|
||||
| Partial batch failure leaves orphaned files on disk | Medium | Low | Files for successful images are kept; error TextContent clearly identifies which index failed. No cleanup needed — partial results are useful. |
|
||||
| `count` loop adds significant latency visible in Roo Code | Medium | Medium | Document expected time: `count × ~15s`. MCP timeout is 5 min; max 10 images ≈ 150s. Still within limit. |
|
||||
| Seed increment wraps around at `2^32` | Very Low | Low | `(seed + i - 1) % 2**32` — add modulo guard in `_generate_single` |
|
||||
| `_generate_single` refactor introduces regression in existing tests | Low | High | Existing test fixtures mock ComfyUI endpoints — as long as the HTTP call sequence is unchanged, respx mocks will match. Verify each existing test still passes before adding new ones. |
|
||||
| `name` with only special chars becomes empty after sanitization | Low | Medium | After sanitization, if result is empty string, treat as unnamed (no prefix). Add assertion in `_sanitize_name` to return `""` for all-whitespace/special inputs. |
|
||||
| MCP tool schema change breaks existing callers | Very Low | Low | New params are optional with defaults — backward compatible. Roo Code re-reads schema on server restart. |
|
||||
|
||||
---
|
||||
|
||||
## 7. Alternatives Considered
|
||||
|
||||
### 7.1 Separate `generate_images_batch()` Tool (Rejected)
|
||||
|
||||
Add a new tool instead of expanding `generate_image`.
|
||||
|
||||
**Pros:** Clean separation, no refactoring of existing tool.
|
||||
**Cons:** Two tools for the same backend; callers must learn two tool names; MCP tool list grows. The MCP convention favours extending existing tools with optional parameters rather than proliferating tools.
|
||||
|
||||
**Verdict:** Rejected. Optional parameters with backward-compatible defaults is the right pattern here.
|
||||
|
||||
### 7.2 Return Grouped List of Lists for `count > 1` (Rejected)
|
||||
|
||||
Return `[[Text1, Image1], [Text2, Image2]]` for batch results.
|
||||
|
||||
**Pros:** Caller can index by image number cleanly.
|
||||
**Cons:** MCP content type is a flat `list[ContentBlock]`. FastMCP does not support nested lists in tool returns — they would be serialized as strings, not rendered. Roo Code renders content sequentially; flat interleaved is the idiomatic structure.
|
||||
|
||||
**Verdict:** Rejected. Flat interleaved list `[Text1, Image1, Text2, Image2]` is MCP-idiomatic.
|
||||
|
||||
### 7.3 Parallel ComfyUI Submission for Batch (Rejected)
|
||||
|
||||
Submit all `count` prompts to ComfyUI simultaneously (async tasks), then collect results in order.
|
||||
|
||||
**Pros:** Faster if ComfyUI supports parallel queue processing (it does).
|
||||
**Cons:** ComfyUI processes one job at a time on a single GPU regardless — parallel submission just fills the queue. Polling becomes complex (N polling loops). Error handling harder. Out-of-order completions break index alignment.
|
||||
|
||||
**Verdict:** Rejected for v1. Sequential submission is simpler, correct, and produces no worse throughput. Can revisit if ComfyUI gains true parallel processing support.
|
||||
|
||||
### 7.4 Name as Subdirectory Instead of Filename Prefix (Rejected)
|
||||
|
||||
When `name="lumen"`, save to `output_dir/lumen/` instead of `output_dir/lumen_*.png`.
|
||||
|
||||
**Pros:** Better directory organisation for large sets.
|
||||
**Cons:** Complicates the implementation (directory creation per name), changes the return path format, breaks callers who assume a flat output directory. Adds complexity for minimal gain at `count ≤ 10`.
|
||||
|
||||
**Verdict:** Rejected for v1. Prefix approach is simpler and equally readable.
|
||||
|
||||
---
|
||||
|
||||
## 8. Success Criteria
|
||||
|
||||
| Criterion | Measure |
|
||||
|-----------|---------|
|
||||
| All 27 tests pass | `uv run pytest tests/ -v` exits 0 |
|
||||
| `name="lumen"` → file starts with `lumen_` | Assert in `test_generate_image_with_name` |
|
||||
| `count=2` → 4 content items, 2 files | Assert `len(result) == 4`, `len(glob("*.png")) == 2` |
|
||||
| `count=2, seed=42` → seeds 42 and 43 | Assert seed values in TextContent |
|
||||
| `count=11` → error TextContent, no ComfyUI call | Assert `len(result) == 1`, no `/api/prompt` mock hit |
|
||||
| Backward compat: existing callers unaffected | All 19 existing tests pass without modification |
|
||||
| MCP tool schema shows `name` and `count` params | Visible in Roo Code tool list after server restart |
|
||||
|
||||
---
|
||||
|
||||
## 9. Open Questions
|
||||
|
||||
| # | Question | Owner | Priority |
|
||||
|---|----------|-------|----------|
|
||||
| Q1 | Should `count=0` be an error, or silently return `[]` (empty list)? | Patrick | Low — assessment recommends error for clarity |
|
||||
| Q2 | Max count cap: 10 or higher? 10 ≈ 150s max at 15s/image — feels right, but could be raised to 20 for batch profile image sets. | Patrick | Medium |
|
||||
| Q3 | Should partial batch failure stop remaining iterations, or always complete all N? | Patrick | Medium — assessment recommends continue (partial success) |
|
||||
| Q4 | Should `name` parameter also tag the TextContent output text, e.g. `[lumen_profile 1/3] Generated: ...`? | Patrick | Low |
|
||||
@@ -0,0 +1,496 @@
|
||||
# mcp-image-gen — Implementation Plan
|
||||
|
||||
**Date:** 2026-04-04
|
||||
**Author:** Lumen (for Patrick / pplate)
|
||||
**Status:** Ready for implementation
|
||||
**Assessment:** [ASSESSMENT.md](./ASSESSMENT.md)
|
||||
**Research Session:** `39809470-6ac8-4713-adf2-79ac0eb36ba7`
|
||||
|
||||
---
|
||||
|
||||
## 1. Directory Structure
|
||||
|
||||
```
|
||||
mcp/mcp-image-gen/
|
||||
├── ASSESSMENT.md ← Architecture assessment (this session)
|
||||
├── PLAN.md ← This file
|
||||
├── README.md ← Usage docs, tool table, env vars
|
||||
├── pyproject.toml ← uv project + deps
|
||||
├── run.sh ← Launch script (used by .roo/mcp.json)
|
||||
├── src/
|
||||
│ ├── __init__.py
|
||||
│ ├── server.py ← FastMCP server + all tools
|
||||
│ └── workflows/
|
||||
│ └── flux_schnell.json ← Minimal ComfyUI API-format workflow
|
||||
└── tests/
|
||||
├── __init__.py
|
||||
├── conftest.py ← sys.path + shared fixtures
|
||||
└── test_server.py ← All tool tests (mocked ComfyUI)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Tool Definitions
|
||||
|
||||
### 2.1 `generate_image`
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def generate_image(
|
||||
prompt: str,
|
||||
width: int = 1024,
|
||||
height: int = 1024,
|
||||
steps: int = 4,
|
||||
model: str = "flux1-schnell.safetensors",
|
||||
seed: int = -1,
|
||||
negative_prompt: str = "",
|
||||
output_dir: str = "",
|
||||
) -> list:
|
||||
"""
|
||||
Generate an image from a text prompt using ComfyUI.
|
||||
|
||||
Returns both a file path (for persistence) and an inline base64 image
|
||||
(for display in Claude / Roo Code chat).
|
||||
|
||||
Args:
|
||||
prompt: Text description of the image to generate.
|
||||
width: Image width in pixels (default: 1024).
|
||||
height: Image height in pixels (default: 1024).
|
||||
steps: Number of inference steps. FLUX.1-schnell works well at 4.
|
||||
model: ComfyUI model filename (default: flux1-schnell.safetensors).
|
||||
seed: Random seed for reproducibility. -1 = random.
|
||||
negative_prompt: Things to exclude from the image (optional).
|
||||
output_dir: Override output directory. Defaults to IMAGE_OUTPUT_DIR env var
|
||||
or ~/Pictures/mcp-generated.
|
||||
|
||||
Returns:
|
||||
[TextContent(path + metadata), ImageContent(base64 PNG)]
|
||||
"""
|
||||
```
|
||||
|
||||
**Return type:** `list` containing:
|
||||
1. `mcp.types.TextContent` — human-readable summary with file path, seed, elapsed time
|
||||
2. `mcp.types.ImageContent` — `type="image"`, `data=base64_encoded_png`, `mimeType="image/png"`
|
||||
|
||||
> ⚠️ **FastMCP 3.x rule:** NEVER annotate return as `-> Image` (fastmcp utility type). It triggers
|
||||
> `output_schema` generation which breaks the early-return path. Return `mcp.types.ImageContent`
|
||||
> directly as part of a `list` — it is a `ContentBlock` and passes through cleanly.
|
||||
|
||||
---
|
||||
|
||||
### 2.2 `list_available_models`
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def list_available_models() -> str:
|
||||
"""
|
||||
List all checkpoint models available in ComfyUI.
|
||||
|
||||
Returns a newline-separated list of model filenames.
|
||||
Requires ComfyUI to be running at COMFYUI_URL.
|
||||
"""
|
||||
```
|
||||
|
||||
**Implementation:** `GET {COMFYUI_URL}/object_info/CheckpointLoaderSimple` → parse
|
||||
`input.required.ckpt_name[0]` list → join with newlines.
|
||||
|
||||
---
|
||||
|
||||
### 2.3 `get_generation_status`
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
async def get_generation_status(prompt_id: str) -> str:
|
||||
"""
|
||||
Check the status of a queued or running generation job.
|
||||
|
||||
Args:
|
||||
prompt_id: The prompt ID returned by a previous generate_image call.
|
||||
|
||||
Returns:
|
||||
Status string: "pending", "running", "completed", or "not_found".
|
||||
"""
|
||||
```
|
||||
|
||||
**Implementation:** `GET {COMFYUI_URL}/api/queue` → check `queue_running` and `queue_pending`
|
||||
lists for matching `prompt_id`. If not found in either, check history endpoint.
|
||||
|
||||
---
|
||||
|
||||
### 2.4 `get_output_directory`
|
||||
|
||||
```python
|
||||
@mcp.tool()
|
||||
def get_output_directory() -> str:
|
||||
"""
|
||||
Return the directory where generated images are saved.
|
||||
|
||||
Returns:
|
||||
Absolute path to the output directory.
|
||||
"""
|
||||
```
|
||||
|
||||
**Implementation:** Resolve `IMAGE_OUTPUT_DIR` env var or default `~/Pictures/mcp-generated`,
|
||||
expand `~`, return as string.
|
||||
|
||||
---
|
||||
|
||||
## 3. ComfyUI Integration
|
||||
|
||||
### 3.1 Workflow: Submit → Poll → Retrieve
|
||||
|
||||
```
|
||||
generate_image()
|
||||
│
|
||||
├── 1. Load flux_schnell.json workflow template
|
||||
├── 2. Parameterize: inject prompt, width, height, steps, seed, model
|
||||
├── 3. POST {COMFYUI_URL}/api/prompt → {"prompt_id": "uuid"}
|
||||
│
|
||||
├── 4. POLL loop (max 120s, sleep 2s between)
|
||||
│ GET {COMFYUI_URL}/api/queue
|
||||
│ → check queue_running[].prompt_id == our id
|
||||
│ → check queue_pending[].prompt_id == our id
|
||||
│ → if neither: job is done
|
||||
│
|
||||
├── 5. GET {COMFYUI_URL}/api/history/{prompt_id}
|
||||
│ → find output image filename + subfolder
|
||||
│
|
||||
├── 6. GET {COMFYUI_URL}/api/view?filename={name}&subfolder={subfolder}&type=output
|
||||
│ → raw PNG bytes
|
||||
│
|
||||
├── 7. Save PNG to output_dir/{timestamp}_{seed}.png
|
||||
└── 8. Return [TextContent(path + meta), ImageContent(base64)]
|
||||
```
|
||||
|
||||
### 3.2 API Endpoints Used
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/api/prompt` | POST | Submit workflow for generation |
|
||||
| `/api/queue` | GET | Poll queue status (pending + running) |
|
||||
| `/api/history/{prompt_id}` | GET | Get completed job output filenames |
|
||||
| `/api/view` | GET | Download image bytes by filename |
|
||||
| `/object_info/CheckpointLoaderSimple` | GET | List available checkpoint models |
|
||||
|
||||
### 3.3 Error Handling
|
||||
|
||||
| Condition | Response |
|
||||
|-----------|----------|
|
||||
| ComfyUI unreachable | `"ComfyUI not reachable at {url}. Start it with: python main.py --listen"` |
|
||||
| Timeout (>120s) | `"Generation timed out after 120s. prompt_id={id} — use get_generation_status to check"` |
|
||||
| ComfyUI returns error in history | Extract and return the error message from history response |
|
||||
| Invalid model name | ComfyUI returns error in history; surface it clearly |
|
||||
| Output dir not writable | `"Cannot write to output directory: {path}"` |
|
||||
|
||||
---
|
||||
|
||||
## 4. Configuration
|
||||
|
||||
All configuration via environment variables. No hardcoded paths.
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `COMFYUI_URL` | `http://localhost:8188` | Base URL of running ComfyUI instance |
|
||||
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Where to save generated PNG files |
|
||||
| `COMFYUI_TIMEOUT` | `120` | Max seconds to wait for generation (int) |
|
||||
|
||||
### `.roo/mcp.json` entry (to be added during implementation):
|
||||
|
||||
```json
|
||||
"mcp-image-gen": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen",
|
||||
"run", "src/server.py"
|
||||
],
|
||||
"env": {
|
||||
"COMFYUI_URL": "http://localhost:8188",
|
||||
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. `pyproject.toml`
|
||||
|
||||
```toml
|
||||
[project]
|
||||
name = "mcp-image-gen"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
description = "MCP server for local AI image generation via ComfyUI"
|
||||
dependencies = [
|
||||
"fastmcp>=0.1.0",
|
||||
"httpx>=0.27.0",
|
||||
"pillow>=10.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
"pytest>=7.0",
|
||||
"pytest-mock>=3.0",
|
||||
"pytest-cov>=4.0",
|
||||
"pytest-asyncio>=0.23",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
```
|
||||
|
||||
**Dependency rationale:**
|
||||
- `fastmcp` — MCP framework
|
||||
- `httpx` — async HTTP client for ComfyUI REST API
|
||||
- `pillow` — validate PNG output, potential future thumbnail generation
|
||||
- `pytest-asyncio` — needed for async tool tests
|
||||
|
||||
---
|
||||
|
||||
## 6. FLUX.1-schnell Workflow JSON
|
||||
|
||||
The minimal ComfyUI API-format workflow for FLUX.1-schnell text-to-image.
|
||||
This is the "API format" (node-graph JSON), not the UI export format.
|
||||
|
||||
File: `src/workflows/flux_schnell.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"6": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["30", 1],
|
||||
"text": "PROMPT_PLACEHOLDER"
|
||||
}
|
||||
},
|
||||
"8": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["13", 0],
|
||||
"vae": ["30", 2]
|
||||
}
|
||||
},
|
||||
"9": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"filename_prefix": "mcp-image-gen",
|
||||
"images": ["8", 0]
|
||||
}
|
||||
},
|
||||
"13": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"cfg": 1.0,
|
||||
"denoise": 1.0,
|
||||
"latent_image": ["27", 0],
|
||||
"model": ["30", 0],
|
||||
"negative": ["33", 0],
|
||||
"positive": ["6", 0],
|
||||
"sampler_name": "euler",
|
||||
"scheduler": "simple",
|
||||
"seed": 42,
|
||||
"steps": 4
|
||||
}
|
||||
},
|
||||
"27": {
|
||||
"class_type": "EmptySD3LatentImage",
|
||||
"inputs": {
|
||||
"batch_size": 1,
|
||||
"height": 1024,
|
||||
"width": 1024
|
||||
}
|
||||
},
|
||||
"30": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {
|
||||
"ckpt_name": "flux1-schnell.safetensors"
|
||||
}
|
||||
},
|
||||
"33": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"inputs": {
|
||||
"clip": ["30", 1],
|
||||
"text": "NEGATIVE_PLACEHOLDER"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Parameterization at runtime** (in `server.py`):
|
||||
|
||||
```python
|
||||
import json, copy
|
||||
|
||||
def _build_workflow(prompt, negative_prompt, width, height, steps, seed, model):
|
||||
with open(Path(__file__).parent / "workflows/flux_schnell.json") as f:
|
||||
wf = json.load(f)
|
||||
wf = copy.deepcopy(wf)
|
||||
wf["6"]["inputs"]["text"] = prompt
|
||||
wf["33"]["inputs"]["text"] = negative_prompt
|
||||
wf["27"]["inputs"]["width"] = width
|
||||
wf["27"]["inputs"]["height"] = height
|
||||
wf["13"]["inputs"]["steps"] = steps
|
||||
wf["13"]["inputs"]["seed"] = seed if seed != -1 else random.randint(0, 2**32 - 1)
|
||||
wf["30"]["inputs"]["ckpt_name"] = model
|
||||
return wf
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Testing Strategy
|
||||
|
||||
### 7.1 Test Structure (`tests/test_server.py`)
|
||||
|
||||
All tests mock `httpx.AsyncClient` — no real ComfyUI needed.
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| `test_generate_image_happy_path` | Mock submit → poll done → history → view → returns TextContent + ImageContent |
|
||||
| `test_generate_image_comfyui_offline` | httpx.ConnectError → returns clear error string |
|
||||
| `test_generate_image_timeout` | Poll loop exceeds COMFYUI_TIMEOUT → returns timeout message with prompt_id |
|
||||
| `test_generate_image_saves_file` | Verify PNG written to output_dir with correct filename pattern |
|
||||
| `test_generate_image_random_seed` | seed=-1 → seed in output filename is a valid integer |
|
||||
| `test_generate_image_custom_params` | Non-default width/height/steps/model passed through to workflow |
|
||||
| `test_generate_image_returns_image_content` | Second item in result list is `mcp.types.ImageContent` with valid base64 |
|
||||
| `test_list_available_models_happy_path` | Mock object_info response → returns model name list |
|
||||
| `test_list_available_models_offline` | ConnectError → returns error string |
|
||||
| `test_get_generation_status_pending` | prompt_id found in queue_pending → "pending" |
|
||||
| `test_get_generation_status_running` | prompt_id found in queue_running → "running" |
|
||||
| `test_get_generation_status_not_found` | prompt_id not in queue, not in history → "not_found" |
|
||||
| `test_get_output_directory_default` | No env var → returns expanded ~/Pictures/mcp-generated |
|
||||
| `test_get_output_directory_custom` | IMAGE_OUTPUT_DIR set → returns that path |
|
||||
| `test_build_workflow_parameterization` | _build_workflow() injects all params correctly into JSON |
|
||||
|
||||
### 7.2 conftest.py fixtures
|
||||
|
||||
```python
|
||||
import sys
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
|
||||
@pytest.fixture
|
||||
def mock_comfyui_submit_response():
|
||||
return {"prompt_id": "test-uuid-1234"}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_comfyui_queue_empty():
|
||||
return {"queue_running": [], "queue_pending": []}
|
||||
|
||||
@pytest.fixture
|
||||
def mock_comfyui_history():
|
||||
return {
|
||||
"test-uuid-1234": {
|
||||
"outputs": {
|
||||
"9": {
|
||||
"images": [{"filename": "mcp-image-gen_00001_.png", "subfolder": "", "type": "output"}]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def sample_png_bytes():
|
||||
"""Minimal valid 1x1 PNG in bytes."""
|
||||
import base64
|
||||
# 1x1 red pixel PNG
|
||||
data = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwADhQGAWjR9awAAAABJRU5ErkJggg=="
|
||||
return base64.b64decode(data)
|
||||
```
|
||||
|
||||
### 7.3 Run command
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen && uv run pytest tests/ -v --cov=src --cov-report=term-missing
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. `run.sh`
|
||||
|
||||
```bash
|
||||
#!/usr/bin/env bash
|
||||
BASEDIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
export PATH="$HOME/.local/bin:$PATH"
|
||||
|
||||
# Create output dir if it doesn't exist
|
||||
OUTPUT_DIR="${IMAGE_OUTPUT_DIR:-$HOME/Pictures/mcp-generated}"
|
||||
mkdir -p "$OUTPUT_DIR"
|
||||
|
||||
cd "$BASEDIR"
|
||||
exec uv run src/server.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Future: Ollama Migration Path
|
||||
|
||||
When Ollama adds Linux image generation support (ETA unknown, announced "coming soon" April 2026):
|
||||
|
||||
### Adapter pattern (no breaking changes to MCP tool signatures)
|
||||
|
||||
```python
|
||||
BACKEND = os.getenv("IMAGE_BACKEND", "comfyui") # or "ollama"
|
||||
|
||||
async def _generate_comfyui(prompt, width, height, steps, model, seed, negative_prompt, output_dir):
|
||||
# current ComfyUI implementation
|
||||
...
|
||||
|
||||
async def _generate_ollama(prompt, width, height, steps, model, seed, negative_prompt, output_dir):
|
||||
# POST http://localhost:11434/api/generate
|
||||
# with model=Z-Image-Turbo or FLUX.2-Klein
|
||||
# width, height, steps in request body
|
||||
# save returned image path
|
||||
...
|
||||
|
||||
@mcp.tool()
|
||||
async def generate_image(prompt, width=1024, height=1024, steps=4, ...):
|
||||
if BACKEND == "ollama":
|
||||
return await _generate_ollama(...)
|
||||
return await _generate_comfyui(...)
|
||||
```
|
||||
|
||||
**No changes to:** tool signatures, return types, env vars (add `IMAGE_BACKEND`), tests structure.
|
||||
|
||||
---
|
||||
|
||||
## 10. Implementation Order (for Code mode)
|
||||
|
||||
1. `src/workflows/flux_schnell.json` — write and validate JSON structure
|
||||
2. `pyproject.toml` — set up project + deps
|
||||
3. `src/__init__.py` — empty
|
||||
4. `src/server.py` — implement all 4 tools + `_build_workflow` + polling helpers
|
||||
5. `tests/conftest.py` — fixtures + sys.path
|
||||
6. `tests/test_server.py` — all 15 tests
|
||||
7. `run.sh` — launch script
|
||||
8. `README.md` — usage docs
|
||||
9. `.roo/mcp.json` — wire server in (requires switching to Code or Homelab mode for that file)
|
||||
10. `uv sync && uv run pytest tests/ -v` — confirm all tests pass
|
||||
|
||||
---
|
||||
|
||||
## 11. ComfyUI Setup Notes (for README)
|
||||
|
||||
These are prerequisites for the MCP server to work. Patrick must have ComfyUI installed:
|
||||
|
||||
```bash
|
||||
# Install ComfyUI (ROCm/AMD)
|
||||
pip install comfyui
|
||||
|
||||
# Download FLUX.1-schnell model (~8GB)
|
||||
# Place in ComfyUI/models/checkpoints/flux1-schnell.safetensors
|
||||
# Source: https://huggingface.co/black-forest-labs/FLUX.1-schnell
|
||||
|
||||
# Start ComfyUI with AMD ROCm
|
||||
HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py --listen
|
||||
|
||||
# Verify API is running
|
||||
curl http://localhost:8188/system_stats
|
||||
```
|
||||
|
||||
> The `HSA_OVERRIDE_GFX_VERSION=11.0.0` env var may be needed for RX 7900 XTX (gfx1100)
|
||||
> to identify correctly to ROCm libraries.
|
||||
@@ -0,0 +1,182 @@
|
||||
# mcp-image-gen
|
||||
|
||||
**FastMCP server for AI image generation via ComfyUI.**
|
||||
|
||||
This MCP server wraps a locally running [ComfyUI](https://github.com/comfyanonymous/ComfyUI) instance, exposing image generation as MCP tools callable from Roo Code, Claude Desktop, or any MCP-compatible client.
|
||||
|
||||
**New:** Support for **FLUX.2 Klein 4B** with **Heretic-abliterated Qwen3-4B text encoder** (zero KL divergence, no refusals). Select via `model="flux-2-klein-4b-fp8.safetensors"`.
|
||||
|
||||
It supports FLUX.1-schnell (default), FLUX.2 Klein (Heretic), and any other ComfyUI-compatible checkpoint model. Generated images are saved to disk **and** returned as inline base64 so Claude can display them directly in chat.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **ComfyUI** installed and running at `http://localhost:8188`
|
||||
2. At least one checkpoint model downloaded (see ComfyUI Setup below)
|
||||
3. **Python 3.11+** and **uv** installed on the system
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen
|
||||
uv sync
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration is via environment variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
|---|---|---|
|
||||
| `COMFYUI_URL` | `http://localhost:8188` | Base URL of the running ComfyUI instance |
|
||||
| `IMAGE_OUTPUT_DIR` | `~/Pictures/mcp-generated` | Directory where generated PNG files are saved |
|
||||
| `COMFYUI_TIMEOUT` | `120` | Max seconds to wait for generation before timeout |
|
||||
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
### Add to `.roo/mcp.json` (Roo Code)
|
||||
|
||||
```json
|
||||
"mcp-image-gen": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen",
|
||||
"run", "src/server.py"
|
||||
],
|
||||
"env": {
|
||||
"COMFYUI_URL": "http://localhost:8188",
|
||||
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Add to Claude Desktop (`claude_desktop_config.json`)
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"mcp-image-gen": {
|
||||
"command": "uv",
|
||||
"args": [
|
||||
"--directory", "/home/pplate/pi_mcps/mcp/mcp-image-gen",
|
||||
"run", "src/server.py"
|
||||
],
|
||||
"env": {
|
||||
"COMFYUI_URL": "http://localhost:8188",
|
||||
"IMAGE_OUTPUT_DIR": "/home/pplate/Pictures/mcp-generated"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Run directly
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen
|
||||
./run.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Available Tools
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `generate_image` | Generate an image from a text prompt. Returns file path + inline base64 PNG. |
|
||||
| `list_available_models` | List all checkpoint models loaded in ComfyUI. |
|
||||
| `get_generation_status` | Check status of a running/queued generation by `prompt_id`. |
|
||||
| `get_output_directory` | Return the current output directory path. |
|
||||
|
||||
### `generate_image` parameters
|
||||
|
||||
| Parameter | Default | Description |
|
||||
|---|---|---|
|
||||
| `prompt` | *(required)* | Text description of the image |
|
||||
| `width` | `1024` | Image width in pixels |
|
||||
| `height` | `1024` | Image height in pixels |
|
||||
| `steps` | `4` | Inference steps (FLUX.1-schnell: 4 is optimal) |
|
||||
| `model` | `flux1-schnell.safetensors` | Checkpoint model filename |
|
||||
| `seed` | `-1` | Seed for reproducibility (`-1` = random) |
|
||||
| `negative_prompt` | `""` | Things to exclude from the image |
|
||||
| `output_dir` | *(IMAGE_OUTPUT_DIR)* | Override output directory |
|
||||
|
||||
---
|
||||
|
||||
## ComfyUI Setup (Fedora + AMD ROCm)
|
||||
|
||||
```bash
|
||||
# Install ComfyUI
|
||||
pip install comfyui
|
||||
|
||||
# Download FLUX.1-schnell model (~8GB, Apache 2.0)
|
||||
# Place in: ComfyUI/models/checkpoints/flux1-schnell.safetensors
|
||||
# Source: https://huggingface.co/black-forest-labs/FLUX.1-schnell
|
||||
|
||||
# Start ComfyUI with ROCm support for AMD RX 7900 XTX
|
||||
HSA_OVERRIDE_GFX_VERSION=11.0.0 python main.py --listen
|
||||
|
||||
# Verify the API is reachable
|
||||
curl http://localhost:8188/system_stats
|
||||
```
|
||||
|
||||
> **Note:** `HSA_OVERRIDE_GFX_VERSION=11.0.0` may be needed for the RX 7900 XTX (gfx1100)
|
||||
> to be recognized correctly by ROCm libraries.
|
||||
|
||||
### PyTorch with ROCm (if needed separately)
|
||||
|
||||
```bash
|
||||
pip install torch torchvision --index-url https://download.pytorch.org/whl/rocm6.1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
cd mcp/mcp-image-gen
|
||||
uv run pytest tests/ -v
|
||||
```
|
||||
|
||||
All tests mock the ComfyUI HTTP API — no running ComfyUI instance needed.
|
||||
|
||||
---
|
||||
|
||||
## Ollama Migration Path
|
||||
|
||||
When Ollama adds Linux image generation support (announced "coming soon" as of April 2026, currently macOS-only), this server can switch backends via a single env var:
|
||||
|
||||
```bash
|
||||
IMAGE_BACKEND=ollama # currently only "comfyui" is implemented
|
||||
```
|
||||
|
||||
The tool signatures, return types, and MCP interface will remain unchanged — only the underlying HTTP calls switch from ComfyUI to Ollama's `/api/generate` endpoint.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Roo Code / Claude Desktop
|
||||
│
|
||||
│ MCP (stdio)
|
||||
▼
|
||||
mcp-image-gen (FastMCP)
|
||||
│
|
||||
│ HTTP REST
|
||||
▼
|
||||
ComfyUI @ localhost:8188
|
||||
│
|
||||
│ ROCm / AMD GPU
|
||||
▼
|
||||
FLUX.1-schnell / SDXL / SD3.5
|
||||
```
|
||||
|
||||
The server submits a FLUX.1-schnell ComfyUI API-format workflow, polls until complete, downloads the PNG, saves it to disk, and returns both a text summary and a base64-encoded inline image.
|
||||