diff --git a/.claude/hooks/build-reminder.ps1 b/.claude/hooks/build-reminder.ps1 new file mode 100644 index 0000000..52d4a29 --- /dev/null +++ b/.claude/hooks/build-reminder.ps1 @@ -0,0 +1,14 @@ +# PostToolUse hook: Remind to verify build after source file changes +$jsonInput = [Console]::In.ReadToEnd() +if (-not $jsonInput) { exit 0 } + +$data = $jsonInput | ConvertFrom-Json + +$filePath = $data.tool_input.file_path +if (-not $filePath) { exit 0 } + +if ($filePath -match '\.(cs|csproj|razor)$') { + @{ additionalContext = [char]0x1F3D7 + [char]0xFE0F + " Source file modified. Remember to verify the build compiles (dotnet build)." } | ConvertTo-Json -Compress | Write-Output +} + +exit 0 diff --git a/.claude/hooks/context-optimizer.ps1 b/.claude/hooks/context-optimizer.ps1 new file mode 100644 index 0000000..bc487a5 --- /dev/null +++ b/.claude/hooks/context-optimizer.ps1 @@ -0,0 +1,6 @@ +# SessionStart hook: Provide project context to Claude +$context = "NexTruzt.io EscrowApp: .NET 10 Blazor Server fintech escrow. Clean Architecture + CQRS/MediatR. Layers: Components/ (UI) -> Features/ (handlers) -> Models/Events (domain) <- Data/ (EF Core/PostgreSQL). Payment strategies: IFundHoldable/IFundReleasable/IFundCancellable. Always: code-behind, scoped CSS, docs sync, OWASP security-first, idempotency keys." + +@{ additionalContext = $context } | ConvertTo-Json -Compress | Write-Output + +exit 0 diff --git a/.claude/hooks/doc-sync-reminder.ps1 b/.claude/hooks/doc-sync-reminder.ps1 new file mode 100644 index 0000000..04b6b56 --- /dev/null +++ b/.claude/hooks/doc-sync-reminder.ps1 @@ -0,0 +1,31 @@ +# PostToolUse hook: Remind to update documentation when key source files change +$jsonInput = [Console]::In.ReadToEnd() +if (-not $jsonInput) { exit 0 } + +$data = $jsonInput | ConvertFrom-Json + +$filePath = $data.tool_input.file_path +if (-not $filePath) { exit 0 } + +$docTriggerPaths = @( + 'Components/', 'Components\\' + 'Features/', 'Features\\' + 'Services/', 'Services\\' + 'Models/', 'Models\\' + 'Events/', 'Events\\' + 'Infrastructure/', 'Infrastructure\\' +) + +$needsDocSync = $false +foreach ($trigger in $docTriggerPaths) { + if ($filePath -like "*$trigger*") { + $needsDocSync = $true + break + } +} + +if ($needsDocSync) { + @{ additionalContext = [char]0x1F4DD + " Remember: update corresponding docs/ README.md to reflect these changes." } | ConvertTo-Json -Compress | Write-Output +} + +exit 0 diff --git a/.claude/hooks/dotnet-conventions.ps1 b/.claude/hooks/dotnet-conventions.ps1 new file mode 100644 index 0000000..e15e4ac --- /dev/null +++ b/.claude/hooks/dotnet-conventions.ps1 @@ -0,0 +1,58 @@ +# PostToolUse hook: Check .NET/Blazor coding conventions +$jsonInput = [Console]::In.ReadToEnd() +if (-not $jsonInput) { exit 0 } + +$data = $jsonInput | ConvertFrom-Json + +$filePath = $data.tool_input.file_path +if (-not $filePath) { exit 0 } + +$issues = @() + +if ($filePath -match '\.cs$') { + $content = $data.tool_input.content + if (-not $content) { $content = $data.tool_input.new_str } + if (-not $content) { $content = $data.tool_input.file_text } + if (-not $content) { exit 0 } + + # Check for block-scoped namespaces (should use file-scoped) + if ($content -match 'namespace\s+\S+\s*\{') { + $issues += "Use file-scoped namespace (no braces) instead of block-scoped namespace" + } + + # Check code-behind files missing partial keyword + if ($filePath -match '\.razor\.cs$' -and $content -match 'class\s+' -and $content -notmatch 'partial\s+class') { + $issues += "Code-behind class must be declared as 'partial'" + } + + # Check for missing nullable enable + if ($content -match 'namespace\s+' -and $content -notmatch '#nullable\s+enable' -and $content -notmatch 'enable') { + $issues += "Consider adding '#nullable enable' or verify it is set in .csproj" + } +} +elseif ($filePath -match '\.razor$') { + $content = $data.tool_input.content + if (-not $content) { $content = $data.tool_input.new_str } + if (-not $content) { $content = $data.tool_input.file_text } + if (-not $content) { exit 0 } + + # Check for inline @code blocks (should use code-behind) + if ($content -match '@code\s*\{') { + $issues += "Use code-behind (.razor.cs) instead of inline @code blocks" + } + + # Check for inline style attributes + if ($content -match 'style\s*=\s*"') { + $issues += "Use scoped CSS (.razor.css) instead of inline style attributes" + } +} +else { + exit 0 +} + +if ($issues.Count -gt 0) { + $message = "Convention issues: " + ($issues -join "; ") + ". Fix before continuing." + @{ additionalContext = $message } | ConvertTo-Json -Compress | Write-Output +} + +exit 0 diff --git a/.claude/hooks/notification.ps1 b/.claude/hooks/notification.ps1 new file mode 100644 index 0000000..78c3551 --- /dev/null +++ b/.claude/hooks/notification.ps1 @@ -0,0 +1,291 @@ +# ------------------------------------------------- +# Notification Hook – Multi-Channel Support +# ------------------------------------------------- +# Features: +# - Console & file logging always on +# - Slack, Teams webhooks +# - Multiple fallback email accounts with credential prompts +# - HTML email templates (configurable) +# - Rate limiting to avoid spam +# - All channels fail independently +# +# Usage: +# .\notification.ps1 # Normal run (prompts for email credentials if needed) +# .\notification.ps1 -SkipEmail # Skip email notifications entirely +# .\notification.ps1 -NoPrompt # Don't prompt for credentials (skip email if not stored) + +[CmdletBinding()] +param( + [switch]$SkipEmail, + [switch]$NoPrompt +) + +$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +$message = "Notification hook triggered at $timestamp" + +# Load configuration +$configPath = ".claude/hooks/notification-config.json" +$config = $null + +if (Test-Path $configPath) { + try { + $config = Get-Content $configPath -Raw | ConvertFrom-Json + } catch { + Write-Warning "Failed to parse config file. Using defaults." + } +} else { + Write-Warning "Config file not found. Creating default." + $defaultConfig = @{ + slack = @{ webhookUrl = ""; channel = "#claude-hooks"; enabled = $false } + email = @{ + accounts = @( + @{ + smtpServer = "smtp.gmail.com" + smtpPort = 587 + from = "" + to = "" + subjectPrefix = "[Claude Hook]" + useHtml = $true + enabled = $false + } + ) + } + teams = @{ webhookUrl = ""; enabled = $false } + console = @{ enabled = $true } + fileLog = @{ enabled = $true; path = ".claude/hooks/notifications.log" } + rateLimit = @{ enabled = $true; intervalSeconds = 30; lastNotificationFile = ".claude/hooks/.rate-limit-timestamp" } + } + $defaultConfig | ConvertTo-Json -Depth 3 | Set-Content $configPath + $config = $defaultConfig +} + +# ------------------------------------------------- +# Rate Limiting Check +# ------------------------------------------------- +$rateLimited = $false +if ($config.rateLimit.enabled) { + $lastTimePath = $config.rateLimit.lastNotificationFile + if (-not $lastTimePath) { $lastTimePath = ".claude/hooks/.rate-limit-timestamp" } + + if (Test-Path $lastTimePath) { + try { + $lastSent = Get-Content $lastTimePath -Raw | Get-Date + $interval = $config.rateLimit.intervalSeconds + if ($interval -lt 1) { $interval = 30 } + + if (((Get-Date) - $lastSent).TotalSeconds -lt $interval) { + $remaining = [math]::Ceiling($interval - ((Get-Date) - $lastSent).TotalSeconds) + Write-Host "Rate limit active. Wait $remaining seconds." -ForegroundColor Yellow + $rateLimited = $true + } + } catch { + # If timestamp exists but invalid, ignore and proceed + } + } +} + +# If rate-limited, skip all external notifications but still log +if ($rateLimited) { + if ($config.console.enabled) { + Write-Host "$message (rate limited)" -ForegroundColor Yellow + } + if ($config.fileLog.enabled) { + $logPath = $config.fileLog.path + if (-not $logPath) { $logPath = ".claude/hooks/notifications.log" } + "$timestamp`t$message (rate-limited)" | Add-Content -Path $logPath -Encoding UTF8 + } + return +} + +# Record successful notification time +if ($config.rateLimit.enabled) { + $lastTimePath = $config.rateLimit.lastNotificationFile + if (-not $lastTimePath) { $lastTimePath = ".claude/hooks/.rate-limit-timestamp" } + $timestamp | Set-Content -Path $lastTimePath -Encoding UTF8 +} + +# ------------------------------------------------- +# 1. Console notification +# ------------------------------------------------- +if ($config.console.enabled) { + Write-Host "[BELL] $message" -ForegroundColor Cyan +} + +# ------------------------------------------------- +# 2. File logging +# ------------------------------------------------- +if ($config.fileLog.enabled) { + $logPath = $config.fileLog.path + if (-not $logPath) { $logPath = ".claude/hooks/notifications.log" } + "$timestamp`t$message" | Add-Content -Path $logPath -Encoding UTF8 +} + +# ------------------------------------------------- +# 3. Slack notification +# ------------------------------------------------- +if ($config.slack.enabled -and $config.slack.webhookUrl) { + try { + $payload = @{ + text = "$message" + channel = $config.slack.channel + username = "Claude Hooks" + icon_emoji = ":robot_face:" + } | ConvertTo-Json -Depth 10 + + Invoke-RestMethod -Uri $config.slack.webhookUrl -Method Post -Body $payload -ContentType "application/json" -ErrorAction Stop | Out-Null + Write-Host "[OK] Slack notification sent" -ForegroundColor Green + } catch { + Write-Warning "Slack notification failed: $($_.Exception.Message)" + } +} + +# ------------------------------------------------- +# 4. Email notification - Multiple accounts with fallback +# ------------------------------------------------- +if ($SkipEmail) { + Write-Host "[INFO] Email notification skipped (-SkipEmail)" -ForegroundColor DarkGray +} elseif ($config.email -and $config.email.accounts) { + $accounts = @($config.email.accounts | Where-Object { $_.enabled -and $_.smtpServer -and $_.from -and $_.to }) + if ($accounts.Count -eq 0) { + Write-Host "[INFO] Email notification: no enabled accounts with valid config" -ForegroundColor DarkGray + } else { + foreach ($account in $accounts) { + try { + $subject = "$($account.subjectPrefix) $message" + + # Build HTML or plain text body + if ($account.useHtml) { + $fromAddr = $account.from + $toAddr = $account.to + $envName = "Development" + $body = @" + + + + + + + +
+
Claude Code Hook Triggered
+
+

Timestamp: $timestamp

+

Source: Claude Code Hook System

+

From: $fromAddr

+

To: $toAddr

+

Environment: $envName

+
+ +
+ + +"@ + $bodyAsHtml = $true + } else { + $body = "Notification hook triggered at $timestamp. Event: Hook Triggered. Source: Claude Code." + $bodyAsHtml = $false + } + + $safeFrom = $account.from.Replace("@", "_") + $credPath = ".claude/hooks/smtp-cred-$safeFrom.xml" + + # Credential handling + $credential = $null + if (Test-Path $credPath) { + try { + $credential = Import-CliXml -Path $credPath + Write-Host "[INFO] Using stored credentials for $($account.from)" -ForegroundColor DarkGray + } catch { + Write-Warning "Failed to load stored credentials for $($account.from): $($_.Exception.Message)" + $credential = $null + } + } + + if ((-not (Test-Path $credPath)) -or $null -eq $credential) { + if ($NoPrompt) { + Write-Host "[INFO] No stored credentials for $($account.from) and -NoPrompt specified. Skipping email." -ForegroundColor DarkGray + continue + } + + Write-Host "[INFO] Enter SMTP credentials for $($account.from) on $($account.smtpServer)" -ForegroundColor Yellow + try { + $credential = Get-Credential -UserName $account.from -Message "Enter password for $($account.from)" + if ($credential) { + $credential | Export-CliXml -Path $credPath + Write-Host "[OK] Credentials saved (encrypted)" -ForegroundColor Green + } else { + Write-Warning "No credentials provided for $($account.from). Skipping..." + continue + } + } catch { + if ($_.Exception.Message -like "*Get-Credential*") { + Write-Warning "Get-Credential failed (non-interactive environment?). Skipping email." + } else { + Write-Warning "Credential prompt failed: $($_.Exception.Message)" + } + continue + } + } + + $smtpParams = @{ + SmtpServer = $account.smtpServer + Port = $account.smtpPort + From = $account.from + To = $account.to + Subject = $subject + Body = $body + UseSsl = $true + Credential = $credential + } + + if ($bodyAsHtml) { + $smtpParams.BodyAsHtml = $true + } + + Send-MailMessage @smtpParams -ErrorAction Stop + Write-Host "[OK] Email sent via $($account.from) to $($account.to)" -ForegroundColor Green + break + } catch { + Write-Warning "Email via $($account.from) failed: $($_.Exception.Message)" + continue + } + } + } +} + +# ------------------------------------------------- +# 5. Teams notification +# ------------------------------------------------- +if ($config.teams.enabled -and $config.teams.webhookUrl) { + try { + $card = @{ + title = "Claude Hook Notification" + text = $message + themeColor = "0076D7" + sections = @( + @{ + activityTitle = "Hook Triggered" + activitySubtitle = $timestamp + facts = @( + @{ name = "Source"; value = "Claude Code" }, + @{ name = "Environment"; value = "Development" } + ) + } + ) + } | ConvertTo-Json -Depth 10 + + Invoke-RestMethod -Uri $config.teams.webhookUrl -Method Post -Body $card -ContentType "application/json" -ErrorAction Stop | Out-Null + Write-Host "[OK] Teams notification sent" -ForegroundColor Green + } catch { + Write-Warning "Teams notification failed: $($_.Exception.Message)" + } +} diff --git a/.claude/hooks/research-first.ps1 b/.claude/hooks/research-first.ps1 new file mode 100644 index 0000000..9f22f63 --- /dev/null +++ b/.claude/hooks/research-first.ps1 @@ -0,0 +1,35 @@ +# UserPromptSubmit hook: Encourage research-first approach before implementation +$jsonInput = [Console]::In.ReadToEnd() +if (-not $jsonInput) { exit 0 } + +$data = $jsonInput | ConvertFrom-Json + +$prompt = $data.user_prompt +if (-not $prompt) { exit 0 } + +$implKeywords = @('create', 'implement', 'build', 'add', 'write', 'refactor', 'fix', 'update', 'modify', 'change', 'delete', 'remove', 'replace', 'migrate') +$researchKeywords = @('explain', 'analyze', 'review', 'understand', 'explore', 'investigate', 'describe', 'show', 'list', 'what is', 'how does', 'why') + +$promptLower = $prompt.ToLower() + +$hasImpl = $false +foreach ($kw in $implKeywords) { + if ($promptLower -match "\b$kw\b") { + $hasImpl = $true + break + } +} + +$hasResearch = $false +foreach ($kw in $researchKeywords) { + if ($promptLower -match "\b$kw\b") { + $hasResearch = $true + break + } +} + +if ($hasImpl -and -not $hasResearch) { + @{ additionalContext = [char]0x1F4DA + " Research-First: Before implementing, check docs/ for existing documentation and understand the affected architecture layer." } | ConvertTo-Json -Compress | Write-Output +} + +exit 0 diff --git a/.claude/hooks/security-scanner.ps1 b/.claude/hooks/security-scanner.ps1 new file mode 100644 index 0000000..3a878a6 --- /dev/null +++ b/.claude/hooks/security-scanner.ps1 @@ -0,0 +1,42 @@ +# PreToolUse hook: Scan for hardcoded secrets in file content +$jsonInput = [Console]::In.ReadToEnd() +if (-not $jsonInput) { exit 0 } + +$data = $jsonInput | ConvertFrom-Json + +$toolName = $data.tool_name +if ($toolName -notmatch 'Edit|Write|MultiEdit|Create') { exit 0 } + +$content = $null +if ($data.tool_input.content) { $content = $data.tool_input.content } +elseif ($data.tool_input.new_str) { $content = $data.tool_input.new_str } +elseif ($data.tool_input.file_text) { $content = $data.tool_input.file_text } + +if (-not $content) { exit 0 } + +$secretPatterns = @( + @{ Name = "Connection string with password"; Pattern = '(?i)(connection\s*string|Server=|Data Source=).*(?:Password|Pwd)\s*=' } + @{ Name = "AWS access key"; Pattern = 'AKIA[0-9A-Z]{16}' } + @{ Name = "API key (sk- prefix)"; Pattern = 'sk-[a-zA-Z0-9]{20,}' } + @{ Name = "API key (pk_ prefix)"; Pattern = 'pk_[a-zA-Z0-9]{20,}' } + @{ Name = "Bearer token"; Pattern = '(?i)bearer\s+[a-zA-Z0-9\-._~+/]+=*' } + @{ Name = "Private key block"; Pattern = '-----BEGIN\s+(RSA\s+)?PRIVATE KEY-----' } + @{ Name = "Hardcoded password literal"; Pattern = '(?i)(password|passwd|pwd)\s*=\s*"[^"]{4,}"' } + @{ Name = "Generic secret assignment"; Pattern = '(?i)(secret|api_key|apikey)\s*=\s*"[^"]{8,}"' } +) + +foreach ($sp in $secretPatterns) { + if ($content -match $sp.Pattern) { + $result = @{ + hookSpecificOutput = @{ + hookEventName = "PreToolUse" + permissionDecision = "deny" + permissionDecisionReason = "Security: hardcoded secret detected ($($sp.Name)). Use user-secrets, environment variables, or Azure Key Vault instead." + } + } + $result | ConvertTo-Json -Depth 3 -Compress | Write-Output + exit 0 + } +} + +exit 0 diff --git a/.claude/hooks/test-runner.ps1 b/.claude/hooks/test-runner.ps1 new file mode 100644 index 0000000..e9ee0b2 --- /dev/null +++ b/.claude/hooks/test-runner.ps1 @@ -0,0 +1,51 @@ +# ------------------------------------------------- +# Test Runner for Notification Hook (Multi-Channel) +# ------------------------------------------------- +# Tests the notification hook and verifies config exists. +# Usage: powershell -File .claude/hooks/test-runner.ps1 + +Write-Host "=== Claude Hook Notification Test ===" -ForegroundColor Cyan + +# 1. Verify config file exists +$configPath = ".claude/hooks/notification-config.json" +if (Test-Path $configPath) { + Write-Host "[✓] Config file found" -ForegroundColor Green + $config = Get-Content $configPath -Raw | ConvertFrom-Json + Write-Host " Enabled channels:" -NoNewline + $enabled = @() + if ($config.console.enabled) { $enabled += "Console" } + if ($config.fileLog.enabled) { $enabled += "File" } + if ($config.slack.enabled -and $config.slack.webhookUrl) { $enabled += "Slack" } + if ($config.email.enabled -and $config.email.from -and $config.email.to) { $enabled += "Email" } + if ($config.teams.enabled -and $config.teams.webhookUrl) { $enabled += "Teams" } + Write-Host ($enabled -join ", ") +} else { + Write-Host "[✗] Config file missing. Creating default..." -ForegroundColor Red + Write-Host " Run the notification script to auto-create." -ForegroundColor Yellow +} + +# 2. Run the notification script directly +Write-Host "`n[→] Triggering notification hook directly..." -ForegroundColor Cyan +powershell -ExecutionPolicy Bypass -File ".claude/hooks/notification.ps1" + +# 3. Verify log file created +$logPath = ".claude/hooks/notifications.log" +if (Test-Path $logPath) { + $lastEntry = Get-Content $logPath -Tail 1 + Write-Host "`n[✓] Log entry created:" -ForegroundColor Green + Write-Host " $lastEntry" -ForegroundColor Gray +} else { + Write-Host "`n[✗] Log file not found" -ForegroundColor Red +} + +# 4. Instructions for enabling external channels +Write-Host "`n=== Next Steps ===" -ForegroundColor Cyan +Write-Host "To enable Slack, Teams, or Email:" -ForegroundColor White +Write-Host "1. Edit: .claude/hooks/notification-config.json" -ForegroundColor Yellow +Write-Host "2. Set 'enabled' to true and fill in credentials" -ForegroundColor Yellow +Write-Host "3. Re-run this test" -ForegroundColor Yellow + +Write-Host "`nExample Slack config:" -ForegroundColor Gray +Write-Host '{ "slack": { "webhookUrl": "https://hooks.slack.com/services/YOUR/WEBHOOK/URL", "enabled": true } }' -ForegroundColor Gray + +Write-Host "`nTest complete! 🎉" -ForegroundColor Green diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..24ce890 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,83 @@ +{ + "env": {}, + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "shell": "powershell", + "command": "powershell -ExecutionPolicy Bypass -File \"C:\\DATA\\MYSTUFFS\\SIDE PROJECTS\\CloudZen\\.claude\\hooks\\context-optimizer.ps1\"", + "statusMessage": "Loading project context..." + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "shell": "powershell", + "command": "powershell -ExecutionPolicy Bypass -File \"$PSScriptRoot/hooks/research-first.ps1\"", + "statusMessage": "Checking research-first..." + } + ] + } + ], + "PreToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "shell": "powershell", + "command": "powershell -ExecutionPolicy Bypass -File \"$PSScriptRoot/hooks/security-scanner.ps1\"", + "statusMessage": "🔒 Security scanning..." + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "Edit|Write|MultiEdit", + "hooks": [ + { + "type": "command", + "shell": "powershell", + "command": "powershell -ExecutionPolicy Bypass -File \"$PSScriptRoot/hooks/dotnet-conventions.ps1\"", + "statusMessage": "Checking conventions..." + }, + { + "type": "command", + "shell": "powershell", + "command": "powershell -ExecutionPolicy Bypass -File \"$PSScriptRoot/hooks/doc-sync-reminder.ps1\"" + }, + { + "type": "command", + "shell": "powershell", + "command": "powershell -ExecutionPolicy Bypass -File \"$PSScriptRoot/hooks/build-reminder.ps1\"" + }, + { + "type": "command", + "shell": "powershell", + "command": "powershell -ExecutionPolicy Bypass -File \"$PSScriptRoot/hooks/notification.ps1\"", + "statusMessage": "Sending notification..." + } + ] + } + ], + "Notification": [ + { + "hooks": [ + { + "type": "command", + "shell": "powershell", + "command": "powershell -ExecutionPolicy Bypass -File \"$PSScriptRoot/hooks/notification.ps1\"", + "statusMessage": "Sending notification..." + } + ] + } + ] + } +} diff --git a/.claude/skills/README.md b/.claude/skills/README.md new file mode 100644 index 0000000..beced0c --- /dev/null +++ b/.claude/skills/README.md @@ -0,0 +1,70 @@ +# Claude Code Skills + +Bridge files that register the project's universal AI skills with Claude Code's `/skills` discovery system. Each subfolder contains a lightweight `SKILL.md` that redirects Claude to the full skill definition in `.github/skills/`. + +## At a Glance + +| Aspect | Detail | +|--------|--------| +| **Count** | 42 skills across 11 categories | +| **Architecture** | Bridge pattern — `.claude/skills/` → `.github/skills/` (single source of truth) | +| **Invocation** | `/skill-name` in Claude Code (e.g., `/owasp-audit`, `/code-reviewer`) | +| **Relationship** | Same universal skills work in Copilot CLI, Gemini, and any file-reading AI agent | + +## How It Works + +``` +1. User types: /owasp-audit +2. Claude loads: .claude/skills/owasp-audit/SKILL.md (bridge, ~15 lines) +3. Bridge says: "Read .github/skills/security/owasp-audit/SKILL.md" +4. Claude follows: Full Core Workflow from the universal skill file +5. On-demand: Load references/*.md only when the current step needs them +``` + +## Why Bridges? + +- **Single source of truth** — all skill content lives in `.github/skills/`, shared across AI tools. +- **Claude-specific discovery** — bridges register skills with Claude Code's `/skills` list. +- **Minimal overhead** — bridge files are < 20 lines each; no duplicated content. + +## Skill Categories (11) + +| Category | Skills | Path | +|----------|--------|------| +| `code-quality` | code-reviewer, refactor-planner, code-documenter, debugging-wizard | `.github/skills/code-quality/` | +| `security` | owasp-audit, secret-scanner, threat-modeler, authentication, authorization | `.github/skills/security/` | +| `architecture` | architecture-reviewer, design-pattern-advisor, dependency-analyzer, legacy-modernizer | `.github/skills/architecture/` | +| `testing` | test-generator, tdd-coach, test-coverage-analyzer | `.github/skills/testing/` | +| `database` | schema-reviewer, query-optimizer | `.github/skills/database/` | +| `devops` | ci-cd-builder, deployment-preflight, monitoring-expert, chaos-engineer | `.github/skills/devops/` | +| `documentation` | readme-generator, adr-creator, api-documenter | `.github/skills/documentation/` | +| `research` | codebase-explorer, tech-spike-planner, spec-miner | `.github/skills/research/` | +| `project-management` | spec-writer, issue-creator, feature-forge | `.github/skills/project-management/` | +| `ai` | mcp-developer, prompt-engineer, agent-orchestrator | `.github/skills/ai/` | +| `language` | dotnet-core-expert, csharp-developer | `.github/skills/language/` | + +## Creating a New Skill + +1. **Create the universal skill** in `.github/skills/{category}/{skill-name}/SKILL.md` with Core Workflow and optional `references/` folder. +2. **Create the bridge** in `.claude/skills/{skill-name}/SKILL.md`: + ```markdown + # {Skill Name} + > Claude Code bridge — read the universal skill for full instructions. + Read: `.github/skills/{category}/{skill-name}/SKILL.md` + Follow the Core Workflow steps inside. + ``` +3. The skill appears in Claude Code's `/skills` list automatically. + +## Key Rules + +- Bridge files must be **minimal** (< 20 lines) — all real content lives in `.github/skills/`. +- **Never duplicate** skill content in the bridge. If the bridge grows, the content belongs upstream. +- Follow **progressive disclosure** — load `references/*.md` only when the current workflow step requires it. +- See `.github/skills/CATALOG.md` for the full skill inventory with descriptions. + +## See Also + +- `.github/skills/` — Universal skill definitions (source of truth) +- `.github/skills/CATALOG.md` — Full skill catalog with descriptions and categories +- `.claude/rules/` — Always-on behavioral rules for Claude Code +- `.claude/hooks/` — Event-triggered PowerShell scripts for Claude Code diff --git a/.claude/skills/adr-creator/SKILL.md b/.claude/skills/adr-creator/SKILL.md new file mode 100644 index 0000000..b315670 --- /dev/null +++ b/.claude/skills/adr-creator/SKILL.md @@ -0,0 +1,22 @@ +--- +name: adr-creator +description: Create Architecture Decision Records following the ADR standard +--- + +# Adr Creator + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/adr-creator/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/adr-creator/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/adr-creator/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/adr-creator/SKILL.md +``` diff --git a/.claude/skills/agent-orchestrator/SKILL.md b/.claude/skills/agent-orchestrator/SKILL.md new file mode 100644 index 0000000..1b29059 --- /dev/null +++ b/.claude/skills/agent-orchestrator/SKILL.md @@ -0,0 +1,22 @@ +--- +name: agent-orchestrator +description: Orchestrate parallel sub-agent fleets with token-aware delegation and approval gates +--- + +# Agent Orchestrator + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/agent-orchestrator/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/agent-orchestrator/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/agent-orchestrator/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/agent-orchestrator/SKILL.md +``` diff --git a/.claude/skills/api-documenter/SKILL.md b/.claude/skills/api-documenter/SKILL.md new file mode 100644 index 0000000..ceba0ac --- /dev/null +++ b/.claude/skills/api-documenter/SKILL.md @@ -0,0 +1,22 @@ +--- +name: api-documenter +description: Generate API documentation from code with examples and schemas +--- + +# Api Documenter + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/api-documenter/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/api-documenter/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/api-documenter/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/api-documenter/SKILL.md +``` diff --git a/.claude/skills/architecture-reviewer/SKILL.md b/.claude/skills/architecture-reviewer/SKILL.md new file mode 100644 index 0000000..68bf225 --- /dev/null +++ b/.claude/skills/architecture-reviewer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: architecture-reviewer +description: Review system architecture for quality attributes and anti-patterns +--- + +# Architecture Reviewer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/architecture-reviewer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/architecture-reviewer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/architecture-reviewer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/architecture-reviewer/SKILL.md +``` diff --git a/.claude/skills/authentication/SKILL.md b/.claude/skills/authentication/SKILL.md new file mode 100644 index 0000000..198fad9 --- /dev/null +++ b/.claude/skills/authentication/SKILL.md @@ -0,0 +1,22 @@ +--- +name: authentication +description: Implement authentication flows with Entra ID, OIDC, JWT, and ASP.NET Core Identity +--- + +# Authentication + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/authentication/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/authentication/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/authentication/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/authentication/SKILL.md +``` diff --git a/.claude/skills/authorization/SKILL.md b/.claude/skills/authorization/SKILL.md new file mode 100644 index 0000000..ced2acc --- /dev/null +++ b/.claude/skills/authorization/SKILL.md @@ -0,0 +1,22 @@ +--- +name: authorization +description: Implement policy-based authorization, RBAC, resource-based access control +--- + +# Authorization + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/authorization/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/authorization/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/authorization/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/authorization/SKILL.md +``` diff --git a/.claude/skills/chaos-engineer/SKILL.md b/.claude/skills/chaos-engineer/SKILL.md new file mode 100644 index 0000000..d6e3d99 --- /dev/null +++ b/.claude/skills/chaos-engineer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: chaos-engineer +description: Design and execute chaos experiments to verify system resilience +--- + +# Chaos Engineer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/chaos-engineer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/chaos-engineer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/chaos-engineer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/chaos-engineer/SKILL.md +``` diff --git a/.claude/skills/ci-cd-builder/SKILL.md b/.claude/skills/ci-cd-builder/SKILL.md new file mode 100644 index 0000000..9a699a9 --- /dev/null +++ b/.claude/skills/ci-cd-builder/SKILL.md @@ -0,0 +1,22 @@ +--- +name: ci-cd-builder +description: Create or improve CI/CD pipeline configurations +--- + +# Ci Cd Builder + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/ci-cd-builder/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/ci-cd-builder/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/ci-cd-builder/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/ci-cd-builder/SKILL.md +``` diff --git a/.claude/skills/code-documenter/SKILL.md b/.claude/skills/code-documenter/SKILL.md new file mode 100644 index 0000000..6813366 --- /dev/null +++ b/.claude/skills/code-documenter/SKILL.md @@ -0,0 +1,22 @@ +--- +name: code-documenter +description: Generate inline documentation, XML doc comments, and usage examples +--- + +# Code Documenter + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/code-documenter/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/code-documenter/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/code-documenter/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/code-documenter/SKILL.md +``` diff --git a/.claude/skills/code-reviewer/SKILL.md b/.claude/skills/code-reviewer/SKILL.md new file mode 100644 index 0000000..62c66e9 --- /dev/null +++ b/.claude/skills/code-reviewer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: code-reviewer +description: Review code changes for correctness, style, security, and maintainability +--- + +# Code Reviewer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/code-reviewer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/code-reviewer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/code-reviewer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/code-reviewer/SKILL.md +``` diff --git a/.claude/skills/codebase-explorer/SKILL.md b/.claude/skills/codebase-explorer/SKILL.md new file mode 100644 index 0000000..60befd1 --- /dev/null +++ b/.claude/skills/codebase-explorer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: codebase-explorer +description: Explore and map unfamiliar codebases to build understanding +--- + +# Codebase Explorer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/codebase-explorer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/codebase-explorer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/codebase-explorer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/codebase-explorer/SKILL.md +``` diff --git a/.claude/skills/csharp-developer/SKILL.md b/.claude/skills/csharp-developer/SKILL.md new file mode 100644 index 0000000..7ed15b4 --- /dev/null +++ b/.claude/skills/csharp-developer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: csharp-developer +description: Senior C# 13 developer — records, pattern matching, Blazor, performance +--- + +# Csharp Developer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/csharp-developer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/csharp-developer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/csharp-developer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/csharp-developer/SKILL.md +``` diff --git a/.claude/skills/debugging-wizard/SKILL.md b/.claude/skills/debugging-wizard/SKILL.md new file mode 100644 index 0000000..ec0b70c --- /dev/null +++ b/.claude/skills/debugging-wizard/SKILL.md @@ -0,0 +1,22 @@ +--- +name: debugging-wizard +description: Systematic debugging with root cause analysis and fix verification +--- + +# Debugging Wizard + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/debugging-wizard/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/debugging-wizard/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/debugging-wizard/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/debugging-wizard/SKILL.md +``` diff --git a/.claude/skills/deep-context-generator/SKILL.md b/.claude/skills/deep-context-generator/SKILL.md new file mode 100644 index 0000000..eda77bd --- /dev/null +++ b/.claude/skills/deep-context-generator/SKILL.md @@ -0,0 +1,22 @@ +--- +name: deep-context-generator +description: Generate LLM-optimized codebase context for onboarding and architecture understanding +--- + +# Deep Context Generator + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/deep-context-generator/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/deep-context-generator/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/deep-context-generator/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/deep-context-generator/SKILL.md +``` diff --git a/.claude/skills/dependency-analyzer/SKILL.md b/.claude/skills/dependency-analyzer/SKILL.md new file mode 100644 index 0000000..ee91b71 --- /dev/null +++ b/.claude/skills/dependency-analyzer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: dependency-analyzer +description: Analyze project dependencies for risks, updates, and license compliance +--- + +# Dependency Analyzer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/dependency-analyzer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/dependency-analyzer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/dependency-analyzer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/dependency-analyzer/SKILL.md +``` diff --git a/.claude/skills/deployment-preflight/SKILL.md b/.claude/skills/deployment-preflight/SKILL.md new file mode 100644 index 0000000..2f2d470 --- /dev/null +++ b/.claude/skills/deployment-preflight/SKILL.md @@ -0,0 +1,22 @@ +--- +name: deployment-preflight +description: Run pre-deployment checks and generate go/no-go reports +--- + +# Deployment Preflight + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/deployment-preflight/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/deployment-preflight/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/deployment-preflight/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/deployment-preflight/SKILL.md +``` diff --git a/.claude/skills/design-pattern-advisor/SKILL.md b/.claude/skills/design-pattern-advisor/SKILL.md new file mode 100644 index 0000000..090c86c --- /dev/null +++ b/.claude/skills/design-pattern-advisor/SKILL.md @@ -0,0 +1,22 @@ +--- +name: design-pattern-advisor +description: Recommend and apply appropriate design patterns to solve structural problems +--- + +# Design Pattern Advisor + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/design-pattern-advisor/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/design-pattern-advisor/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/design-pattern-advisor/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/design-pattern-advisor/SKILL.md +``` diff --git a/.claude/skills/dotnet-core-expert/SKILL.md b/.claude/skills/dotnet-core-expert/SKILL.md new file mode 100644 index 0000000..904baba --- /dev/null +++ b/.claude/skills/dotnet-core-expert/SKILL.md @@ -0,0 +1,22 @@ +--- +name: dotnet-core-expert +description: Deep .NET 10 expertise — Clean Architecture, EF Core, CQRS/MediatR, JWT auth +--- + +# Dotnet Core Expert + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/dotnet-core-expert/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/dotnet-core-expert/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/dotnet-core-expert/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/dotnet-core-expert/SKILL.md +``` diff --git a/.claude/skills/feature-forge/SKILL.md b/.claude/skills/feature-forge/SKILL.md new file mode 100644 index 0000000..b4e5553 --- /dev/null +++ b/.claude/skills/feature-forge/SKILL.md @@ -0,0 +1,22 @@ +--- +name: feature-forge +description: Generate complete feature breakdowns with stories, tasks, and acceptance criteria +--- + +# Feature Forge + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/feature-forge/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/feature-forge/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/feature-forge/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/feature-forge/SKILL.md +``` diff --git a/.claude/skills/issue-creator/SKILL.md b/.claude/skills/issue-creator/SKILL.md new file mode 100644 index 0000000..271cddc --- /dev/null +++ b/.claude/skills/issue-creator/SKILL.md @@ -0,0 +1,22 @@ +--- +name: issue-creator +description: Create structured GitHub issues with acceptance criteria and sub-task decomposition +--- + +# Issue Creator + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/issue-creator/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/issue-creator/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/issue-creator/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/issue-creator/SKILL.md +``` diff --git a/.claude/skills/legacy-modernizer/SKILL.md b/.claude/skills/legacy-modernizer/SKILL.md new file mode 100644 index 0000000..a141bf9 --- /dev/null +++ b/.claude/skills/legacy-modernizer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: legacy-modernizer +description: Plan and execute modernization of legacy codebases to modern architectures +--- + +# Legacy Modernizer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/legacy-modernizer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/legacy-modernizer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/legacy-modernizer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/legacy-modernizer/SKILL.md +``` diff --git a/.claude/skills/mcp-developer/SKILL.md b/.claude/skills/mcp-developer/SKILL.md new file mode 100644 index 0000000..71527ae --- /dev/null +++ b/.claude/skills/mcp-developer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: mcp-developer +description: Build, debug, and extend MCP servers and clients with JSON-RPC transport +--- + +# Mcp Developer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/mcp-developer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/mcp-developer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/mcp-developer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/mcp-developer/SKILL.md +``` diff --git a/.claude/skills/memory-optimization/SKILL.md b/.claude/skills/memory-optimization/SKILL.md new file mode 100644 index 0000000..ea3e894 --- /dev/null +++ b/.claude/skills/memory-optimization/SKILL.md @@ -0,0 +1,30 @@ +--- +name: memory-optimization +description: Context window and token optimization rules — load less, achieve more. Apply to every session. +--- + +# Memory & Context Optimization + +> **Bridge to universal instruction.** The full rules live in +> `~/.claude/skills/memory-optimization/SKILL.md`. + +This skill teaches token-efficient AI behavior: progressive disclosure, context budgeting, +selective loading, and output compression. Apply these rules to **every session**. + +## Instructions + +1. **Read the full rules:** Open `~/.claude/skills/memory-optimization/SKILL.md` +2. **Internalize the 7 sections** — they apply to all tasks, not just specific workflows +3. **Key principles to always follow:** + - Load only what you need (grep first, then read matched files) + - Use `view_range` instead of reading entire files + - Suppress verbose output (`--quiet`, pipe to `head`) + - Batch parallel reads in a single turn + - Progressive disclosure: SKILL.md first, references only when needed + - Token budget awareness: <30% normal, 30-60% selective, 60-80% delegate, >80% compact + +## Quick Start + +``` +Read ~/.claude/skills/memory-optimization/SKILL.md +``` diff --git a/.claude/skills/monitoring-expert/SKILL.md b/.claude/skills/monitoring-expert/SKILL.md new file mode 100644 index 0000000..9cca9a0 --- /dev/null +++ b/.claude/skills/monitoring-expert/SKILL.md @@ -0,0 +1,22 @@ +--- +name: monitoring-expert +description: Design observability stacks with metrics, logs, traces, and alerting +--- + +# Monitoring Expert + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/monitoring-expert/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/monitoring-expert/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/monitoring-expert/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/monitoring-expert/SKILL.md +``` diff --git a/.claude/skills/owasp-audit/SKILL.md b/.claude/skills/owasp-audit/SKILL.md new file mode 100644 index 0000000..6621891 --- /dev/null +++ b/.claude/skills/owasp-audit/SKILL.md @@ -0,0 +1,22 @@ +--- +name: owasp-audit +description: Audit code against OWASP Top 10 vulnerabilities +--- + +# Owasp Audit + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/owasp-audit/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/owasp-audit/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/owasp-audit/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/owasp-audit/SKILL.md +``` diff --git a/.claude/skills/polyglot-analyzer/SKILL.md b/.claude/skills/polyglot-analyzer/SKILL.md new file mode 100644 index 0000000..e1d6612 --- /dev/null +++ b/.claude/skills/polyglot-analyzer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: polyglot-analyzer +description: Multi-language quality comparison with cross-language boundary analysis and unified quality gates +--- + +# Polyglot Analyzer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/polyglot-analyzer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/polyglot-analyzer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/polyglot-analyzer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/polyglot-analyzer/SKILL.md +``` diff --git a/.claude/skills/prompt-engineer/SKILL.md b/.claude/skills/prompt-engineer/SKILL.md new file mode 100644 index 0000000..78b55ab --- /dev/null +++ b/.claude/skills/prompt-engineer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: prompt-engineer +description: Write, refactor, and evaluate LLM prompts with structured outputs +--- + +# Prompt Engineer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/prompt-engineer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/prompt-engineer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/prompt-engineer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/prompt-engineer/SKILL.md +``` diff --git a/.claude/skills/quality-analyzer/SKILL.md b/.claude/skills/quality-analyzer/SKILL.md new file mode 100644 index 0000000..9239ae8 --- /dev/null +++ b/.claude/skills/quality-analyzer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: quality-analyzer +description: Analyze code quality metrics — complexity, maintainability, SATD, and style conformance +--- + +# Quality Analyzer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/quality-analyzer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/quality-analyzer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/quality-analyzer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/quality-analyzer/SKILL.md +``` diff --git a/.claude/skills/query-optimizer/SKILL.md b/.claude/skills/query-optimizer/SKILL.md new file mode 100644 index 0000000..15213dd --- /dev/null +++ b/.claude/skills/query-optimizer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: query-optimizer +description: Analyze and optimize SQL queries for performance +--- + +# Query Optimizer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/query-optimizer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/query-optimizer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/query-optimizer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/query-optimizer/SKILL.md +``` diff --git a/.claude/skills/readme-generator/SKILL.md b/.claude/skills/readme-generator/SKILL.md new file mode 100644 index 0000000..60f64e4 --- /dev/null +++ b/.claude/skills/readme-generator/SKILL.md @@ -0,0 +1,22 @@ +--- +name: readme-generator +description: Generate comprehensive README files from project analysis +--- + +# Readme Generator + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/readme-generator/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/readme-generator/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/readme-generator/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/readme-generator/SKILL.md +``` diff --git a/.claude/skills/refactor-planner/SKILL.md b/.claude/skills/refactor-planner/SKILL.md new file mode 100644 index 0000000..64ad3cb --- /dev/null +++ b/.claude/skills/refactor-planner/SKILL.md @@ -0,0 +1,22 @@ +--- +name: refactor-planner +description: Analyze code and produce a prioritized refactoring plan +--- + +# Refactor Planner + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/refactor-planner/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/refactor-planner/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/refactor-planner/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/refactor-planner/SKILL.md +``` diff --git a/.claude/skills/schema-reviewer/SKILL.md b/.claude/skills/schema-reviewer/SKILL.md new file mode 100644 index 0000000..7640c4d --- /dev/null +++ b/.claude/skills/schema-reviewer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: schema-reviewer +description: Review database schema design for normalization, indexing, and integrity +--- + +# Schema Reviewer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/schema-reviewer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/schema-reviewer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/schema-reviewer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/schema-reviewer/SKILL.md +``` diff --git a/.claude/skills/secret-scanner/SKILL.md b/.claude/skills/secret-scanner/SKILL.md new file mode 100644 index 0000000..24d133b --- /dev/null +++ b/.claude/skills/secret-scanner/SKILL.md @@ -0,0 +1,22 @@ +--- +name: secret-scanner +description: Detect hardcoded secrets, API keys, and credentials in source code +--- + +# Secret Scanner + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/secret-scanner/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/secret-scanner/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/secret-scanner/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/secret-scanner/SKILL.md +``` diff --git a/.claude/skills/smart-refactor/SKILL.md b/.claude/skills/smart-refactor/SKILL.md new file mode 100644 index 0000000..4da0770 --- /dev/null +++ b/.claude/skills/smart-refactor/SKILL.md @@ -0,0 +1,22 @@ +--- +name: smart-refactor +description: Metrics-driven refactoring with baseline/after comparison and scientific measurement +--- + +# Smart Refactor + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/smart-refactor/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/smart-refactor/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/smart-refactor/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/smart-refactor/SKILL.md +``` diff --git a/.claude/skills/spec-miner/SKILL.md b/.claude/skills/spec-miner/SKILL.md new file mode 100644 index 0000000..1de25b4 --- /dev/null +++ b/.claude/skills/spec-miner/SKILL.md @@ -0,0 +1,22 @@ +--- +name: spec-miner +description: Extract implicit specifications from code, tests, and documentation +--- + +# Spec Miner + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/spec-miner/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/spec-miner/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/spec-miner/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/spec-miner/SKILL.md +``` diff --git a/.claude/skills/spec-writer/SKILL.md b/.claude/skills/spec-writer/SKILL.md new file mode 100644 index 0000000..923b1be --- /dev/null +++ b/.claude/skills/spec-writer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: spec-writer +description: Write comprehensive technical specifications from feature requests +--- + +# Spec Writer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/spec-writer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/spec-writer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/spec-writer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/spec-writer/SKILL.md +``` diff --git a/.claude/skills/tdd-coach/SKILL.md b/.claude/skills/tdd-coach/SKILL.md new file mode 100644 index 0000000..a3982d0 --- /dev/null +++ b/.claude/skills/tdd-coach/SKILL.md @@ -0,0 +1,22 @@ +--- +name: tdd-coach +description: Guide test-driven development with red-green-refactor cycle +--- + +# Tdd Coach + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/tdd-coach/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/tdd-coach/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/tdd-coach/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/tdd-coach/SKILL.md +``` diff --git a/.claude/skills/tech-debt-tracker/SKILL.md b/.claude/skills/tech-debt-tracker/SKILL.md new file mode 100644 index 0000000..67d4c2d --- /dev/null +++ b/.claude/skills/tech-debt-tracker/SKILL.md @@ -0,0 +1,22 @@ +--- +name: tech-debt-tracker +description: Detect, quantify, and prioritize technical debt with SATD detection and sprint planning +--- + +# Tech Debt Tracker + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/tech-debt-tracker/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/tech-debt-tracker/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/tech-debt-tracker/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/tech-debt-tracker/SKILL.md +``` diff --git a/.claude/skills/tech-spike-planner/SKILL.md b/.claude/skills/tech-spike-planner/SKILL.md new file mode 100644 index 0000000..e37fecb --- /dev/null +++ b/.claude/skills/tech-spike-planner/SKILL.md @@ -0,0 +1,22 @@ +--- +name: tech-spike-planner +description: Plan time-boxed technical investigations with clear success criteria +--- + +# Tech Spike Planner + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/tech-spike-planner/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/tech-spike-planner/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/tech-spike-planner/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/tech-spike-planner/SKILL.md +``` diff --git a/.claude/skills/test-coverage-analyzer/SKILL.md b/.claude/skills/test-coverage-analyzer/SKILL.md new file mode 100644 index 0000000..2642bbb --- /dev/null +++ b/.claude/skills/test-coverage-analyzer/SKILL.md @@ -0,0 +1,22 @@ +--- +name: test-coverage-analyzer +description: Analyze test coverage gaps and recommend high-value tests to add +--- + +# Test Coverage Analyzer + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/test-coverage-analyzer/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/test-coverage-analyzer/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/test-coverage-analyzer/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/test-coverage-analyzer/SKILL.md +``` diff --git a/.claude/skills/test-generator/SKILL.md b/.claude/skills/test-generator/SKILL.md new file mode 100644 index 0000000..3bdfa12 --- /dev/null +++ b/.claude/skills/test-generator/SKILL.md @@ -0,0 +1,22 @@ +--- +name: test-generator +description: Generate unit and integration tests with Arrange-Act-Assert structure +--- + +# Test Generator + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/test-generator/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/test-generator/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/test-generator/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/test-generator/SKILL.md +``` diff --git a/.claude/skills/threat-modeler/SKILL.md b/.claude/skills/threat-modeler/SKILL.md new file mode 100644 index 0000000..49692a4 --- /dev/null +++ b/.claude/skills/threat-modeler/SKILL.md @@ -0,0 +1,22 @@ +--- +name: threat-modeler +description: Create STRIDE-based threat models for system components +--- + +# Threat Modeler + +> **Bridge to universal skill catalog.** This file registers the skill with Claude's `/skills` system. +> The full skill definition lives in `~/.claude/skills/threat-modeler/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `~/.claude/skills/threat-modeler/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `~/.claude/skills/threat-modeler/references/` — only when the Reference Guide table says to +4. **Never load all references at once** — progressive disclosure saves tokens + +## Quick Start + +``` +Read ~/.claude/skills/threat-modeler/SKILL.md +``` diff --git a/.github/SETUP-GUIDE.md b/.github/SETUP-GUIDE.md new file mode 100644 index 0000000..cc7e545 --- /dev/null +++ b/.github/SETUP-GUIDE.md @@ -0,0 +1,837 @@ +# Copilot CLI Project Configuration — Setup Guide + +> How to replicate this AI development infrastructure in any .NET project. +> This guide walks through every component, explains its purpose, and provides +> templates you can adapt. + +## Overview + +This configuration system gives AI assistants (Copilot, Claude, Gemini) deep +project knowledge through layered instruction files, custom extensions with +hooks and tools, MCP server integrations, and LSP configuration. + +### What You Get + +| Layer | Purpose | Files | +|-------|---------|-------| +| **Model Instructions** | Project identity, per-model optimization | `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` | +| **Master Rules** | Technology stack, architecture, conventions | `.github/copilot-instructions.md` | +| **Scoped Instructions** | Domain-specific rules activated by file glob | `.github/instructions/**/*.instructions.md` | +| **Skills Catalog** | Reusable AI skills with lazy-loaded references | `.github/skills/{category}/{skill}/SKILL.md` | +| **Extensions** | Custom tools, hooks, and real-time behaviors | `.github/extensions/*/extension.mjs` | +| **MCP Servers** | External tool integrations (DB, APIs) | `.github/copilot-mcp.json` | +| **LSP Config** | Language server for code intelligence | `.github/lsp.json` | +| **Cloud Agent** | CI environment for Copilot coding agent | `.github/copilot-setup-steps.yml` | + +--- + +## Step 1: Create the Directory Structure + +```bash +# From your solution/repo root: +mkdir -p .github/instructions/{architecture,security,testing,resilience,memory,development} +mkdir -p .github/instructions/{blazor,cqrs,database,domain} +mkdir -p .github/extensions/{security-scanner,build-guardian,context-optimizer} +mkdir -p .github/extensions/{research-first,doc-sync,dotnet-conventions} +mkdir -p .github/skills/{code-quality,security,architecture,testing} +mkdir -p .github/skills/{database,devops,documentation,research} +mkdir -p .github/skills/{project-management,ai,language} +# Claude Code bridge (for /skills discovery) +mkdir -p .claude/skills +``` + +On Windows (PowerShell): + +```powershell +$dirs = @( + # Instructions (scoped rules) + ".github\instructions\architecture", + ".github\instructions\security", + ".github\instructions\testing", + ".github\instructions\resilience", + ".github\instructions\memory", + ".github\instructions\development", + ".github\instructions\blazor", + ".github\instructions\cqrs", + ".github\instructions\database", + ".github\instructions\domain", + # Extensions (hooks + tools) + ".github\extensions\security-scanner", + ".github\extensions\build-guardian", + ".github\extensions\context-optimizer", + ".github\extensions\research-first", + ".github\extensions\doc-sync", + ".github\extensions\dotnet-conventions", + # Skills catalog (11 categories) + ".github\skills\code-quality", + ".github\skills\security", + ".github\skills\architecture", + ".github\skills\testing", + ".github\skills\database", + ".github\skills\devops", + ".github\skills\documentation", + ".github\skills\research", + ".github\skills\project-management", + ".github\skills\ai", + ".github\skills\language" +) +# Add Claude Code bridge directory +$dirs += ".claude\skills" +foreach ($d in $dirs) { + New-Item -ItemType Directory -Path $d -Force | Out-Null +} +``` + +Add domain-specific instruction folders as needed (e.g., `blazor/`, `cqrs/`, +`database/`, `domain/`). + +### Final Structure + +``` +your-project/ +├── AGENTS.md +├── CLAUDE.md +├── GEMINI.md +├── .github/ +│ ├── copilot-instructions.md +│ ├── copilot-mcp.json +│ ├── copilot-setup-steps.yml +│ ├── lsp.json +│ ├── SETUP-GUIDE.md ← This file +│ ├── instructions/ +│ │ ├── architecture/ +│ │ │ └── clean-architecture.instructions.md +│ │ ├── security/ +│ │ │ └── owasp-top10.instructions.md +│ │ ├── testing/ +│ │ │ └── testing-standards.instructions.md +│ │ ├── resilience/ +│ │ │ └── polly-patterns.instructions.md +│ │ ├── memory/ +│ │ │ └── memory-optimization.instructions.md +│ │ ├── development/ +│ │ │ └── mvp-first.instructions.md ← MVP anti-over-engineering rules +│ │ ├── blazor/ +│ │ │ └── component-patterns.instructions.md +│ │ ├── cqrs/ +│ │ │ └── mediatr-patterns.instructions.md +│ │ ├── database/ +│ │ │ └── ef-core-patterns.instructions.md +│ │ ├── domain/ +│ │ │ └── ddd-guidelines.instructions.md +│ │ └── {your-domain}/ +│ │ └── {your-rules}.instructions.md +│ ├── skills/ ← Reusable AI skills catalog +│ │ ├── CATALOG.md ← Master index of all skills +│ │ ├── code-quality/ +│ │ │ ├── code-reviewer/ +│ │ │ │ ├── SKILL.md ← Lean core (4-6 KB) +│ │ │ │ └── references/ ← Deep-dive files (2-4 KB each) +│ │ │ │ ├── review-checklist.md +│ │ │ │ ├── common-issues.md +│ │ │ │ └── ... +│ │ │ ├── refactor-planner/ +│ │ │ ├── code-documenter/ +│ │ │ └── debugging-wizard/ +│ │ ├── security/ +│ │ │ ├── owasp-audit/ +│ │ │ ├── secret-scanner/ +│ │ │ ├── threat-modeler/ +│ │ │ ├── authentication/ ← Auth patterns (Entra ID, JWT, OIDC) +│ │ │ └── authorization/ ← AuthZ patterns (policies, claims, Blazor) +│ │ ├── architecture/ +│ │ ├── testing/ +│ │ ├── database/ +│ │ ├── devops/ +│ │ ├── documentation/ +│ │ ├── research/ +│ │ ├── project-management/ +│ │ ├── ai/ ← Agent orchestration, MCP, prompts +│ │ └── language/ ← .NET Core, C# deep expertise +│ └── extensions/ +│ ├── security-scanner/ +│ │ └── extension.mjs +│ ├── build-guardian/ +│ │ └── extension.mjs +│ ├── context-optimizer/ +│ │ └── extension.mjs +│ ├── research-first/ +│ │ └── extension.mjs +│ ├── doc-sync/ +│ │ └── extension.mjs +│ └── dotnet-conventions/ +│ └── extension.mjs +├── .claude/ ← Claude Code specific +│ ├── settings.json +│ ├── skills/ ← Bridge files for /skills discovery +│ │ ├── code-reviewer/SKILL.md ← Bridges to .github/skills/code-reviewer/ +│ │ ├── owasp-audit/SKILL.md ← Bridges to .github/skills/owasp-audit/ +│ │ ├── agent-orchestrator/SKILL.md ← Bridges to .github/skills/agent-orchestrator/ +│ │ └── ... (37 total — one per universal skill) +│ └── rules/ ← Scoped rules (auto-loaded by file path) +│ ├── clean-architecture.md ← paths: ["**/*.cs"] +│ ├── blazor-components.md ← paths: ["**/*.razor", "**/*.razor.cs"] +│ ├── cqrs-mediatr.md ← paths: ["**/Commands/**", "**/Queries/**"] +│ ├── ef-core.md ← paths: ["**/Infrastructure/**"] +│ ├── owasp-security.md ← paths: ["**/*.cs", "**/*.razor"] +│ ├── memory-optimization.md ← paths: ["**/*"] (always active) +│ ├── mvp-first.md ← paths: ["**/*"] (always active) +│ ├── ddd-domain.md ← paths: ["**/Domain/**"] +│ ├── polly-resilience.md ← paths: ["**/Services/**"] +│ └── testing-standards.md ← paths: ["**/*Test*"] +└── YourProject.sln +``` + +--- + +## Step 2: Model Instruction Files (Root) + +These files sit at the repo root and are auto-discovered by Copilot CLI. + +### `AGENTS.md` — Universal Agent Instructions + +This is the **primary identity file** all AI models read. Include: + +```markdown +# Project Name — Agent Instructions + +## Project Identity +- What the project does (1-2 sentences) +- Target users / domain + +## Architecture +- Pattern (Clean Architecture, Vertical Slice, etc.) +- Layer map with directory names +- Dependency flow diagram (ASCII) + +## Mandatory Rules +- [ ] List non-negotiable rules (e.g., "always use code-behind") +- [ ] Security requirements +- [ ] Documentation sync requirements + +## Key Design Patterns +- List patterns in use with where they're applied + +## Anti-Patterns +- What NOT to do (with brief justification) +``` + +### `CLAUDE.md` / `GEMINI.md` — Model-Specific Optimization + +Tailor prompting patterns per model's strengths: + +| Model | Emphasize | +|-------|-----------| +| **Claude** | Structured reasoning, chain-of-thought, systematic OWASP enumeration | +| **Gemini** | Code search, dependency graph mapping, cross-reference analysis | + +--- + +## Step 3: Master Copilot Instructions + +### `.github/copilot-instructions.md` + +This is the **single most important file** — Copilot reads it for every session. + +Template structure: + +```markdown +# {Project Name} — Copilot Instructions + +## Project Overview +[1-paragraph description] + +## Technology Stack +| Technology | Version | Purpose | +|------------|---------|---------| +| .NET | 10 | Runtime | +| ... | ... | ... | + +## Architecture +[Layer diagram, dependency rules] + +## File Organization +| Directory | Layer | Contents | +|-----------|-------|----------| +| ... | ... | ... | + +## Conventions +- Naming, formatting, patterns + +## Domain Rules +- Business-specific rules the AI must follow +``` + +**Keep it under 10 KB** — this loads into every conversation. + +--- + +## Step 4: Scoped Instruction Files + +These activate **only when the AI touches matching files**, keeping context lean. + +### Format + +Each file needs a glob at the top: + +```markdown +--- +applyTo: "**/*.cs" +--- + +# Rule Title + +## Rules +... +``` + +### Recommended Scopes for .NET Projects + +| File | Glob | Purpose | +|------|------|---------| +| `architecture/*.instructions.md` | `**/*.cs` | Layer dependency rules | +| `blazor/*.instructions.md` | `**/*.razor*` | Component patterns | +| `security/*.instructions.md` | `**/*.cs, **/*.razor` | OWASP rules | +| `testing/*.instructions.md` | `**/*Test*/**` | Test conventions | +| `database/*.instructions.md` | `**/Data/**, **/Migrations/**` | EF Core patterns | +| `resilience/*.instructions.md` | `**/Services/**, **/Infrastructure/**` | Polly patterns | +| `memory/*.instructions.md` | `**/*` | Context window optimization | + +### Adapting for Non-.NET Projects + +| Stack | Suggested Scopes | +|-------|-----------------| +| **React/TypeScript** | `components/`, `hooks/`, `api/`, `store/`, `**/*.test.ts` | +| **Python/Django** | `models/`, `views/`, `serializers/`, `tests/`, `migrations/` | +| **Go** | `cmd/`, `internal/`, `pkg/`, `**/*_test.go` | +| **Java/Spring** | `controller/`, `service/`, `repository/`, `**/test/**` | + +--- + +## Step 5: Extensions (Skills, Hooks, Agents) + +Extensions are Node.js ES modules (`.mjs`) that run as child processes. + +### Anatomy of an Extension + +```javascript +import { joinSession } from "@github/copilot-sdk/extension"; + +const session = await joinSession({ + hooks: { + // Intercept and modify behavior at lifecycle points + onUserPromptSubmitted: async (input) => { /* ... */ }, + onPreToolUse: async (input) => { /* ... */ }, + onPostToolUse: async (input) => { /* ... */ }, + onSessionStart: async (input) => { /* ... */ }, + }, + tools: [ + // Custom tools the AI can invoke + { + name: "my_tool", + description: "What it does", + parameters: { type: "object", properties: { /* ... */ } }, + handler: async (args) => "result string", + }, + ], +}); +``` + +### Extension Catalog — What to Include + +| Extension | Transferable? | Adapt For | +|-----------|--------------|-----------| +| **security-scanner** | ✅ Universal | Adjust secret patterns per stack | +| **build-guardian** | ✅ Change build command | `npm run build`, `go build`, `mvn package` | +| **context-optimizer** | ✅ Update project summary | Change the hardcoded summary text | +| **research-first** | ✅ Universal | Adjust docs/ path if different | +| **doc-sync** | ⚠️ Project-specific | Rewrite feature→docs mapping | +| **dotnet-conventions** | ❌ .NET only | Replace with eslint/pylint/golint hooks | + +### Adapting `build-guardian` for Other Stacks + +```javascript +// Node.js/TypeScript +const buildCmd = isWindows ? "npm.cmd" : "npm"; +const buildArgs = ["run", "build"]; +const testCmd = isWindows ? "npm.cmd" : "npm"; +const testArgs = ["run", "test"]; + +// Go +const buildCmd = "go"; +const buildArgs = ["build", "./..."]; +const testCmd = "go"; +const testArgs = ["test", "./..."]; + +// Python +const buildCmd = "python"; +const buildArgs = ["-m", "pytest"]; +``` + +### Critical Rules for Extensions + +1. **Tool names must be globally unique** across all extensions +2. **Never use `console.log()`** — stdout is JSON-RPC. Use `session.log()` +3. **Only `.mjs` files** — TypeScript not supported +4. **`@github/copilot-sdk` auto-resolves** — don't `npm install` it +5. **Reload after changes**: use `/clear` or the `extensions_reload` command + +--- + +## Step 6: Skills Catalog (Reusable AI Skills) + +The skills catalog provides **reusable, cross-platform AI skills** that work with Copilot CLI, +Claude, and Gemini. Each skill follows the **Jeffallan `references/` pattern** for memory optimization. + +### What is a Skill? + +A skill is a structured markdown file (`SKILL.md`) that tells AI assistants *how* to perform +a specific task — code review, security audit, test generation, etc. Skills include: + +- **YAML frontmatter** — metadata, triggers, platform targeting +- **Core Workflow** — numbered steps with validation checkpoints +- **Reference Guide** — lazy-loaded deep-dive files (memory optimization) +- **Constraints** — MUST DO / MUST NOT DO rules +- **Output Template** — expected deliverable format + +### The `references/` Pattern (Memory Optimization) + +This is the key innovation for token savings. Instead of one large SKILL.md file: + +``` +# WITHOUT references/ (old pattern — 15-18 KB loaded every time) +skills/owasp-audit/SKILL.md ← 17 KB monolithic file + +# WITH references/ (new pattern — 5 KB base + surgical deep-dives) +skills/owasp-audit/ + SKILL.md ← 5 KB core (always loaded) + references/ + injection-prevention.md ← 3 KB (loaded ONLY when doing SQL injection work) + broken-auth.md ← 3 KB (loaded ONLY when doing auth review) + access-control.md ← 3 KB (loaded ONLY when doing access control) + crypto-failures.md ← 3 KB (loaded ONLY when doing crypto review) +``` + +**Result: ~60-70% token savings per skill invocation.** + +The SKILL.md includes a **Reference Guide table** that tells the AI *when* to load each file: + +```markdown +## Reference Guide + +| Topic | Reference | Load When | +|-------|-----------|-----------| +| Injection Prevention | `references/injection-prevention.md` | SQL injection, XSS, command injection | +| Authentication | `references/broken-auth.md` | Auth failures, session management | +| Access Control | `references/access-control.md` | Broken access control (A01) | +| Crypto Failures | `references/crypto-failures.md` | Data exposure, weak encryption | +``` + +### SKILL.md Format + +```yaml +--- +name: skill-name +description: "What this skill does and when to invoke it" +license: MIT +allowed-tools: Read, Grep, Glob, Bash +metadata: + author: YourOrg + version: "2.0.0" + domain: code-quality # category + triggers: review code, PR review # activation phrases + role: specialist # specialist | reviewer | expert + scope: review # review | implementation | analysis | design + platforms: copilot-cli, claude, gemini + output-format: report # report | code | document | analysis + related-skills: refactor-planner, test-generator +--- + +# Skill Name + +One-sentence role definition. + +## When to Use This Skill +- Trigger scenario 1 +- Trigger scenario 2 + +## Core Workflow +1. **Step** — Description. _Checkpoint: verify X before proceeding._ +2. **Step** — Description. + +## Reference Guide +| Topic | Reference | Load When | +|-------|-----------|-----------| +| Topic 1 | `references/topic-1.md` | When doing X | + +## Quick Reference +(1-2 inline code examples) + +## Constraints +### MUST DO +### MUST NOT DO + +## Output Template +(Expected deliverable structure) +``` + +### Skill Categories (11) + +| # | Category | Skills | Description | +|---|----------|--------|-------------| +| 1 | `code-quality` | code-reviewer, refactor-planner, code-documenter, debugging-wizard | Code review, refactoring, documentation, debugging | +| 2 | `security` | owasp-audit, secret-scanner, threat-modeler, authentication, authorization | Security audit, secrets, threats, auth | +| 3 | `architecture` | architecture-reviewer, design-pattern-advisor, dependency-analyzer, legacy-modernizer | Architecture review, patterns, dependencies, migration | +| 4 | `testing` | test-generator, tdd-coach, test-coverage-analyzer | Test generation, TDD, coverage | +| 5 | `database` | schema-reviewer, query-optimizer | Schema review, query optimization | +| 6 | `devops` | ci-cd-builder, deployment-preflight, monitoring-expert, chaos-engineer | CI/CD, deployment, monitoring, resilience | +| 7 | `documentation` | readme-generator, adr-creator, api-documenter | README, ADR, API docs | +| 8 | `research` | codebase-explorer, tech-spike-planner, spec-miner | Exploration, spikes, reverse engineering | +| 9 | `project-management` | spec-writer, issue-creator, feature-forge | Specs, issues, requirements | +| 10 | `ai` | mcp-developer, prompt-engineer, agent-orchestrator | MCP, prompts, agent coordination | +| 11 | `language` | dotnet-core-expert, csharp-developer | .NET Core, C# deep expertise | + +### Creating a New Skill + +```bash +# 1. Create skill directory with references +mkdir -p .github/skills/{category}/{skill-name}/references + +# 2. Create SKILL.md with frontmatter + sections +# 3. Create 3-5 reference files for deep-dive content +# 4. Update CATALOG.md master index +``` + +### Adapting Skills for Your Stack + +Skills are stack-agnostic by design. To adapt for a different stack: + +1. **Keep the workflow and constraints** — they're universal +2. **Replace code examples** — swap C# for Python/Go/Java in Quick Reference +3. **Update reference files** — replace `.NET` patterns with your framework's equivalents +4. **Update triggers** — match your team's vocabulary + +| Original (.NET) | React/TypeScript | Python/Django | Go | +|-----------------|------------------|---------------|----| +| `xUnit + Moq` | `Jest + React Testing Library` | `pytest + unittest.mock` | `testing + testify` | +| `EF Core` | `Prisma / Drizzle` | `Django ORM` | `GORM / sqlc` | +| `FluentValidation` | `Zod / Yup` | `Pydantic / marshmallow` | `go-playground/validator` | +| `MediatR` | `tRPC` | `Django signals` | `Go channels` | +| `Blazor AuthorizeView` | `Next-Auth + middleware` | `Django permissions` | `casbin` | + +--- + +## Step 6B: Claude Code Bridge Skills (`.claude/skills/`) + +Claude Code discovers skills from `.claude/skills/`, not `.github/skills/`. To make all +universal skills appear in Claude's `/skills` menu, create **bridge files** that redirect +to the universal definitions. + +### Why a Bridge? + +``` +.github/skills/ ← Universal source of truth (all models) +.claude/skills/ ← Claude Code discovery layer (bridge files only) +``` + +- **Single source of truth** stays in `.github/skills/` +- Bridge files are thin (~20 lines) — just YAML frontmatter + read instruction +- `/skills` in Claude Code shows all 36 skills +- Users invoke via `/skill-name` (e.g., `/owasp-audit`, `/code-reviewer`) + +### Bridge File Template + +Create `.claude/skills/{skill-name}/SKILL.md` for each skill: + +```markdown +--- +name: {skill-name} +description: {Brief description — shown in /skills listing} +--- + +# {Skill Display Name} + +> **Bridge to universal skill catalog.** The full skill definition lives in +> `.github/skills/{category}/{skill-name}/SKILL.md`. + +## Instructions + +1. **Read the full skill:** Open `.github/skills/{category}/{skill-name}/SKILL.md` +2. **Follow the Core Workflow** steps defined in that file +3. **Load references on demand** from `.github/skills/{category}/{skill-name}/references/` +4. **Never load all references at once** — progressive disclosure saves tokens +``` + +### Automation Script (PowerShell) + +Generate all bridge files from your skills catalog: + +```powershell +# Define skills: @{ name="skill-name"; cat="category"; desc="description" } +$skills = @( + @{ name="code-reviewer"; cat="code-quality"; desc="Review code for correctness and style" }, + @{ name="owasp-audit"; cat="security"; desc="Audit code against OWASP Top 10" }, + # ... add all your skills +) + +foreach ($s in $skills) { + $dir = ".claude\skills\$($s.name)" + New-Item -ItemType Directory -Path $dir -Force | Out-Null + @" +--- +name: $($s.name) +description: $($s.desc) +--- +# $($s.name) +> Bridge to universal skill catalog. +> Full definition: ``.github/skills/$($s.cat)/$($s.name)/SKILL.md`` +## Instructions +1. Read ``.github/skills/$($s.cat)/$($s.name)/SKILL.md`` +2. Follow the Core Workflow steps +3. Load references on demand from ``references/`` +"@ | Set-Content "$dir\SKILL.md" -Encoding UTF8 +} +``` + +### Bash equivalent: + +```bash +# For each skill, create the bridge: +for skill_name in code-reviewer owasp-audit agent-orchestrator; do + mkdir -p ".claude/skills/${skill_name}" + cat > ".claude/skills/${skill_name}/SKILL.md" << 'EOF' +--- +name: SKILL_NAME +description: SKILL_DESC +--- +# Bridge — read .github/skills/{category}/{skill}/SKILL.md +EOF +done +``` + +### Verification + +After creating bridge files, run `/skills` in Claude Code — all 37 skills should appear. + +--- + +## Step 6C: Claude Code Scoped Rules (`.claude/rules/`) + +Claude Code's equivalent of Copilot's `.github/instructions/` scoped instructions. +Rules in `.claude/rules/` are **auto-loaded** when Claude touches files matching the `paths:` globs. + +### How It Maps + +| Copilot (`.github/instructions/`) | Claude Code (`.claude/rules/`) | Targeting | +|---|---|---| +| `applyTo: "**/*.cs"` | `paths: ["**/*.cs"]` | Same glob syntax | +| Auto-loaded per file | Auto-loaded per file | Same behavior | +| Full detail (120-340 lines) | Condensed (40-80 lines) + reference link | Claude = lean | + +### Rule File Template + +```markdown +--- +paths: + - "**/*.cs" + - "**/*.razor" +description: One-line description of what this rule covers +--- + +# Rule Name + +> Auto-loaded by Claude Code when working with matching files. +> Full reference: `.github/instructions/{category}/{filename}` + +## Key Rules + +- Rule 1... +- Rule 2... + +--- + +*Deep-dive: Read `.github/instructions/{category}/{filename}` for complete patterns.* +``` + +### Rules to Create + +| Rule File | Scoped To | Source | +|-----------|-----------|--------| +| `clean-architecture.md` | `**/*.cs` | `architecture/clean-architecture.instructions.md` | +| `blazor-components.md` | `**/*.razor`, `**/*.razor.cs`, `**/*.razor.css` | `blazor/component-patterns.instructions.md` | +| `cqrs-mediatr.md` | `**/Commands/**`, `**/Queries/**`, `**/Handlers/**` | `cqrs/mediatr-patterns.instructions.md` | +| `ef-core.md` | `**/Infrastructure/**`, `**/*DbContext*`, `**/*Repository*` | `database/ef-core-patterns.instructions.md` | +| `mvp-first.md` | `**/*` (always) | `development/mvp-first.instructions.md` | +| `ddd-domain.md` | `**/Domain/**`, `**/Entities/**`, `**/ValueObjects/**` | `domain/ddd-guidelines.instructions.md` | +| `memory-optimization.md` | `**/*` (always) | `memory/memory-optimization.instructions.md` | +| `polly-resilience.md` | `**/Infrastructure/**`, `**/Services/**` | `resilience/polly-patterns.instructions.md` | +| `owasp-security.md` | `**/*.cs`, `**/*.razor` | `security/owasp-top10.instructions.md` | +| `testing-standards.md` | `**/*Test*`, `**/*.Tests/**` | `testing/testing-standards.instructions.md` | + +### Maintenance + +When you update a `.github/instructions/` file, also update the matching `.claude/rules/` file. +The `.claude/rules/` files are condensed summaries — keep them under 80 lines. Point to the +full instruction file for deep-dive reference. + +--- + +## Step 7: MCP Server Configuration + +### `.github/copilot-mcp.json` + +```json +{ + "mcpServers": { + "your-db": { + "command": "npx", + "args": ["-y", "@anthropic/mcp-sqlserver"], + "env": { + "CONNECTION_STRING": "${env:YOUR_DB_CONNECTION_STRING}" + } + } + } +} +``` + +**Never hardcode connection strings** — always use `${env:VAR_NAME}`. + +### Common MCP Servers + +| Server | Package | Use Case | +|--------|---------|----------| +| SQL Server | `@anthropic/mcp-sqlserver` | Database exploration | +| PostgreSQL | `@anthropic/mcp-postgres` | Database exploration | +| Filesystem | `@anthropic/mcp-filesystem` | Sandboxed file access | +| GitHub | Built-in | Repo, issues, PRs | + +--- + +## Step 8: LSP Configuration + +### `.github/lsp.json` + +```json +{ + "lspServers": { + "csharp": { + "command": "dotnet", + "args": ["tool", "run", "csharp-ls", "--solution", "YourProject.sln"], + "fileExtensions": { + ".cs": "csharp" + } + } + } +} +``` + +### Common Language Servers + +| Language | Command | Install | +|----------|---------|---------| +| C# | `csharp-ls` | `dotnet tool install csharp-ls` | +| TypeScript | `typescript-language-server` | `npm i -g typescript-language-server` | +| Python | `pylsp` | `pip install python-lsp-server` | +| Go | `gopls` | `go install golang.org/x/tools/gopls@latest` | +| Rust | `rust-analyzer` | Via rustup | + +--- + +## Step 9: Cloud Agent Setup + +### `.github/copilot-setup-steps.yml` + +This configures the GitHub Copilot coding agent's CI environment: + +```yaml +steps: + - name: Setup runtime + uses: actions/setup-dotnet@v4 # or setup-node, setup-go, etc. + with: + dotnet-version: '10.0.x' + + - name: Install dependencies + run: dotnet restore YourProject.sln # or npm ci, go mod download, etc. + + - name: Build + run: dotnet build YourProject.sln --no-restore + + - name: Setup database + uses: ikalnytskyi/action-setup-postgres@v7 + with: + username: app_user + password: ${{ secrets.DB_PASSWORD }} + database: app_db +``` + +**All secrets via `${{ secrets.* }}`** — never inline credentials. + +--- + +## Checklist for New Projects + +### Phase 1: Foundation +- [ ] Create directory structure (Step 1) +- [ ] Write `AGENTS.md` with project identity and rules +- [ ] Write `CLAUDE.md` and `GEMINI.md` with model-specific guidance +- [ ] Write `.github/copilot-instructions.md` (most important file) + +### Phase 2: Scoped Instructions +- [ ] Add scoped instructions for your stack's key concerns +- [ ] Add `memory-optimization.instructions.md` (copy as-is — it's universal) +- [ ] Add `mvp-first.instructions.md` (copy as-is — it's universal) +- [ ] Add domain-specific instructions (architecture, testing, security, etc.) + +### Phase 3: Skills Catalog +- [ ] Copy `.github/skills/` directory (all categories) +- [ ] Adapt code examples in SKILL.md files for your stack +- [ ] Adapt reference files for your framework/language +- [ ] Update `CATALOG.md` with any added/removed skills +- [ ] Add authentication/authorization skills matching your auth provider +- [ ] Generate `.claude/skills/` bridge files (see Step 6B automation script) +- [ ] Verify `/skills` shows all skills in Claude Code + +### Phase 3B: Claude Code Rules (`.claude/rules/`) +- [ ] Create `.claude/rules/` directory +- [ ] Create condensed rule files for each `.github/instructions/` file (see Step 6C) +- [ ] Verify `paths:` globs match your project structure +- [ ] Test: Edit a `.cs` file in Claude Code → rules should auto-load + +### Phase 4: Extensions & Hooks +- [ ] Copy and adapt Copilot CLI extensions: + - [ ] `security-scanner` — update secret patterns + - [ ] `build-guardian` — update build/test commands + - [ ] `context-optimizer` — update project summary + - [ ] `research-first` — update docs path + - [ ] `doc-sync` — rewrite feature→docs mapping + - [ ] Stack-specific conventions extension +- [ ] Validate extensions: `node --check .github/extensions/*/extension.mjs` +- [ ] Create Claude Code hooks (`.claude/hooks/*.ps1` or `.sh`): + - [ ] `security-scanner` — PreToolUse blocker for secrets + - [ ] `dotnet-conventions` — PostToolUse convention checker + - [ ] `doc-sync-reminder` — PostToolUse docs reminder + - [ ] `build-reminder` — PostToolUse build reminder + - [ ] `research-first` — UserPromptSubmit guidance + - [ ] `context-optimizer` — SessionStart project context +- [ ] Add `hooks` section to `.claude/settings.json` +- [ ] Review hooks reference: `.github/docs/hooks-reference.md` + +### Phase 5: Infrastructure +- [ ] Configure MCP servers if using databases +- [ ] Configure LSP for your language +- [ ] Configure cloud agent setup steps +- [ ] Test: start Copilot CLI in the repo and verify extensions load + +--- + +## Maintenance + +- **Update instructions** when architecture or conventions change +- **Update extension hooks** when adding new directories or features +- **Update `context-optimizer` summary** when the tech stack evolves +- **Update `doc-sync` mappings** when adding new feature documentation +- **Update skills** when adding new frameworks or patterns +- **Update `CATALOG.md`** when adding or removing skills +- **Update this SETUP-GUIDE.md** when any structural changes are made +- **Run `/instructions`** in Copilot CLI to verify which files are loaded +- **Run `extensions_manage({ operation: "list" })`** to check extension health diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..6feac5a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,251 @@ +# Copilot Instructions — Project Conventions + +> Master project-level instructions for GitHub Copilot and all AI coding assistants. +> This file defines coding conventions, architecture patterns, and best practices +> for .NET / Blazor projects. Customize the examples to match your domain. + +## Project + +This file defines the **coding conventions and architecture standards** for this .NET / Blazor project. All AI assistants working on this codebase should follow these patterns. + +**Tech Stack:** +- .NET 10, Blazor Server (interactive SSR) +- PostgreSQL with EF Core (Npgsql) +- MediatR (CQRS vertical slices) +- Bootstrap 5 (enterprise LOB UI) +- IStringLocalizer with .resx files (en-US, es-MX) + +--- + +## Architecture + +**Clean Architecture** with **CQRS** organized as vertical slices. + +### Layer Map + +``` +Presentation Components/ Blazor pages, layouts, scoped CSS +Application Features/{Domain}/ MediatR command/query handlers +Domain Models/ Entities, value objects, enums + Events/ DomainEvent, IEventBus, domain event classes + Services/Strategies/ Strategy interfaces (IChargeable, IRefundable, etc.) +Infrastructure Data/ AppDbContext, repository implementations + Services/ External service integrations + Infrastructure/Auth/ Authentication handlers + Infrastructure/Middleware/ Exception handling, logging middleware +``` + +### Dependency Direction — MANDATORY + +``` +Components/ ──→ Features/ ──→ Models/ ←── Data/ + Events/ ←── Services/ + Strategies/ ←── Infrastructure/ +``` + +Inner layers (Models, Events, Strategies) **never** reference outer layers. Infrastructure implements domain interfaces. + +--- + +## Design Patterns + +| Pattern | Where | Purpose | +|---|---|---| +| **Strategy** | `Services/Strategies/` | External provider abstraction (e.g., payment, notification) | +| **Repository** | `Data/Repositories/` | Data access abstraction — EF Core hidden from business logic | +| **Factory** | `IStrategyFactory` | Runtime resolution of strategy implementation by provider name | +| **Event Bus** | `Events/IEventBus` | Decouple side effects from business operations | +| **MediatR/CQRS** | `Features/{Domain}/` | Separate command (write) and query (read) paths | +| **Vertical Slice** | `Features/{Domain}/*/` | Each feature is a self-contained slice: command + handler + (optional validator) | + +### Strategy Interfaces (ISP-Compliant) + +```csharp +IPaymentProcessor // Marker — every provider implements this +├── IChargeable // ChargeAsync(amount, paymentMethodId, idempotencyKey) +├── IRefundable // RefundAsync(transactionReference, idempotencyKey) +└── ICancellable // CancelAsync(transactionReference, idempotencyKey) +``` + +Providers implement only the capabilities they support. One provider may implement all three; another might only implement `IChargeable`. + +--- + +## Blazor Rules — MANDATORY + +### Code-Behind Pattern (Always) + +Every component produces **three files**: + +``` +ComponentName.razor ← Markup only. No @code {} blocks. Ever. +ComponentName.razor.cs ← sealed partial class. All logic here. +ComponentName.razor.css ← Scoped CSS. Bootstrap 5 + custom overrides. +``` + +### Component Conventions + +- Inject services via `[Inject]` in code-behind — not `@inject` in markup (markup `@inject` is acceptable for `IStringLocalizer` only). +- Use `IStringLocalizer` for all user-facing text. +- Use `IMediator` for all data operations — never call repositories or services directly from components. +- Use `[CascadingParameter] Task` for auth state. +- Implement `IDisposable` / `IAsyncDisposable` when using event handlers or JS interop. +- Override `OnInitializedAsync` for data loading — not the constructor. + +--- + +## Security — OWASP Top 10 + +| Category | Requirement | +|---|---| +| **Broken Access Control** | `[Authorize]` on every endpoint. Policy-based auth (`"ApiAccess"`). Default deny. | +| **Cryptographic Failures** | Secrets via env vars or Key Vault. Never in source or `appsettings.json`. | +| **Injection** | Parameterized queries only (EF Core). No raw SQL string concatenation. | +| **Insecure Design** | Strategy Pattern enforces external provider boundaries. | +| **Security Misconfiguration** | HTTPS + HSTS enforced. Antiforgery tokens. Swagger only in Development. | +| **Vulnerable Components** | Keep NuGet packages updated. Monitor for CVEs. | +| **Auth Failures** | Validate credentials on every request. Use policy-based auth. | +| **Logging Failures** | Structured logging. Correlation IDs. **Never log PII, tokens, or secrets.** | + +--- + +## Business Operation Rules — MANDATORY + +1. **Idempotency keys** on every write operation that calls external services. All strategy methods require an `idempotencyKey` parameter. +2. **Domain events after persistence** — publish domain events (e.g., `OrderCreatedEvent`, `OrderCompletedEvent`) only after `SaveChangesAsync`. +3. **State machine integrity** — enforce valid status transitions in the domain model. Invalid transitions throw domain exceptions. +4. **External references** — store provider-specific IDs (e.g., payment intent ID, tracking number) on the entity for reconciliation. +5. **Never modify monetary amounts** after initial creation. Amounts flow from the domain model to external providers — no manual arithmetic. +6. **Audit trail** — every state transition must be traceable via domain events. + +--- + +## CQRS Flow + +All business operations go through MediatR: + +``` +UI/API ──→ IMediator.Send(Command/Query) + │ + ▼ + Handler (Features/{Domain}/*/Handler.cs) + │ + ├──→ Validate input + ├──→ Resolve strategy (IStrategyFactory) + ├──→ Execute operation (IChargeable, etc.) + ├──→ Persist via repository interface + ├──→ Publish domain event (IEventBus) + └──→ Return result +``` + +### Example Slices + +| Slice | Type | Purpose | +|---|---|---| +| `CreateOrder/` | Command | Create a new order with initial validation | +| `CompleteOrder/` | Command | Mark order as completed and trigger side effects | +| `CancelOrder/` | Command | Cancel an order and initiate reversal if needed | +| `GetOrder/` | Query | Read single order by ID | +| `ListOrders/` | Query | List orders with filtering and pagination | + +--- + +## Code Conventions + +| Convention | Rule | +|---|---| +| Namespaces | File-scoped (`namespace ProjectName.X;`) | +| Nullability | Enabled — use `string?` for nullable | +| Inheritance | `sealed` by default on concrete classes | +| DTOs | `record` types with `init` properties | +| Async | `async Task` / `async Task` with `CancellationToken` | +| Naming | Intention-revealing. No abbreviations except DTO, ID, HTTP. | +| Guard clauses | Fail fast at method entry — no deep nesting | +| Constants | No magic strings or numbers — use `const` or `enum` | + +--- + +## Localization + +- **Resource files:** `Resources/SharedResource.resx` (en-US default), `SharedResource.es.resx` (es-MX) +- **Component resources:** `Resources/Components/` for component-specific strings +- **Injection:** `IStringLocalizer` in code-behind files +- **Markup:** `@L["KeyName"]` for localized strings +- **Culture switch:** `GET /culture/set?culture={code}&redirectUri={path}` — cookie-based +- **All user-facing strings must be localized** — no hardcoded text in `.razor` or `.razor.cs` files + +--- + +## Data Model (Example) + +``` +Order +├── Id int (PK, auto-increment) +├── CustomerId string (required) +├── Amount decimal (required) +├── Description string (required) +├── Status string — "Pending" | "Processing" | "Completed" | "Cancelled" +├── ExternalReference string? — external provider transaction ID +├── ExternalProvider string? — e.g., "Stripe", "PayPal" +└── CreatedAt DateTime (UTC) +``` + +Replace `Order` with your domain's aggregate root entity. Add fields as needed for your domain. + +--- + +## Documentation — MANDATORY + +Update `docs/` when features change. Maintain numbered documentation files: + +``` +00-Architecture-Overview Cross-cutting architecture +01-Feature-Name Feature-specific workflow docs +02-Feature-Name ... +``` + +Each feature should have a corresponding doc. New features without a matching doc → create the next numbered file (e.g., `03-Feature-Name`). + +--- + +## DI Registration (Program.cs) + +When adding new services, register them in `Program.cs` following existing patterns: + +```csharp +// Repository +builder.Services.AddScoped(); + +// Strategy (new provider implementation) +builder.Services.AddScoped(); + +// Event handler +// (auto-discovered by MediatR if implementing INotificationHandler) + +// New service +builder.Services.AddScoped(); +``` + +MediatR handlers are auto-discovered — no manual registration needed. + +--- + +## Agent Orchestration — MANDATORY + +When delegating work to sub-agents (parallel or serial): + +1. **ALWAYS present the delegation plan to the user** before spawning any agent. +2. **Use `ask_user`** to show: agent count, agent types, task descriptions, blast radius, estimated tokens. +3. **Wait for explicit approval** — do not assume approval from silence or prior permissions. +4. **Never spawn agents without the user seeing and approving the plan first.** + +See `.github/skills/agent-orchestrator/SKILL.md` (Step 3) for the full approval gate workflow. + +--- + +## Skills Catalog + +See **AGENTS.md → Skills Catalog** for the complete skill loading instructions, categories, +and usage examples. Skills are universal across all models. + +**Quick start:** Read `.github/skills/CATALOG.md` to browse all 36 skills across 11 categories. diff --git a/.github/copilot-mcp.json b/.github/copilot-mcp.json new file mode 100644 index 0000000..ae8a288 --- /dev/null +++ b/.github/copilot-mcp.json @@ -0,0 +1,20 @@ +{ + "mcpServers": { + "sqlserver": { + "command": "npx", + "args": ["-y", "@anthropic/mcp-sqlserver"], + "env": { + "SQLSERVER_CONNECTION_STRING": "${env:DB_CONNECTION_STRING}" + }, + "description": "SQL Server MCP for database exploration. Connection string must be set via DB_CONNECTION_STRING environment variable — never hardcode credentials." + }, + "postgres": { + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-postgres"], + "env": { + "POSTGRES_CONNECTION_STRING": "${env:POSTGRES_CONNECTION_STRING}" + }, + "description": "PostgreSQL MCP server. Set POSTGRES_CONNECTION_STRING env var to connect." + } + } +} diff --git a/.github/copilot-setup-steps.yml b/.github/copilot-setup-steps.yml new file mode 100644 index 0000000..c0870fe --- /dev/null +++ b/.github/copilot-setup-steps.yml @@ -0,0 +1,37 @@ +# copilot-setup-steps.yml +# Configures the environment for GitHub Copilot coding agent (cloud agent). +# These steps run before Copilot begins working on pull requests or issues. +# See: https://docs.github.com/en/copilot/customizing-copilot/customizing-the-development-environment-for-copilot-coding-agent + +steps: + # Install the .NET 10 SDK (net10.0 target framework) + - name: Setup .NET 10 SDK + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + + # Node.js is needed for MCP server extensions (npx-based tools) + - name: Setup Node.js for MCP extensions + uses: actions/setup-node@v4 + with: + node-version: "20" + + # Restore all NuGet packages + - name: Restore NuGet packages + run: dotnet restore + + # Build the solution to ensure the codebase compiles before Copilot makes changes. + # This gives the agent a known-good baseline to diff against. + - name: Build solution + run: dotnet build --no-restore + + # Set environment variables for the development/CI context. + - name: Configure environment variables + run: | + echo "ASPNETCORE_ENVIRONMENT=Development" >> $GITHUB_ENV + + # Install the csharp-ls language server as a global dotnet tool. + # This enables rich code intelligence (go-to-definition, references, diagnostics) + # for the Copilot agent when navigating the codebase. + - name: Install C# language server + run: dotnet tool install --global csharp-ls diff --git a/.github/docs/README.md b/.github/docs/README.md new file mode 100644 index 0000000..05e0d98 --- /dev/null +++ b/.github/docs/README.md @@ -0,0 +1,27 @@ +# AI Context Documentation Index + +This folder contains AI-assistant support docs. Use this file as the quick map for where to load context from. + +## Start Here + +1. Project overview: `README.md` +2. Architecture and APIs: `docs/01-architecture/` +3. Feature behavior: `docs/03-features/` +4. Security guidance: `docs/04-security/` +5. Troubleshooting and fixes: `docs/05-troubleshooting/` +6. Reusable implementation patterns: `docs/06-patterns/` +7. Deployment runbooks: `docs/02-deployment/` + +## AI/Agent-Specific Docs + +- Hook lifecycle reference: `.github/docs/hooks-reference.md` +- Project-wide AI instructions: `AGENTS.md`, `CLAUDE.md`, `GEMINI.md` +- Skill catalog and workflows: `.github/skills/CATALOG.md` and `.github/skills/*/SKILL.md` + +## Context-Saving Guidance + +- For architecture changes, read `docs/01-architecture/*` first. +- For feature work, read the matching file in `docs/03-features/*`. +- For security/compliance-sensitive work, check `docs/04-security/*` before edits. +- For known incidents, load `docs/05-troubleshooting/*`. +- When adding new behavior, update the relevant file under `docs/` and keep this index accurate. diff --git a/.github/docs/hooks-reference.md b/.github/docs/hooks-reference.md new file mode 100644 index 0000000..69122a6 --- /dev/null +++ b/.github/docs/hooks-reference.md @@ -0,0 +1,154 @@ +# Hooks Reference — Copilot CLI vs Claude Code + +> Cross-platform hooks comparison for the the project AI development framework. +> Both platforms fire hooks at lifecycle events. This doc maps events, capabilities, and our implementations. + +--- + +## Hook Events Comparison + +### Copilot CLI Hook Events + +| Event | When It Fires | Can Block | Available In | +|-------|--------------|-----------|-------------| +| `onSessionStart` | Session begins | No | `joinSession()` | +| `onSessionEnd` | Session terminates | No | `joinSession()` | +| `onUserPromptSubmitted` | User submits a prompt, before processing | No* | `joinSession()` | +| `onPreToolUse` | Before a tool call executes | Yes (return `"reject"`) | `joinSession()` | +| `onPostToolUse` | After a tool call succeeds | No | `joinSession()` | +| `onErrorOccurred` | When an error occurs during tool execution | No | `joinSession()` | + +*\* Can inject `additionalContext` to influence behavior but cannot block the prompt.* + +**Implementation:** Node.js ES modules (`.mjs`) in `.github/extensions/*/extension.mjs` + +--- + +### Claude Code Hook Events + +| Event | When It Fires | Can Block | Matcher | +|-------|--------------|-----------|---------| +| `SessionStart` | Session begins or resumes | No | `startup`, `resume`, `clear`, `compact` | +| `SessionEnd` | Session terminates | No | `clear`, `resume`, `logout`, etc. | +| `UserPromptSubmit` | User submits a prompt, before processing | Yes | *(no matcher)* | +| `PreToolUse` | Before a tool call executes | Yes (`permissionDecision: "deny"`) | Tool name regex: `Bash`, `Edit\|Write` | +| `PostToolUse` | After a tool call succeeds | No | Tool name regex | +| `PostToolUseFailure` | After a tool call fails | No | Tool name regex | +| `PermissionRequest` | Permission dialog appears | Yes | Tool name regex | +| `PermissionDenied` | Tool call denied by classifier | No (but can `retry: true`) | Tool name regex | +| `Notification` | Claude sends notification | No | `permission_prompt`, `idle_prompt` | +| `SubagentStart` | Subagent spawned | No | Agent type: `Bash`, `Explore`, `Plan` | +| `SubagentStop` | Subagent finishes | Yes | Agent type | +| `TaskCreated` | Task created via TaskCreate | No | *(no matcher)* | +| `TaskCompleted` | Task marked complete | No | *(no matcher)* | +| `Stop` | Claude finishes responding | Yes | *(no matcher)* | +| `StopFailure` | Turn ends due to API error | No | `rate_limit`, `server_error`, etc. | +| `TeammateIdle` | Agent team member going idle | No | *(no matcher)* | +| `InstructionsLoaded` | CLAUDE.md or rules file loads | No | `session_start`, `path_glob_match` | +| `ConfigChange` | Config file changes mid-session | No | `user_settings`, `project_settings` | +| `CwdChanged` | Working directory changes (`cd`) | No | *(always fires)* | +| `FileChanged` | Watched file changes on disk | Yes | Filename (e.g., `.envrc`) | +| `WorktreeCreate` | Git worktree being created | No | *(no matcher)* | +| `WorktreeRemove` | Git worktree being removed | No | *(no matcher)* | +| `PreCompact` | Before context compaction | No | `manual`, `auto` | +| `PostCompact` | After compaction completes | No | `manual`, `auto` | +| `Elicitation` | MCP server requests user input | No | MCP server name | +| `ElicitationResult` | User responds to MCP elicitation | No | MCP server name | + +**Implementation:** Shell scripts, HTTP endpoints, LLM prompts, or agent hooks in `.claude/settings.json` + +--- + +## Event Mapping: Copilot CLI ↔ Claude Code + +| Copilot CLI Event | Claude Code Equivalent | Notes | +|---|---|---| +| `onSessionStart` | `SessionStart` | Direct equivalent | +| `onSessionEnd` | `SessionEnd` | Direct equivalent | +| `onUserPromptSubmitted` | `UserPromptSubmit` | Claude can also block prompts | +| `onPreToolUse` | `PreToolUse` | Both can block; Claude has richer matcher syntax | +| `onPostToolUse` | `PostToolUse` | Direct equivalent | +| `onErrorOccurred` | `PostToolUseFailure` / `StopFailure` | Claude splits into tool vs API errors | +| *(none)* | `PermissionRequest` | Claude-only: intercept permission dialogs | +| *(none)* | `SubagentStart` / `SubagentStop` | Claude-only: subagent lifecycle | +| *(none)* | `InstructionsLoaded` | Claude-only: react to config loading | +| *(none)* | `PreCompact` / `PostCompact` | Claude-only: context compaction hooks | +| *(none)* | `FileChanged` | Claude-only: file watcher hooks | +| *(none)* | `CwdChanged` | Claude-only: directory change hooks | +| *(none)* | `Notification` | Claude-only: notification interception | +| *(none)* | `TaskCreated` / `TaskCompleted` | Claude-only: task lifecycle | +| *(none)* | `Stop` | Claude-only: validate before turn ends | + +--- + +## Our Implementations + +### Copilot CLI Extensions (`.github/extensions/`) + +| Extension | Hooks Used | Purpose | +|-----------|-----------|---------| +| **security-scanner** | `onPreToolUse`, `onPostToolUse`, `onUserPromptSubmitted` | Blocks secrets in writes, OWASP reminders, payment/auth context | +| **build-guardian** | `onPostToolUse` | Tracks modified `.cs` files, reminds to validate build | +| **context-optimizer** | `onSessionStart`, `onUserPromptSubmitted` | Injects project summary, warns on long prompts | +| **doc-sync** | `onSessionStart`, `onPostToolUse` | Reminds to update docs when source changes | +| **dotnet-conventions** | `onSessionStart`, `onPostToolUse` | Checks `.cs`/`.razor` conventions after edits | +| **research-first** | `onSessionStart`, `onUserPromptSubmitted` | Injects "read docs first" before implementation | + +### Claude Code Hooks (`.claude/settings.json` + `.claude/hooks/`) + +| Hook Script | Event | Matcher | Purpose | +|-------------|-------|---------|---------| +| **security-scanner.ps1** | `PreToolUse` | `Edit\|Write\|MultiEdit` | Blocks hardcoded secrets, API keys, connection strings | +| **dotnet-conventions.ps1** | `PostToolUse` | `Edit\|Write\|MultiEdit` | Checks code-behind, namespaces, scoped CSS | +| **doc-sync-reminder.ps1** | `PostToolUse` | `Edit\|Write\|MultiEdit` | Reminds to update docs for source changes | +| **build-reminder.ps1** | `PostToolUse` | `Edit\|Write\|MultiEdit` | Reminds to verify build after `.cs` changes | +| **research-first.ps1** | `UserPromptSubmit` | *(all)* | Injects research-first guidance | +| **context-optimizer.ps1** | `SessionStart` | *(all)* | Injects project architecture context | + +--- + +## Configuration Locations + +| Platform | Config File | Hook Scripts | +|----------|------------|-------------| +| **Copilot CLI** | `.github/extensions/*/extension.mjs` | Inline (Node.js ES modules) | +| **Claude Code** | `.claude/settings.json` → `hooks` | `.claude/hooks/*.ps1` (Windows) or `.sh` (Linux/Mac) | + +--- + +## Key Differences + +| Feature | Copilot CLI | Claude Code | +|---------|------------|-------------| +| **Language** | JavaScript (ES modules, `.mjs`) | Any (shell, PowerShell, HTTP, LLM prompt) | +| **Hook types** | Code callbacks only | `command`, `http`, `prompt`, `agent` | +| **Blocking** | `onPreToolUse` returns `"reject"` | `PreToolUse` outputs `permissionDecision: "deny"` | +| **Context injection** | Return `{ additionalContext: "..." }` | Output `{ "additionalContext": "..." }` JSON | +| **Custom tools** | `registerTool()` in extension | Not in hooks (use MCP servers instead) | +| **Matcher syntax** | Programmatic (`if` statements in code) | Regex on tool name + `if` field for arguments | +| **Total events** | 6 | 27 | +| **Async hooks** | All async by nature (Node.js) | `async: true` flag for background execution | +| **Discovery** | `.github/extensions/*/extension.mjs` | `.claude/settings.json` + script paths | +| **SDK** | `@github/copilot-sdk/extension` | stdin/stdout JSON protocol | + +--- + +## Adding New Hooks + +### Copilot CLI + +1. Create `.github/extensions/{name}/extension.mjs` +2. Import `joinSession` from `@github/copilot-sdk/extension` +3. Register hooks in the `joinSession()` call +4. Optionally register tools with `registerTool()` + +### Claude Code + +1. Create script in `.claude/hooks/{name}.ps1` (Windows) or `.sh` (Linux/Mac) +2. Add hook entry to `.claude/settings.json` under the appropriate event +3. Script reads JSON from stdin, outputs JSON to stdout +4. Use `exit 0` for no action, output JSON for context/decisions + +--- + +*Maintained as part of the the project AI Development Framework* diff --git a/.github/extensions/README.md b/.github/extensions/README.md new file mode 100644 index 0000000..bb26e06 --- /dev/null +++ b/.github/extensions/README.md @@ -0,0 +1,71 @@ +# Copilot CLI Extensions + +Custom JavaScript modules that extend GitHub Copilot CLI with hooks and tools. + +## At a Glance + +| Aspect | Detail | +|--------|--------| +| **SDK** | `@github/copilot-sdk/extension` — `joinSession()` API | +| **Runtime** | Node.js, ES modules (`.mjs`) | +| **Entry point** | `extension.mjs` per folder | +| **Capabilities** | `hooks` (session/tool lifecycle) and `tools` (custom tool definitions) | +| **Auto-load** | Extensions load on session start; run `extensions_reload` to pick up changes | + +## Current Extensions + +| Extension | Purpose | +|-----------|---------| +| `build-guardian` | Enforces build verification before commits | +| `context-optimizer` | Provides `project_summary` and `check_docs` tools for efficient context loading | +| `doc-sync` | Config-driven reminders to keep `docs/` and planning docs in sync with code changes | +| `dotnet-conventions` | `check_conventions` tool for .NET coding standards enforcement | +| `research-first` | Encourages codebase exploration before making changes | +| `security-scanner` | OWASP security scanning tools (`owasp_security_scan`, `check_secrets`) | +| `superpowers` | Workflow skill catalog and loader (`superpowers_catalog`, `superpowers_skill`) | + +## File Structure + +``` +extensions/ +└── my-extension/ + ├── extension.mjs ← Entry point (required) + └── config.json ← Optional configuration +``` + +## How to Create a New Extension + +1. **Scaffold:** Use `extensions_manage scaffold` with a name and description, or create the folder manually. +2. **Implement:** Export a default function that calls `joinSession()`, registering `hooks` and/or `tools`. +3. **Reload:** Run `extensions_reload` to activate without restarting the CLI. + +```js +// extension.mjs — minimal example +import { joinSession } from "@github/copilot-sdk/extension"; + +joinSession({ + hooks: { + onSessionStart: async (ctx) => { /* ... */ }, + }, + tools: { + my_tool: { + description: "Does something useful", + parameters: { /* JSON Schema */ }, + execute: async (params) => { /* ... */ }, + }, + }, +}); +``` + +## Key Rules + +- Extensions run in a **Node.js context** — they have access to `process.cwd()` and the filesystem. +- Extensions do **not** have access to the agent's conversation or chat context directly. +- Use ES module syntax (`import`/`export`) — CommonJS (`require`) is not supported. +- Keep extensions focused — one concern per extension. + +## See Also + +- [`.github/skills/`](../skills/) — Reusable AI workflow methodologies (markdown, not code) +- [`.github/instructions/`](../instructions/) — Pattern-matched context injection for AI agents +- [`.github/hooks/`](../hooks/) — Git hooks (trigger on git events, not AI events) diff --git a/.github/extensions/build-guardian/extension.mjs b/.github/extensions/build-guardian/extension.mjs new file mode 100644 index 0000000..316d611 --- /dev/null +++ b/.github/extensions/build-guardian/extension.mjs @@ -0,0 +1,218 @@ +import { joinSession } from "@github/copilot-sdk/extension"; +import { execFile } from "node:child_process"; +import { readdirSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { resolve, extname } from "node:path"; + +const SOLUTION_NAME = "EscrowApp.sln"; +const BUILD_TIMEOUT = 120_000; +const TEST_TIMEOUT = 120_000; +const MAX_BUFFER = 1024 * 1024 * 5; // 5MB + +const modifiedFiles = new Set(); +let lastReminderTime = 0; +const REMINDER_COOLDOWN = 30_000; // Only remind every 30 seconds + +function findSolutionRoot() { + // Walk up from cwd to find the .sln file + let dir = process.cwd(); + const root = resolve(dir, "/"); + while (dir !== root) { + try { + const files = readdirSync(dir); + if (files.includes(SOLUTION_NAME)) return dir; + } catch { + // Skip + } + dir = resolve(dir, ".."); + } + return process.cwd(); +} + +function runDotnetCommand(args, timeoutMs) { + const solutionRoot = findSolutionRoot(); + const solutionPath = resolve(solutionRoot, SOLUTION_NAME); + + return new Promise((res) => { + const child = execFile("dotnet", [...args, solutionPath], { + cwd: solutionRoot, + timeout: timeoutMs, + maxBuffer: MAX_BUFFER, + windowsHide: true, + }, (err, stdout, stderr) => { + const output = (stdout || "") + (stderr || ""); + if (err) { + if (err.killed) { + res({ success: false, output: `Command timed out after ${timeoutMs / 1000}s.\n${output}` }); + } else { + res({ success: false, output }); + } + } else { + res({ success: true, output }); + } + }); + }); +} + +function isWatchedFile(filePath) { + if (!filePath || typeof filePath !== "string") return false; + const ext = extname(filePath).toLowerCase(); + return ext === ".cs" || ext === ".csproj" || ext === ".razor"; +} + +async function hasTestProjects(solutionRoot) { + try { + const entries = await readdir(solutionRoot, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory() && entry.name.toLowerCase().includes("test")) { + return true; + } + } + // Also check if dotnet test would find anything by looking for test csproj references + return false; + } catch { + return false; + } +} + +const session = await joinSession({ + hooks: { + onPostToolUse: async (input) => { + const toolName = input.toolName; + if (toolName !== "create" && toolName !== "edit") return; + + const args = input.toolArgs; + if (!args || typeof args !== "object") return; + + const filePath = args.path; + if (!isWatchedFile(filePath)) return; + + modifiedFiles.add(filePath); + + const now = Date.now(); + if (now - lastReminderTime < REMINDER_COOLDOWN) return; + lastReminderTime = now; + + const fileList = [...modifiedFiles].map(f => ` - ${f}`).join("\n"); + return { + additionalContext: `🏗️ Build Guardian: ${modifiedFiles.size} file(s) modified since last build check:\n${fileList}\nRemember to verify the build compiles after these changes. Use the project_dotnet_build_check tool to validate.`, + }; + }, + }, + + tools: [ + { + name: "project_dotnet_build_check", + description: "Runs 'dotnet build' on the EscrowApp.sln solution and returns a structured result. Returns 'Build succeeded' on success or detailed error messages on failure.", + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async () => { + await session.log("🏗️ Running dotnet build...", { ephemeral: true }); + + const result = await runDotnetCommand(["build", "--no-restore", "--verbosity", "minimal"], BUILD_TIMEOUT); + + // Clear tracked files on successful build + if (result.success) { + const count = modifiedFiles.size; + modifiedFiles.clear(); + await session.log("✅ Build succeeded", { ephemeral: true }); + return `Build succeeded. ${count > 0 ? `(${count} pending file change(s) verified)` : ""}`; + } + + await session.log("❌ Build failed", { level: "warning", ephemeral: true }); + + // Extract error lines for concise output + const lines = result.output.split("\n"); + const errors = lines.filter(l => /:\s*error\s+\w+/i.test(l)); + const warnings = lines.filter(l => /:\s*warning\s+\w+/i.test(l)); + + let output = "Build FAILED.\n\n"; + if (errors.length > 0) { + output += `### Errors (${errors.length})\n`; + output += errors.slice(0, 20).join("\n"); + if (errors.length > 20) output += `\n... and ${errors.length - 20} more errors`; + output += "\n\n"; + } + if (warnings.length > 0) { + output += `### Warnings (${warnings.length})\n`; + output += warnings.slice(0, 10).join("\n"); + if (warnings.length > 10) output += `\n... and ${warnings.length - 10} more warnings`; + } + if (errors.length === 0 && warnings.length === 0) { + output += result.output.substring(0, 2000); + } + + return { textResultForLlm: output, resultType: "failure" }; + }, + }, + { + name: "project_dotnet_test_check", + description: "Runs 'dotnet test' on the EscrowApp.sln solution. Returns test results summary on success or failing test details on failure. Reports if no test projects exist.", + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async () => { + const solutionRoot = findSolutionRoot(); + + // Check for test projects first + const hasTests = await hasTestProjects(solutionRoot); + if (!hasTests) { + await session.log("ℹ️ No test projects detected", { ephemeral: true }); + } + + await session.log("🧪 Running dotnet test...", { ephemeral: true }); + + const result = await runDotnetCommand(["test", "--no-build", "--verbosity", "minimal"], TEST_TIMEOUT); + + if (result.success) { + // Extract test count from output + const totalMatch = result.output.match(/Passed!\s*-\s*Failed:\s*(\d+),\s*Passed:\s*(\d+)/i) + || result.output.match(/Total tests:\s*(\d+)/i); + + let summary = "All tests passed"; + if (totalMatch) { + summary += ` (${totalMatch[0].trim()})`; + } + + // Check for "no test" scenarios + if (/No test is available/i.test(result.output) || /No test matches/i.test(result.output)) { + await session.log("ℹ️ No tests found in solution", { ephemeral: true }); + return "No test projects or test methods found in the solution. Consider adding a test project (e.g., EscrowApp.Tests) with xUnit or NUnit."; + } + + await session.log("✅ Tests passed", { ephemeral: true }); + return summary; + } + + await session.log("❌ Tests failed", { level: "warning", ephemeral: true }); + + const lines = result.output.split("\n"); + const failedTests = lines.filter(l => /Failed\s+\w+/i.test(l) || /✗|×/.test(l)); + const errorLines = lines.filter(l => /:\s*error\s+/i.test(l)); + + let output = "Tests FAILED.\n\n"; + if (failedTests.length > 0) { + output += `### Failed Tests (${failedTests.length})\n`; + output += failedTests.slice(0, 20).join("\n"); + output += "\n\n"; + } + if (errorLines.length > 0) { + output += `### Errors\n`; + output += errorLines.slice(0, 10).join("\n"); + } + if (failedTests.length === 0 && errorLines.length === 0) { + output += result.output.substring(0, 2000); + } + + return { textResultForLlm: output, resultType: "failure" }; + }, + }, + ], +}); + +await session.log("🏗️ Build Guardian loaded"); diff --git a/.github/extensions/context-optimizer/extension.mjs b/.github/extensions/context-optimizer/extension.mjs new file mode 100644 index 0000000..84b19a7 --- /dev/null +++ b/.github/extensions/context-optimizer/extension.mjs @@ -0,0 +1,81 @@ +import { joinSession } from "@github/copilot-sdk/extension"; + +const PROJECT_SUMMARY = `## NexTruzt.io EscrowApp — Project Summary + +**Stack:** .NET 10 · Blazor Server · EF Core · PostgreSQL · MediatR · FluentValidation + +### Architecture (Clean Architecture + CQRS) +\`\`\` +┌─────────────────────────────────────────────────┐ +│ Components/ (Blazor UI — code-behind pattern) │ +│ Pages/, Layout/, Shared/ │ +├─────────────────────────────────────────────────┤ +│ Features/ (Application — CQRS handlers) │ +│ Commands/, Queries/, Validators/ │ +├─────────────────────────────────────────────────┤ +│ Models/ + Events/ (Domain layer) │ +│ Entities, Value Objects, Domain Events │ +├─────────────────────────────────────────────────┤ +│ Data/ (Infrastructure — EF Core + PostgreSQL) │ +│ DbContext, Repositories, Migrations/ │ +├─────────────────────────────────────────────────┤ +│ Services/ + Infrastructure/ │ +│ External integrations, Payment gateways │ +└─────────────────────────────────────────────────┘ +\`\`\` + +### Key Patterns +- **Payment Strategy:** IFundHoldable / IFundReleasable / IFundCancellable interfaces +- **CQRS:** MediatR command/query separation +- **Code-behind:** All Blazor components use .razor + .razor.cs (never inline @code) +- **Scoped CSS:** Every component has .razor.css +- **Validation:** FluentValidation on all commands +- **Resilience:** Polly retry + circuit breaker on external calls +- **Security:** OWASP-first, idempotency keys on payments, [Authorize] everywhere + +### Key Files +- \`EscrowApp.sln\` — Solution root +- \`EscrowApp/Program.cs\` — App bootstrap + DI +- \`EscrowApp/Data/\` — EF Core DbContext + repositories +- \`EscrowApp/Models/\` — Domain entities + value objects +- \`EscrowApp/Features/\` — CQRS handlers (commands + queries) +- \`EscrowApp/Components/\` — Blazor pages + shared components +- \`EscrowApp/Services/\` — Business services + payment integration +- \`EscrowApp/docs/\` — Architecture + API documentation (keep in sync)`; + +const session = await joinSession({ + hooks: { + onSessionStart: async () => { + await session.log("📋 Context Optimizer loaded", { ephemeral: true }); + return { + additionalContext: "NexTruzt.io EscrowApp: .NET 10 Blazor Server fintech escrow. Clean Architecture + CQRS/MediatR. Layers: Components/ (UI) → Features/ (handlers) → Models/Events (domain) ← Data/ (EF Core/PostgreSQL). Payment strategies: IFundHoldable/IFundReleasable/IFundCancellable. Key: code-behind required, docs/ must stay in sync, OWASP security-first, idempotency keys on payments.", + }; + }, + + onUserPromptSubmitted: async (input) => { + const prompt = input.prompt; + if (!prompt || typeof prompt !== "string") return; + + if (prompt.length > 2000) { + return { + additionalContext: "Note: The user's prompt is quite long. Be efficient with context usage — prefer concise responses and avoid repeating the prompt back. If the conversation is getting long, suggest the user use /compact to optimize the context window.", + }; + } + }, + }, + + tools: [ + { + name: "cloudzen_project_summary", + description: "Returns a concise, structured summary of the NexTruzt.io EscrowApp project including architecture diagram, key files, design patterns, and technology stack. Use this to quickly orient yourself without reading multiple files.", + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async () => { + return PROJECT_SUMMARY; + }, + }, + ], +}); diff --git a/.github/extensions/doc-sync/extension.mjs b/.github/extensions/doc-sync/extension.mjs new file mode 100644 index 0000000..1de34e2 --- /dev/null +++ b/.github/extensions/doc-sync/extension.mjs @@ -0,0 +1,201 @@ +import { joinSession } from "@github/copilot-sdk/extension"; +import { existsSync, statSync, readdirSync } from "node:fs"; +import { join, resolve, relative } from "node:path"; + +// Maps source path segments to their corresponding docs/ folder +const FEATURE_MAP = [ + { pattern: /Features[/\\]Escrow[/\\]HoldFunds/i, doc: "01-Escrow-Hold-Funds" }, + { pattern: /Features[/\\]Escrow[/\\]CreateAndHoldFunds/i, doc: "01-Escrow-Hold-Funds" }, + { pattern: /Features[/\\]Escrow[/\\]ReleaseFunds/i, doc: "02-Escrow-Release-Funds" }, + { pattern: /Features[/\\]Escrow[/\\]DisputeFunds/i, doc: "03-Escrow-Dispute-Funds" }, + { pattern: /Services[/\\]Strategies/i, doc: "04-Payment-Strategies" }, + { pattern: /Services[/\\]/i, doc: "04-Payment-Strategies" }, + { pattern: /Infrastructure[/\\]Auth/i, doc: "05-Hybrid-Identity" }, + { pattern: /Events[/\\]/i, doc: "06-Event-Bus" }, + { pattern: /Resources[/\\]/i, doc: "07-Localization" }, + { pattern: /Components[/\\]Pages[/\\]/i, doc: "08-Landing-Page-UI" }, + { pattern: /Features[/\\]Escrow[/\\]Api/i, doc: "09-API-Integration" }, + { pattern: /Features[/\\]Escrow[/\\]GetTransaction/i, doc: "09-API-Integration" }, + { pattern: /Features[/\\]Escrow[/\\]ListTransactions/i, doc: "09-API-Integration" }, + { pattern: /Infrastructure[/\\]Middleware/i, doc: "09-API-Integration" }, +]; + +const WATCHED_DIRS = + /[/\\](Features|Services|Models|Events|Components|Infrastructure|Resources)[/\\]/i; + +// Deduplication: track last reminder time per doc target +const lastReminder = new Map(); +const REMINDER_COOLDOWN_MS = 60_000; + +function findAppRoot(cwd) { + const candidates = [ + join(cwd, "EscrowApp"), + cwd, + ]; + for (const candidate of candidates) { + if (existsSync(join(candidate, "docs")) && existsSync(join(candidate, "EscrowApp.csproj"))) { + return candidate; + } + } + // Fallback: check if docs/ exists at cwd/EscrowApp + if (existsSync(join(cwd, "EscrowApp", "docs"))) { + return join(cwd, "EscrowApp"); + } + return undefined; +} + +function mapFileToDoc(filePath) { + const normalized = filePath.replace(/\\/g, "/"); + for (const entry of FEATURE_MAP) { + if (entry.pattern.test(normalized)) { + return entry.doc; + } + } + return "00-Architecture-Overview"; +} + +function getLatestMtime(dirPath) { + let latest = 0; + if (!existsSync(dirPath)) return latest; + + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = join(dirPath, entry.name); + try { + if (entry.isDirectory()) { + const childMtime = getLatestMtime(fullPath); + if (childMtime > latest) latest = childMtime; + } else if (entry.isFile()) { + const mtime = statSync(fullPath).mtimeMs; + if (mtime > latest) latest = mtime; + } + } catch { + // Skip inaccessible entries + } + } + } catch { + // Skip inaccessible directories + } + return latest; +} + +// Source directories and their doc mappings for the docs_status tool +const STATUS_MAP = [ + { label: "Escrow Hold Funds", srcDir: "Features/Escrow/HoldFunds", doc: "01-Escrow-Hold-Funds" }, + { label: "Escrow Release Funds", srcDir: "Features/Escrow/ReleaseFunds", doc: "02-Escrow-Release-Funds" }, + { label: "Escrow Dispute Funds", srcDir: "Features/Escrow/DisputeFunds", doc: "03-Escrow-Dispute-Funds" }, + { label: "Payment Strategies", srcDir: "Services/Strategies", doc: "04-Payment-Strategies" }, + { label: "Hybrid Identity", srcDir: "Infrastructure/Auth", doc: "05-Hybrid-Identity" }, + { label: "Event Bus", srcDir: "Events", doc: "06-Event-Bus" }, + { label: "Localization", srcDir: "Resources", doc: "07-Localization" }, + { label: "Landing Page UI", srcDir: "Components/Pages", doc: "08-Landing-Page-UI" }, + { label: "API Integration", srcDir: "Features/Escrow/Api", doc: "09-API-Integration" }, +]; + +function formatTimestamp(ms) { + if (ms === 0) return "N/A"; + return new Date(ms).toISOString().replace("T", " ").substring(0, 19); +} + +const session = await joinSession({ + hooks: { + onSessionStart: async () => { + await session.log("Doc-Sync extension loaded"); + }, + + onPostToolUse: async (input) => { + if (input.toolName !== "edit" && input.toolName !== "create") { + return undefined; + } + + const filePath = typeof input.toolArgs?.path === "string" + ? input.toolArgs.path + : undefined; + if (!filePath) return undefined; + + // Only watch relevant directories + if (!WATCHED_DIRS.test(filePath)) return undefined; + + const docFolder = mapFileToDoc(filePath); + + // Deduplicate reminders + const now = Date.now(); + const lastTime = lastReminder.get(docFolder) || 0; + if (now - lastTime < REMINDER_COOLDOWN_MS) return undefined; + lastReminder.set(docFolder, now); + + return { + additionalContext: [ + `DOCS SYNC REQUIRED: You modified code related to "${docFolder}".`, + `Per project rules, the corresponding docs/${docFolder}/README.md must be updated to reflect these changes.`, + "Check if documentation needs updating before moving on.", + ].join(" "), + }; + }, + }, + + tools: [ + { + name: "cloudzen_docs_status", + description: + "Compares last-modified timestamps of source code directories vs their corresponding docs/ README.md files. Reports which docs are potentially stale.", + parameters: { + type: "object", + properties: {}, + }, + handler: async () => { + const cwd = process.cwd(); + const appRoot = findAppRoot(cwd); + + if (!appRoot) { + return "Could not locate EscrowApp directory. Searched from: " + cwd; + } + + const docsRoot = join(appRoot, "docs"); + const lines = [ + "# Documentation Freshness Report", + "", + `App root: ${appRoot}`, + "", + "Feature Area | Source Last Modified | Docs Last Modified | Status", + "-----------------------|------------------------|------------------------|------------------", + ]; + + for (const entry of STATUS_MAP) { + const srcPath = join(appRoot, ...entry.srcDir.split("/")); + const docReadme = join(docsRoot, entry.doc, "README.md"); + + const srcMtime = getLatestMtime(srcPath); + + let docMtime = 0; + try { + if (existsSync(docReadme)) { + docMtime = statSync(docReadme).mtimeMs; + } + } catch { + // Not accessible + } + + let status; + if (srcMtime === 0) { + status = "no source"; + } else if (docMtime === 0) { + status = "MISSING DOCS"; + } else if (srcMtime > docMtime) { + status = "potentially-stale"; + } else { + status = "up-to-date"; + } + + const label = entry.label.padEnd(23); + const srcTs = formatTimestamp(srcMtime).padEnd(24); + const docTs = formatTimestamp(docMtime).padEnd(24); + lines.push(`${label}| ${srcTs}| ${docTs}| ${status}`); + } + + return lines.join("\n"); + }, + }, + ], +}); diff --git a/.github/extensions/dotnet-conventions/extension.mjs b/.github/extensions/dotnet-conventions/extension.mjs new file mode 100644 index 0000000..7b97885 --- /dev/null +++ b/.github/extensions/dotnet-conventions/extension.mjs @@ -0,0 +1,213 @@ +import { joinSession } from "@github/copilot-sdk/extension"; +import { readFileSync, readdirSync, existsSync, statSync } from "node:fs"; +import { join, basename, extname, resolve } from "node:path"; + +const EXCLUDED_DIRS = new Set(["bin", "obj", "node_modules", ".git", "Migrations"]); +const EXCLUDED_FILES = /\.(g|Designer|AssemblyInfo)\.cs$/i; +const TOPLEVEL_EXCEPTIONS = new Set(["Program.cs"]); + +function checkCsConventions(filePath, content) { + const findings = []; + const fileName = basename(filePath); + + // Skip known exceptions + if (TOPLEVEL_EXCEPTIONS.has(fileName)) return findings; + + // 1. File-scoped namespace check: detect block-scoped `namespace X\n{` or `namespace X {` + const blockNamespace = /^namespace\s+[\w.]+\s*\r?\n?\s*\{/m; + if (blockNamespace.test(content)) { + findings.push("Uses block-scoped namespace. Convert to file-scoped namespace (namespace X;)."); + } + + // 2. .razor.cs must declare partial class + if (filePath.endsWith(".razor.cs")) { + const hasPartial = /\bpartial\s+class\b/i.test(content); + if (!hasPartial) { + findings.push("Code-behind file (.razor.cs) must declare a partial class."); + } + } + + // 3. Nullable reference types (check for #nullable enable or nullable annotation) + // Only flag if there's a namespace (real source file, not top-level Program.cs) + if (/\bnamespace\b/.test(content) && !/#nullable\s+enable/.test(content)) { + // Not necessarily a violation if enabled in .csproj, note as advisory + findings.push("Advisory: No #nullable enable directive found. Ensure enable is set in .csproj."); + } + + // 4. Class name should match file name (for non-razor.cs files) + if (!filePath.endsWith(".razor.cs")) { + const expectedName = fileName.replace(/\.cs$/, ""); + const classDecl = /\bclass\s+(\w+)/.exec(content); + if (classDecl && classDecl[1] !== expectedName) { + findings.push(`Class name "${classDecl[1]}" does not match file name "${fileName}".`); + } + } + + return findings; +} + +function checkRazorConventions(filePath, content) { + const findings = []; + + // 1. @code blocks — should use code-behind pattern + if (/@code\s*\{/i.test(content)) { + findings.push("Contains @code block. Use code-behind pattern (.razor + .razor.cs) instead."); + } + + // 2. Inline styles + if (/\bstyle\s*=\s*"/i.test(content)) { + findings.push("Contains inline style attribute. Use scoped CSS (.razor.css) instead."); + } + + return findings; +} + +function checkFile(filePath) { + try { + const content = readFileSync(filePath, "utf-8"); + const ext = extname(filePath).toLowerCase(); + + if (ext === ".cs") { + return checkCsConventions(filePath, content); + } + if (ext === ".razor") { + return checkRazorConventions(filePath, content); + } + } catch { + return [`Could not read file: ${filePath}`]; + } + return []; +} + +function walkDirectory(dirPath, results) { + if (!existsSync(dirPath)) return; + + try { + const entries = readdirSync(dirPath, { withFileTypes: true }); + for (const entry of entries) { + if (EXCLUDED_DIRS.has(entry.name)) continue; + + const fullPath = join(dirPath, entry.name); + if (entry.isDirectory()) { + walkDirectory(fullPath, results); + } else if (entry.isFile()) { + if (EXCLUDED_FILES.test(entry.name)) continue; + const ext = extname(entry.name).toLowerCase(); + if (ext === ".cs" || ext === ".razor") { + const findings = checkFile(fullPath); + if (findings.length > 0) { + results.push({ file: fullPath, findings }); + } + } + } + } + } catch { + // Skip inaccessible directories + } +} + +const session = await joinSession({ + hooks: { + onSessionStart: async () => { + await session.log("DotNet Conventions extension loaded"); + }, + + onPostToolUse: async (input) => { + if (input.toolName !== "edit" && input.toolName !== "create") { + return undefined; + } + + const filePath = typeof input.toolArgs?.path === "string" + ? input.toolArgs.path + : undefined; + if (!filePath) return undefined; + + const ext = extname(filePath).toLowerCase(); + if (ext !== ".cs" && ext !== ".razor") return undefined; + + // Skip excluded files + const fileName = basename(filePath); + if (TOPLEVEL_EXCEPTIONS.has(fileName)) return undefined; + if (EXCLUDED_FILES.test(fileName)) return undefined; + + try { + const findings = checkFile(filePath); + if (findings.length === 0) return undefined; + + return { + additionalContext: [ + `CONVENTION VIOLATIONS in ${fileName}:`, + ...findings.map((f, i) => ` ${i + 1}. ${f}`), + "", + "Please fix these violations to comply with project .NET conventions.", + ].join("\n"), + }; + } catch { + // If file can't be read, skip silently + return undefined; + } + }, + }, + + tools: [ + { + name: "cloudzen_check_conventions", + description: + "Checks .NET coding conventions on a file or directory. Scans .cs and .razor files for: file-scoped namespaces, code-behind pattern, partial class declarations, inline styles, and class naming.", + parameters: { + type: "object", + properties: { + path: { + type: "string", + description: "Absolute path to a file or directory to check.", + }, + }, + required: ["path"], + }, + handler: async (args) => { + const targetPath = args.path; + + if (!existsSync(targetPath)) { + return `Path does not exist: ${targetPath}`; + } + + const stat = statSync(targetPath); + const results = []; + + if (stat.isFile()) { + const ext = extname(targetPath).toLowerCase(); + if (ext !== ".cs" && ext !== ".razor") { + return `Not a .cs or .razor file: ${targetPath}`; + } + const findings = checkFile(targetPath); + if (findings.length > 0) { + results.push({ file: targetPath, findings }); + } + } else if (stat.isDirectory()) { + walkDirectory(targetPath, results); + } + + if (results.length === 0) { + return "✓ No convention violations found."; + } + + const lines = [ + "# Convention Check Results", + "", + `Files with violations: ${results.length}`, + "", + ]; + + for (const r of results) { + lines.push(`## ${r.file}`); + for (const f of r.findings) { + lines.push(` - ${f}`); + } + lines.push(""); + } + + return lines.join("\n"); + }, + }, + ], +}); diff --git a/.github/extensions/research-first/extension.mjs b/.github/extensions/research-first/extension.mjs new file mode 100644 index 0000000..7b9bd72 --- /dev/null +++ b/.github/extensions/research-first/extension.mjs @@ -0,0 +1,139 @@ +import { joinSession } from "@github/copilot-sdk/extension"; +import { readdirSync, readFileSync, existsSync, statSync } from "node:fs"; +import { join, resolve } from "node:path"; + +const IMPLEMENTATION_KEYWORDS = + /\b(create|implement|add|build|write|refactor|change|modify|update)\b/i; + +const RESEARCH_KEYWORDS = + /\b(explore|search|find|understand|analyze|review|read|explain)\b|what is|how does/i; + +function findDocsRoot(cwd) { + const candidates = [ + join(cwd, "EscrowApp", "docs"), + join(cwd, "docs"), + ]; + for (const candidate of candidates) { + if (existsSync(candidate) && statSync(candidate).isDirectory()) { + return candidate; + } + } + return undefined; +} + +function listDocFolders(docsRoot) { + const entries = readdirSync(docsRoot, { withFileTypes: true }); + return entries + .filter((e) => e.isDirectory() && /^\d{2}-/.test(e.name)) + .map((e) => { + const readmePath = join(docsRoot, e.name, "README.md"); + const hasReadme = existsSync(readmePath); + return { folder: e.name, readmePath, hasReadme }; + }) + .sort((a, b) => a.folder.localeCompare(b.folder)); +} + +function searchDocs(docsRoot, term) { + const folders = listDocFolders(docsRoot); + const matches = []; + const lowerTerm = term.toLowerCase(); + + for (const entry of folders) { + if (!entry.hasReadme) continue; + try { + const content = readFileSync(entry.readmePath, "utf-8"); + const lines = content.split("\n"); + for (let i = 0; i < lines.length; i++) { + if (lines[i].toLowerCase().includes(lowerTerm)) { + matches.push({ + doc: entry.folder, + line: i + 1, + text: lines[i].trim().substring(0, 200), + }); + } + } + } catch { + // Skip unreadable files + } + } + return matches; +} + +const session = await joinSession({ + hooks: { + onSessionStart: async () => { + await session.log("Research-First extension loaded"); + }, + + onUserPromptSubmitted: async (input) => { + if (!input.prompt) return undefined; + + // Skip injection if prompt already contains research intent + if (RESEARCH_KEYWORDS.test(input.prompt)) return undefined; + + // Inject only when implementation intent is detected + if (IMPLEMENTATION_KEYWORDS.test(input.prompt)) { + return { + additionalContext: [ + "RESEARCH-FIRST PRINCIPLE: Before making changes, explore the existing codebase to understand current patterns.", + "Check docs/ for feature documentation (use the check_docs tool if needed).", + "Understand the layer this change belongs to (Domain/Application/Infrastructure/Presentation).", + "Verify existing tests and patterns before creating new code.", + ].join(" "), + }; + } + + return undefined; + }, + }, + + tools: [ + { + name: "cloudzen_check_docs", + description: + "Lists available feature documentation in EscrowApp/docs/ and optionally searches README.md files for a term.", + parameters: { + type: "object", + properties: { + search_term: { + type: "string", + description: + "Optional keyword to search for within README.md files.", + }, + }, + }, + handler: async (args, invocation) => { + const cwd = process.cwd(); + const docsRoot = findDocsRoot(cwd); + + if (!docsRoot) { + return "Could not locate EscrowApp/docs/ directory. Searched from: " + cwd; + } + + const folders = listDocFolders(docsRoot); + const lines = ["# Available Feature Documentation", ""]; + lines.push(`Location: ${docsRoot}`, ""); + + for (const entry of folders) { + const status = entry.hasReadme ? "✓ README.md" : "✗ no README.md"; + lines.push(` ${entry.folder} [${status}]`); + } + + if (args.search_term) { + const matches = searchDocs(docsRoot, args.search_term); + lines.push("", `# Search results for "${args.search_term}"`, ""); + + if (matches.length === 0) { + lines.push(" No matches found."); + } else { + for (const m of matches) { + lines.push(` [${m.doc}] line ${m.line}: ${m.text}`); + } + } + } + + return lines.join("\n"); + }, + }, + ], +}); diff --git a/.github/extensions/security-scanner/extension.mjs b/.github/extensions/security-scanner/extension.mjs new file mode 100644 index 0000000..b607c26 --- /dev/null +++ b/.github/extensions/security-scanner/extension.mjs @@ -0,0 +1,291 @@ +import { joinSession } from "@github/copilot-sdk/extension"; +import { readFileSync } from "node:fs"; +import { readFile, readdir, stat } from "node:fs/promises"; +import { resolve, extname, join, relative } from "node:path"; + +// --- Security pattern definitions --- + +const CONNECTION_STRING_PATTERNS = [ + { regex: /["'](?:Server|Data Source)\s*=[^"']+(?:Password|Pwd)\s*=[^"']+["']/gi, id: "hardcoded-connstr", category: "Sensitive Data Exposure", severity: "HIGH" }, + { regex: /["']mongodb(?:\+srv)?:\/\/[^"']+["']/gi, id: "hardcoded-mongodb", category: "Sensitive Data Exposure", severity: "HIGH" }, + { regex: /["'](?:Host|Server)\s*=\s*[^"']+;.*(?:Password|Pwd)\s*=[^"']+["']/gi, id: "hardcoded-pg-connstr", category: "Sensitive Data Exposure", severity: "HIGH" }, +]; + +const SECRET_PATTERNS = [ + { regex: /["']sk_(?:live|test)_[A-Za-z0-9]{20,}["']/g, id: "stripe-key", category: "Sensitive Data Exposure", severity: "CRITICAL" }, + { regex: /["'](?:Bearer\s+)[A-Za-z0-9\-._~+/]+=*["']/g, id: "bearer-token", category: "Sensitive Data Exposure", severity: "HIGH" }, + { regex: /(?:api[_-]?key|apikey|secret[_-]?key|client[_-]?secret)\s*[:=]\s*["'][A-Za-z0-9\-._]{16,}["']/gi, id: "api-key-assignment", category: "Sensitive Data Exposure", severity: "HIGH" }, + { regex: /["'](?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{30,}["']/g, id: "github-token", category: "Sensitive Data Exposure", severity: "CRITICAL" }, + { regex: /["']AKIA[A-Z0-9]{16}["']/g, id: "aws-access-key", category: "Sensitive Data Exposure", severity: "CRITICAL" }, +]; + +const SQL_INJECTION_PATTERNS = [ + { regex: /string\.Format\s*\(\s*["'].*(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER)\b/gi, id: "sql-string-format", category: "Injection", severity: "HIGH" }, + { regex: /\$"[^"]*(?:SELECT|INSERT|UPDATE|DELETE|DROP|ALTER)\b[^"]*\{/gi, id: "sql-interpolation", category: "Injection", severity: "HIGH" }, + { regex: /(?:["'].*(?:SELECT|INSERT|UPDATE|DELETE)\b.*["'])\s*\+\s*(?:\w+)/gi, id: "sql-concat", category: "Injection", severity: "MEDIUM" }, + { regex: /ExecuteSqlRaw\s*\(\s*\$"/gi, id: "ef-raw-sql-interpolated", category: "Injection", severity: "HIGH" }, + { regex: /FromSqlRaw\s*\(\s*\$"/gi, id: "ef-fromsql-interpolated", category: "Injection", severity: "HIGH" }, +]; + +const XSS_PATTERNS = [ + { regex: /MarkupString\s*\(\s*(?!\s*["']<)/g, id: "markup-string-dynamic", category: "XSS", severity: "MEDIUM" }, + { regex: /\bHtml\.Raw\s*\(/g, id: "html-raw", category: "XSS", severity: "MEDIUM" }, +]; + +const AUTH_PATTERNS = [ + { regex: /\[AllowAnonymous\]/g, id: "allow-anonymous", category: "Broken Access Control", severity: "INFO" }, + { regex: /(?:password|pwd)\s*[:=]\s*["'][^"']+["']/gi, id: "hardcoded-password", category: "Broken Authentication", severity: "HIGH" }, +]; + +const MASS_ASSIGNMENT_PATTERNS = [ + { regex: /\[Bind\s*\(\s*\)\s*\]/g, id: "empty-bind", category: "Mass Assignment", severity: "MEDIUM" }, + { regex: /TryUpdateModelAsync\s*<\s*\w+\s*>\s*\([^)]*\)/g, id: "tryupdatemodel", category: "Mass Assignment", severity: "INFO" }, +]; + +const ALL_PATTERNS = [ + ...CONNECTION_STRING_PATTERNS, + ...SECRET_PATTERNS, + ...SQL_INJECTION_PATTERNS, + ...XSS_PATTERNS, + ...AUTH_PATTERNS, + ...MASS_ASSIGNMENT_PATTERNS, +]; + +const SCAN_EXTENSIONS = new Set([".cs", ".razor", ".json", ".csproj", ".config"]); +const SKIP_DIRS = new Set([".git", "bin", "obj", "node_modules", ".vs", "wwwroot"]); + +function scanContent(content, source) { + const findings = []; + for (const pattern of ALL_PATTERNS) { + const regex = new RegExp(pattern.regex.source, pattern.regex.flags); + let match; + while ((match = regex.exec(content)) !== null) { + const lineNum = content.substring(0, match.index).split("\n").length; + findings.push({ + id: pattern.id, + category: pattern.category, + severity: pattern.severity, + line: lineNum, + match: match[0].substring(0, 80), + source, + }); + } + } + return findings; +} + +function formatFindings(findings) { + if (findings.length === 0) return "✅ No security issues found."; + const grouped = {}; + for (const f of findings) { + if (!grouped[f.category]) grouped[f.category] = []; + grouped[f.category].push(f); + } + let out = `⚠️ Found ${findings.length} potential security issue(s):\n`; + for (const [cat, items] of Object.entries(grouped)) { + out += `\n### ${cat}\n`; + for (const item of items) { + out += `- [${item.severity}] ${item.id} at ${item.source}:${item.line} — \`${item.match}\`\n`; + } + } + return out; +} + +function isTargetFile(filePath) { + if (!filePath || typeof filePath !== "string") return false; + const ext = extname(filePath).toLowerCase(); + return ext === ".cs" || ext === ".razor"; +} + +function isTargetFileExtended(filePath) { + if (!filePath || typeof filePath !== "string") return false; + const ext = extname(filePath).toLowerCase(); + return SCAN_EXTENSIONS.has(ext); +} + +async function walkDirectory(dir, rootDir) { + const files = []; + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (SKIP_DIRS.has(entry.name)) continue; + const fullPath = join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...await walkDirectory(fullPath, rootDir)); + } else if (entry.isFile()) { + const ext = extname(entry.name).toLowerCase(); + if (ext === ".cs" || ext === ".json") { + files.push(fullPath); + } + } + } + } catch { + // Skip inaccessible directories + } + return files; +} + +const session = await joinSession({ + hooks: { + onPreToolUse: async (input) => { + const toolName = input.toolName; + if (toolName !== "create" && toolName !== "edit") return; + + const args = input.toolArgs; + if (!args || typeof args !== "object") return; + + const filePath = args.path; + if (!isTargetFileExtended(filePath)) return; + + // Scan the content being written + const content = toolName === "create" ? args.file_text : args.new_str; + if (!content || typeof content !== "string") return; + + const findings = scanContent(content, `${toolName}:${filePath}`); + if (findings.length === 0) return; + + const highSeverity = findings.filter(f => f.severity === "CRITICAL" || f.severity === "HIGH"); + if (highSeverity.length > 0) { + await session.log(`🔒 Security scan found ${highSeverity.length} HIGH/CRITICAL issue(s) in pending ${toolName}`, { level: "warning" }); + } + + return { + additionalContext: `🔒 SECURITY SCANNER WARNING — The ${toolName} operation on "${filePath}" contains potential security issues:\n${formatFindings(findings)}\nPlease address these before proceeding. Use parameterized queries, IOptions for config, and Azure Key Vault / user-secrets for sensitive values.`, + }; + }, + + onPostToolUse: async (input) => { + const toolName = input.toolName; + if (toolName !== "create" && toolName !== "edit") return; + + const args = input.toolArgs; + if (!args || typeof args !== "object") return; + + const filePath = args.path; + if (!filePath || typeof filePath !== "string") return; + if (!isTargetFile(filePath)) return; + + const lower = filePath.toLowerCase(); + const isSensitive = lower.includes("auth") || lower.includes("payment") || lower.includes("program.cs") + || lower.includes("startup") || lower.includes("security") || lower.includes("credential") + || lower.includes("stripe") || lower.includes("escrow") || lower.includes("fund"); + + if (!isSensitive) return; + + return { + additionalContext: "🔒 OWASP Compliance Reminder: This file is in a security-sensitive area. Verify: (1) No hardcoded secrets — use IOptions + Azure Key Vault, (2) Input validation with FluentValidation, (3) Parameterized queries only, (4) [Authorize] on all endpoints, (5) DTOs for mass-assignment protection, (6) CancellationToken propagation.", + }; + }, + + onUserPromptSubmitted: async (input) => { + const prompt = input.prompt; + if (!prompt || typeof prompt !== "string") return; + + if (/\b(?:payment|stripe|pay(?:out)?|escrow|fund|refund)\b/i.test(prompt)) { + return { + additionalContext: "🔒 FINTECH SECURITY CONTEXT: This involves payment/financial operations. Requirements: (1) Idempotency keys on all payment mutations, (2) PCI-DSS: never log or store raw card numbers, (3) Use Stripe SDK — never call API directly with raw HTTP, (4) Audit trail for all financial state transitions, (5) Use decimal (not float/double) for monetary amounts, (6) Implement retry with Polly + circuit breaker for payment gateway calls.", + }; + } + + if (/\b(?:auth|login|credential|token|session|identity|password|jwt|oauth|oidc)\b/i.test(prompt)) { + return { + additionalContext: "🔒 AUTH SECURITY CONTEXT: (1) Use Microsoft.Identity.Web or Duende IdentityServer — never roll custom auth, (2) Policy-based authorization with [Authorize(Policy = \"...\")], (3) Never store tokens in localStorage, (4) Implement token refresh, (5) Hash passwords with bcrypt/scrypt via ASP.NET Identity, (6) Enforce MFA for admin operations, (7) Log auth failures with correlation IDs.", + }; + } + }, + }, + + tools: [ + { + name: "cloudzen_owasp_security_scan", + description: "Scans a file for OWASP Top 10 security issues including injection, broken auth, sensitive data exposure, XSS, security misconfiguration, and mass assignment. Returns structured findings with severity levels.", + parameters: { + type: "object", + properties: { + filePath: { type: "string", description: "Absolute path to the file to scan" }, + }, + required: ["filePath"], + additionalProperties: false, + }, + handler: async (args) => { + const filePath = args.filePath; + if (!filePath) return "Error: filePath is required."; + + const resolved = resolve(filePath); + try { + const content = readFileSync(resolved, "utf-8"); + const findings = scanContent(content, relative(process.cwd(), resolved)); + + let result = `## OWASP Security Scan: ${relative(process.cwd(), resolved)}\n`; + result += `Scanned ${content.split("\n").length} lines against ${ALL_PATTERNS.length} patterns.\n\n`; + result += formatFindings(findings); + return result; + } catch (err) { + return `Error reading file: ${err.message}`; + } + }, + }, + { + name: "cloudzen_check_secrets", + description: "Recursively scans .cs and .json files in a directory for hardcoded secrets, API keys, connection strings, and credentials. Reports findings with file path and line number.", + parameters: { + type: "object", + properties: { + directory: { type: "string", description: "Directory to scan (defaults to current working directory)" }, + }, + additionalProperties: false, + }, + handler: async (args) => { + const rootDir = resolve(process.cwd()); + const targetDir = args.directory ? resolve(args.directory) : rootDir; + + // Scope check: must be within the repo root + if (!targetDir.startsWith(rootDir)) { + return "Error: Directory must be within the project root."; + } + + await session.log("🔍 Scanning for secrets...", { ephemeral: true }); + + try { + const files = await walkDirectory(targetDir, rootDir); + const allFindings = []; + + for (const filePath of files) { + try { + const content = await readFile(filePath, "utf-8"); + const relPath = relative(rootDir, filePath); + const findings = scanContent(content, relPath); + // Only report secret-related findings + const secretFindings = findings.filter(f => + f.category === "Sensitive Data Exposure" || f.category === "Broken Authentication" + ); + allFindings.push(...secretFindings); + } catch { + // Skip unreadable files + } + } + + let result = `## Secret Scan Results\n`; + result += `Scanned ${files.length} files in ${relative(rootDir, targetDir) || "."}\n\n`; + + if (allFindings.length === 0) { + result += "✅ No hardcoded secrets detected."; + } else { + result += `⚠️ Found ${allFindings.length} potential secret(s):\n\n`; + for (const f of allFindings) { + result += `- [${f.severity}] **${f.source}:${f.line}** — ${f.id}: \`${f.match}\`\n`; + } + result += "\n**Recommendation:** Move secrets to Azure Key Vault, `dotnet user-secrets`, or environment variables. Use `IOptions` pattern for configuration."; + } + + await session.log(`Secret scan complete: ${allFindings.length} finding(s) in ${files.length} files`, { ephemeral: true }); + return result; + } catch (err) { + return `Error scanning directory: ${err.message}`; + } + }, + }, + ], +}); + +await session.log("🔒 OWASP Security Scanner loaded"); diff --git a/.github/extensions/superpowers/extension.mjs b/.github/extensions/superpowers/extension.mjs new file mode 100644 index 0000000..c265aba --- /dev/null +++ b/.github/extensions/superpowers/extension.mjs @@ -0,0 +1,153 @@ +import { joinSession } from "@github/copilot-sdk/extension"; +import { readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Resolve paths relative to this extension, not cwd +const __dirname = dirname(fileURLToPath(import.meta.url)); +const SKILLS_DIR = join(__dirname, "skills"); + +// Manifest: single source of truth for all skills +const MANIFEST = { + brainstorming: { + title: "Brainstorming Ideas Into Designs", + file: "brainstorming.md", + description: "Socratic design refinement — explore intent, propose approaches, get approval before code", + related: ["writing-plans"], + recommended_next: "writing-plans", + }, + "writing-plans": { + title: "Writing Implementation Plans", + file: "writing-plans.md", + description: "Break specs into bite-sized TDD tasks with exact file paths, code, and verification steps", + related: ["brainstorming", "executing-plans", "subagent-driven-development"], + recommended_next: "executing-plans", + }, + "executing-plans": { + title: "Executing Plans", + file: "executing-plans.md", + description: "Load plan, review critically, execute tasks sequentially with verification", + related: ["writing-plans", "subagent-driven-development", "verification-before-completion"], + recommended_next: "verification-before-completion", + }, + tdd: { + title: "Test-Driven Development", + file: "test-driven-development.md", + description: "RED-GREEN-REFACTOR — write failing test, minimal code to pass, then clean up", + related: ["systematic-debugging", "verification-before-completion"], + recommended_next: null, + }, + "systematic-debugging": { + title: "Systematic Debugging", + file: "systematic-debugging.md", + description: "4-phase root cause analysis — investigate before fixing, never guess", + related: ["tdd", "verification-before-completion"], + recommended_next: "verification-before-completion", + }, + "subagent-driven-development": { + title: "Subagent-Driven Development", + file: "subagent-driven-development.md", + description: "Dispatch fresh agent per task with two-stage review (spec + quality)", + related: ["writing-plans", "executing-plans", "requesting-code-review"], + recommended_next: "requesting-code-review", + }, + "verification-before-completion": { + title: "Verification Before Completion", + file: "verification-before-completion.md", + description: "Evidence before claims — run verification, read output, THEN report status", + related: ["tdd", "systematic-debugging"], + recommended_next: null, + }, + "requesting-code-review": { + title: "Requesting Code Review", + file: "requesting-code-review.md", + description: "Dispatch critic agent to review changes against spec and quality standards", + related: ["subagent-driven-development", "verification-before-completion"], + recommended_next: null, + }, +}; + +const ALLOWED_SKILLS = new Set(Object.keys(MANIFEST)); + +function loadSkill(skillId) { + if (!ALLOWED_SKILLS.has(skillId)) { + return `Unknown skill: "${skillId}". Use superpowers_catalog to see available skills.`; + } + const entry = MANIFEST[skillId]; + const filePath = join(SKILLS_DIR, entry.file); + try { + const content = readFileSync(filePath, "utf-8"); + let result = content; + if (entry.related.length > 0) { + result += `\n\n---\n**Related skills:** ${entry.related.join(", ")}`; + } + if (entry.recommended_next) { + result += `\n**Recommended next:** superpowers_skill(skill: "${entry.recommended_next}")`; + } + return result; + } catch { + return `Error: Could not load skill file "${entry.file}". Ensure the extension is properly installed.`; + } +} + +function buildCatalog() { + const lines = [ + "# Superpowers Skills Catalog", + "", + "On-demand workflow skills ported from obra/superpowers (MIT). Call `superpowers_skill` with a skill ID to load.", + "", + "| Skill ID | Title | Description |", + "|----------|-------|-------------|", + ]; + for (const [id, entry] of Object.entries(MANIFEST)) { + lines.push(`| \`${id}\` | ${entry.title} | ${entry.description} |`); + } + lines.push(""); + lines.push("## Typical Flow"); + lines.push("```"); + lines.push("brainstorming → writing-plans → executing-plans / subagent-driven-development"); + lines.push(" ↕ ↕"); + lines.push(" tdd + systematic-debugging + verification-before-completion"); + lines.push(" ↕"); + lines.push(" requesting-code-review"); + lines.push("```"); + lines.push(""); + lines.push("*Attribution: Based on obra/superpowers (MIT License) — adapted for Copilot CLI*"); + return lines.join("\n"); +} + +const session = await joinSession({ + tools: [ + { + name: "superpowers_catalog", + description: + "List all available Superpowers workflow skills with descriptions and recommended flow. Zero-cost overview — no skill content loaded.", + parameters: { + type: "object", + properties: {}, + additionalProperties: false, + }, + handler: async () => buildCatalog(), + }, + { + name: "superpowers_skill", + description: + "Load a specific Superpowers workflow skill on-demand. Returns the full skill methodology for the agent to follow. Use superpowers_catalog first to see available skills.", + parameters: { + type: "object", + properties: { + skill: { + type: "string", + description: "The skill ID to load", + enum: Object.keys(MANIFEST), + }, + }, + required: ["skill"], + additionalProperties: false, + }, + handler: async (params) => loadSkill(params.skill), + }, + ], +}); + +await session.log("⚡ Superpowers extension loaded with 8 workflow skills"); diff --git a/.github/extensions/superpowers/skills/brainstorming.md b/.github/extensions/superpowers/skills/brainstorming.md new file mode 100644 index 0000000..8b207a4 --- /dev/null +++ b/.github/extensions/superpowers/skills/brainstorming.md @@ -0,0 +1,83 @@ +# Brainstorming Ideas Into Designs + +> Adapted from obra/superpowers (MIT) for Copilot CLI. + +Help turn ideas into fully formed designs through collaborative dialogue. + +## HARD GATE + +Do NOT write any code, scaffold any project, or take any implementation action until you have presented a design and the user has approved it. This applies to EVERY project regardless of perceived simplicity. + +## Checklist + +Complete these in order: + +1. **Explore project context** — check files, docs, recent commits +2. **Ask clarifying questions** — one at a time, understand purpose/constraints/success criteria +3. **Propose 2-3 approaches** — with trade-offs and your recommendation +4. **Present design** — in sections scaled to complexity, get approval after each section +5. **Write design doc** — save to session artifacts or docs/ and commit +6. **Spec self-review** — check for placeholders, contradictions, ambiguity, scope +7. **User reviews written spec** — ask user to review before proceeding +8. **Transition** — use `superpowers_skill(skill: "writing-plans")` to create implementation plan + +## The Process + +### Understanding the Idea + +- Check project state first (files, docs, recent commits) — use `project_summary` tool if available +- Assess scope: if multiple independent subsystems, flag immediately and decompose +- Ask questions **one at a time** — prefer multiple choice when possible +- Focus on: purpose, constraints, success criteria + +### Exploring Approaches + +- Propose 2-3 approaches with trade-offs +- Lead with your recommendation and explain why +- YAGNI ruthlessly — remove unnecessary features + +### Presenting the Design + +- Scale each section to its complexity (a few sentences if simple, up to 300 words if nuanced) +- Ask after each section whether it looks right +- Cover: architecture, components, data flow, error handling, testing + +### Design for Isolation + +- Break system into smaller units with one clear purpose each +- Well-defined interfaces, testable independently +- Smaller units = better reasoning, more reliable edits + +### Working in Existing Codebases + +- Explore current structure before proposing changes — follow existing patterns +- Include targeted improvements only where existing code affects the work +- Don't propose unrelated refactoring + +## After the Design + +1. **Write the spec** to docs/ or session artifacts — commit it +2. **Self-review** the spec: + - Placeholder scan: any TBD, TODO, incomplete sections? + - Internal consistency: do sections contradict each other? + - Scope check: focused enough for a single plan? + - Ambiguity check: could any requirement be interpreted two ways? +3. **User review gate**: Ask user to review before proceeding +4. **Transition**: Load writing-plans skill to create implementation plan + +## Key Principles + +- **One question at a time** — don't overwhelm +- **Multiple choice preferred** — easier to answer +- **YAGNI ruthlessly** — remove unnecessary features +- **Explore alternatives** — always 2-3 approaches before settling +- **Incremental validation** — present, get approval, then move on + +## Copilot CLI Mappings + +| Superpowers Concept | Copilot CLI Equivalent | +|---|---| +| Dispatch subagent | Use `task` tool with appropriate agent_type | +| TodoWrite | SQL `todos` table | +| Next skill | `superpowers_skill(skill: "writing-plans")` | +| Project exploration | `project_summary` tool, `check_docs` tool, grep/glob | diff --git a/.github/extensions/superpowers/skills/executing-plans.md b/.github/extensions/superpowers/skills/executing-plans.md new file mode 100644 index 0000000..d04caea --- /dev/null +++ b/.github/extensions/superpowers/skills/executing-plans.md @@ -0,0 +1,58 @@ +# Executing Plans + +> Adapted from obra/superpowers (MIT) for Copilot CLI. + +Load plan, review critically, execute all tasks, report when complete. + +## The Process + +### Step 1: Load and Review Plan + +1. Read plan file (plan.md or docs/ path) +2. Review critically — identify questions or concerns +3. If concerns: raise them with user before starting +4. If no concerns: populate SQL todos and proceed + +```sql +-- Track all tasks +INSERT INTO todos (id, title, description, status) VALUES + ('task-1', 'Task 1: [Title]', '[Description]', 'pending'); + +-- Track dependencies +INSERT INTO todo_deps (todo_id, depends_on) VALUES ('task-2', 'task-1'); +``` + +### Step 2: Execute Tasks + +For each task: +1. Mark as in_progress: `UPDATE todos SET status = 'in_progress' WHERE id = 'task-N'` +2. Follow each step exactly (plan has bite-sized steps) +3. Run verifications as specified — use `superpowers_skill(skill: "verification-before-completion")` +4. Mark as done: `UPDATE todos SET status = 'done' WHERE id = 'task-N'` + +### Step 3: Complete Development + +After all tasks complete: +- Run full test suite to verify nothing is broken +- Use `superpowers_skill(skill: "requesting-code-review")` for final review +- Commit with meaningful message + +## When to Stop and Ask + +**STOP executing immediately when:** +- Hit a blocker (missing dependency, test fails, instruction unclear) +- Plan has critical gaps +- You don't understand an instruction +- Verification fails repeatedly + +**Ask for clarification rather than guessing.** + +## Copilot CLI Mappings + +| Superpowers Concept | Copilot CLI Equivalent | +|---|---| +| TodoWrite | SQL `todos` + `todo_deps` tables | +| Status tracking | `UPDATE todos SET status = '...'` | +| Ready query | `SELECT * FROM todos WHERE status='pending' AND NOT EXISTS (...)` | +| Build/test | `dotnet_build_check` / `dotnet_test_check` tools or `task` agent | +| Verification | `superpowers_skill(skill: "verification-before-completion")` | diff --git a/.github/extensions/superpowers/skills/requesting-code-review.md b/.github/extensions/superpowers/skills/requesting-code-review.md new file mode 100644 index 0000000..a9a9df1 --- /dev/null +++ b/.github/extensions/superpowers/skills/requesting-code-review.md @@ -0,0 +1,95 @@ +# Requesting Code Review + +> Adapted from obra/superpowers (MIT) for Copilot CLI. + +Dispatch a critic agent to catch issues before they cascade. + +**Core principle:** Review early, review often. + +## When to Request Review + +**Mandatory:** +- After each task in subagent-driven development +- After completing a major feature +- Before merge to main + +**Optional but valuable:** +- When stuck (fresh perspective) +- Before refactoring (baseline check) +- After fixing complex bug + +## How to Request + +### 1. Gather Context + +```bash +git --no-pager log --oneline -5 # Recent commits +git --no-pager diff HEAD~N # Changes to review +``` + +### 2. Dispatch Critic Agent + +``` +Use task tool: + agent_type: "critic" + prompt: | + Review these code changes for correctness, quality, and security. + + **What was implemented:** [description] + **Requirements/spec:** [paste relevant section or file path] + **Files changed:** [list files] + + Review criteria: + 1. Does the implementation match the spec? (completeness) + 2. Are there bugs, edge cases, or logic errors? (correctness) + 3. SOLID principles, clean code, naming? (quality) + 4. Input validation, authorization, injection prevention? (security) + 5. Test coverage adequate? (testing) + + For each issue found: + - Severity: Critical / Important / Minor + - Location: file and line + - Issue: what's wrong + - Fix: specific suggestion +``` + +### 3. Act on Feedback + +| Severity | Action | +|----------|--------| +| **Critical** | Fix immediately — blocks everything | +| **Important** | Fix before proceeding to next task | +| **Minor** | Note for later, don't block progress | +| **Reviewer wrong** | Push back with technical reasoning | + +### 4. Re-Review if Needed + +If critic found Critical or Important issues: +1. Fix the issues +2. Re-dispatch critic with same scope +3. Repeat until clean + +## Integration with Workflows + +| Workflow | When to Review | +|----------|---------------| +| Subagent-driven development | After EACH task (mandatory) | +| Executing plans | After each batch of 3 tasks | +| Ad-hoc development | Before merge | + +## Red Flags + +- Skip review because "it's simple" +- Ignore Critical issues +- Proceed with unfixed Important issues +- Argue without technical evidence + +## Copilot CLI Mappings + +| Superpowers Concept | Copilot CLI Equivalent | +|---|---| +| Dispatch code reviewer | `task` tool, agent_type: "critic" | +| Get review results | `read_agent` tool | +| Follow-up with reviewer | `write_agent` tool | +| Check git changes | `git --no-pager diff`, `git --no-pager log` | +| Security-focused review | `owasp_security_scan` tool + critic agent | diff --git a/.github/extensions/superpowers/skills/subagent-driven-development.md b/.github/extensions/superpowers/skills/subagent-driven-development.md new file mode 100644 index 0000000..48a11b9 --- /dev/null +++ b/.github/extensions/superpowers/skills/subagent-driven-development.md @@ -0,0 +1,115 @@ +# Subagent-Driven Development + +> Adapted from obra/superpowers (MIT) for Copilot CLI. + +Execute plans by dispatching a fresh agent per task, with two-stage review after each: spec compliance first, then code quality. + +**Why agents:** Isolated context per task prevents confusion. You construct exactly what each agent needs. This preserves your own context for coordination. + +## When to Use + +- Have an implementation plan with mostly independent tasks +- Want fast iteration with quality gates +- Tasks can be delegated to sub-agents + +## The Process + +For each task in the plan: + +### 1. Dispatch Implementer Agent + +``` +Use task tool: + agent_type: "general-purpose" (or "task" for simpler work) + prompt: [Full task text + project context + file paths] +``` + +**Include in the prompt:** +- Complete task description with all steps +- Relevant project context (architecture, patterns, conventions) +- File paths to create/modify +- Testing requirements +- "Follow TDD: write failing test → verify fail → implement → verify pass → commit" + +### 2. Handle Agent Status + +| Status | Action | +|--------|--------| +| **Completed successfully** | Proceed to spec review | +| **Completed with concerns** | Read concerns, address if about correctness/scope | +| **Needs more context** | Provide missing info via `write_agent`, re-dispatch | +| **Failed/blocked** | Assess: context problem → provide more; too complex → break down; plan wrong → escalate | + +### 3. Dispatch Spec Reviewer + +``` +Use task tool: + agent_type: "critic" + prompt: | + Review the changes for spec compliance. + Task spec: [paste task requirements] + Check: Does the implementation match EVERY requirement? + Flag: Missing requirements, extra unrequested features, deviations from spec. +``` + +- If issues found → implementer agent fixes → re-review +- If clean → proceed to quality review + +### 4. Dispatch Quality Reviewer + +``` +Use task tool: + agent_type: "critic" + prompt: | + Review code quality of recent changes. + Check: naming, error handling, test coverage, SOLID, security. + Severity levels: Critical (blocks), Important (fix before next task), Minor (note for later). +``` + +- If issues found → implementer fixes → re-review +- If clean → mark task done + +### 5. Mark Task Complete + +```sql +UPDATE todos SET status = 'done' WHERE id = 'task-N'; +``` + +### 6. Repeat for Next Task + +## Model Selection + +Use the least powerful model that can handle each role: + +| Task Type | Recommended agent_type | +|-----------|----------------------| +| Mechanical (1-2 files, clear spec) | "task" (fast/cheap) | +| Integration (multi-file, judgment) | "general-purpose" (standard) | +| Architecture/review | "critic" (most capable) | + +## After All Tasks + +1. Dispatch final code reviewer for the entire implementation +2. Run full verification: `dotnet_build_check` + `dotnet_test_check` +3. Use `superpowers_skill(skill: "verification-before-completion")` + +## Red Flags — Never Do These + +- Skip reviews (spec OR quality) +- Proceed with unfixed issues +- Dispatch multiple implementation agents in parallel (conflicts) +- Start quality review before spec compliance passes +- Move to next task while review has open issues +- Try to fix manually instead of re-dispatching (context pollution) + +## Copilot CLI Mappings + +| Superpowers Concept | Copilot CLI Equivalent | +|---|---| +| Dispatch implementer | `task` tool, agent_type: "general-purpose" or "task" | +| Dispatch spec reviewer | `task` tool, agent_type: "critic" | +| Dispatch quality reviewer | `task` tool, agent_type: "critic" | +| TodoWrite | SQL `todos` table | +| Follow-up to agent | `write_agent` tool with agent_id | +| Read agent result | `read_agent` tool with agent_id | +| Fresh subagent | Each `task` call creates isolated context | diff --git a/.github/extensions/superpowers/skills/systematic-debugging.md b/.github/extensions/superpowers/skills/systematic-debugging.md new file mode 100644 index 0000000..6701dc5 --- /dev/null +++ b/.github/extensions/superpowers/skills/systematic-debugging.md @@ -0,0 +1,124 @@ +# Systematic Debugging + +> Adapted from obra/superpowers (MIT) for Copilot CLI. + +Random fixes waste time and create new bugs. Quick patches mask underlying issues. + +**Core principle:** ALWAYS find root cause before attempting fixes. + +## The Iron Law + +``` +NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST +``` + +If you haven't completed Phase 1, you cannot propose fixes. + +## When to Use + +Use for ANY technical issue: test failures, bugs, unexpected behavior, performance problems, build failures. + +**Use ESPECIALLY when:** +- Under time pressure (emergencies make guessing tempting) +- "Just one quick fix" seems obvious +- You've already tried multiple fixes +- Previous fix didn't work + +## Phase 1: Root Cause Investigation + +**BEFORE attempting ANY fix:** + +1. **Read Error Messages Carefully** + - Don't skip past errors or warnings + - Read stack traces completely + - Note line numbers, file paths, error codes + +2. **Reproduce Consistently** + - Can you trigger it reliably? + - Exact steps? + - Every time? + +3. **Check Recent Changes** + - `git --no-pager log --oneline -10` + - `git --no-pager diff` + - New dependencies, config changes? + +4. **Gather Evidence in Multi-Component Systems** + For EACH component boundary: + - Log what data enters/exits the component + - Verify environment/config propagation + - Check state at each layer + - Run once to gather evidence showing WHERE it breaks + +5. **Trace Data Flow** + - Where does the bad value originate? + - What called this with the bad value? + - Keep tracing up until you find the source + - Fix at source, not at symptom + +## Phase 2: Pattern Analysis + +1. **Find Working Examples** — locate similar working code in same codebase +2. **Compare Against References** — read reference implementations completely, not skimming +3. **Identify Differences** — list every difference, however small +4. **Understand Dependencies** — components, settings, config, environment, assumptions + +## Phase 3: Hypothesis and Testing + +1. **Form Single Hypothesis** — "I think X is the root cause because Y" +2. **Test Minimally** — smallest possible change, one variable at a time +3. **Verify Before Continuing** — worked → Phase 4; didn't → form NEW hypothesis +4. **When You Don't Know** — say "I don't understand X", ask for help + +## Phase 4: Implementation + +1. **Create Failing Test** — use `superpowers_skill(skill: "tdd")` for the test +2. **Implement Single Fix** — ONE change, no "while I'm here" improvements +3. **Verify Fix** — test passes, no other tests broken, issue resolved +4. **If Fix Doesn't Work:** + - Count fixes attempted + - If < 3: return to Phase 1 with new information + - **If ≥ 3: STOP — question the architecture** + +### 3+ Fixes Failed? Question Architecture + +Pattern indicating architectural problem: +- Each fix reveals new coupling/problems elsewhere +- Fixes require "massive refactoring" +- Each fix creates new symptoms + +**STOP and discuss with user before attempting more fixes.** + +## Red Flags — STOP and Return to Phase 1 + +- "Quick fix for now, investigate later" +- "Just try changing X and see" +- "Add multiple changes, run tests" +- "It's probably X, let me fix that" +- Proposing solutions before tracing data flow +- "One more fix attempt" (when already tried 2+) + +## Quick Reference + +| Phase | Key Activities | Success Criteria | +|-------|---------------|------------------| +| **1. Root Cause** | Read errors, reproduce, check changes, gather evidence | Understand WHAT and WHY | +| **2. Pattern** | Find working examples, compare | Identify differences | +| **3. Hypothesis** | Form theory, test minimally | Confirmed or new hypothesis | +| **4. Implementation** | Create test, fix, verify | Bug resolved, tests pass | + +## Real-World Impact + +- Systematic approach: 15-30 minutes to fix +- Random fixes approach: 2-3 hours of thrashing +- First-time fix rate: 95% vs 40% + +## Copilot CLI Mappings + +| Superpowers Concept | Copilot CLI Equivalent | +|---|---| +| Check recent changes | `git --no-pager log`, `git --no-pager diff` | +| Run tests | `dotnet_test_check` tool | +| Create failing test | `superpowers_skill(skill: "tdd")` | +| Verify fix | `superpowers_skill(skill: "verification-before-completion")` | +| Question architecture | Use `task` tool with agent_type: "critic" | diff --git a/.github/extensions/superpowers/skills/test-driven-development.md b/.github/extensions/superpowers/skills/test-driven-development.md new file mode 100644 index 0000000..44c3961 --- /dev/null +++ b/.github/extensions/superpowers/skills/test-driven-development.md @@ -0,0 +1,147 @@ +# Test-Driven Development (TDD) + +> Adapted from obra/superpowers (MIT) for Copilot CLI. + +Write the test first. Watch it fail. Write minimal code to pass. + +**Core principle:** If you didn't watch the test fail, you don't know if it tests the right thing. + +## The Iron Law + +``` +NO PRODUCTION CODE WITHOUT A FAILING TEST FIRST +``` + +Write code before the test? Delete it. Start over. No exceptions. + +## When to Use + +**Always:** New features, bug fixes, refactoring, behavior changes. + +**Exceptions (ask user):** Throwaway prototypes, generated code, configuration files. + +## Red-Green-Refactor Cycle + +### RED — Write Failing Test + +Write one minimal test showing what should happen. + +**Requirements:** +- One behavior per test +- Clear name: `MethodName_Scenario_ExpectedResult` +- Real code (no mocks unless unavoidable) +- Arrange-Act-Assert structure + +```csharp +[Fact] +public async Task HoldFunds_ValidTransaction_ReturnsSuccess() +{ + // Arrange + var command = new HoldFundsCommand(transactionId, 500m, "USD", idempotencyKey); + + // Act + var result = await handler.Handle(command, CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result.IsSuccess.Should().BeTrue(); +} +``` + +### Verify RED — Watch It Fail + +**MANDATORY. Never skip.** + +Run: `dotnet test --filter "HoldFunds_ValidTransaction"` + +Confirm: +- Test fails (not errors) +- Failure is expected (feature missing, not typo) +- Failure message makes sense + +**Test passes?** You're testing existing behavior. Fix test. + +### GREEN — Minimal Code + +Write the simplest code to pass the test. Nothing more. + +- Don't add features not required by the test +- Don't refactor other code +- Don't "improve" beyond the test + +### Verify GREEN — Watch It Pass + +**MANDATORY.** + +Run: `dotnet test --filter "HoldFunds_ValidTransaction"` + +Confirm: +- Test passes +- Other tests still pass +- No warnings or errors + +**Test fails?** Fix code, not test. + +### REFACTOR — Clean Up + +After green only: +- Remove duplication +- Improve names +- Extract helpers + +Keep tests green. Don't add behavior. + +## Good Tests + +| Quality | Good | Bad | +|---------|------|-----| +| **Minimal** | One thing. "and" in name? Split it. | `Test_ValidatesEmailAndDomainAndWhitespace` | +| **Clear** | Name describes behavior | `Test1` | +| **Shows intent** | Demonstrates desired API | Obscures what code should do | + +## Common Rationalizations + +| Excuse | Reality | +|--------|---------| +| "Too simple to test" | Simple code breaks. Test takes 30 seconds. | +| "I'll test after" | Tests passing immediately prove nothing. | +| "Need to explore first" | Fine. Throw away exploration, start with TDD. | +| "Test hard = design unclear" | Hard to test = hard to use. Listen to the test. | +| "TDD will slow me down" | TDD faster than debugging. | + +## Red Flags — STOP and Start Over + +- Code before test +- Test passes immediately (without new code) +- Can't explain why test failed +- Rationalizing "just this once" + +**ALL of these mean: Delete code. Start over with TDD.** + +## Bug Fix Flow + +1. **RED:** Write test reproducing the bug +2. **Verify RED:** Watch it fail with the bug +3. **GREEN:** Fix the bug with minimal code +4. **Verify GREEN:** Test passes, all other tests pass +5. **REFACTOR:** Clean up if needed + +## Verification Checklist + +Before marking work complete: + +- [ ] Every new function/method has a test +- [ ] Watched each test fail before implementing +- [ ] Each test failed for expected reason +- [ ] Wrote minimal code to pass each test +- [ ] All tests pass +- [ ] Edge cases and errors covered + +## Copilot CLI Mappings + +| Superpowers Concept | Copilot CLI Equivalent | +|---|---| +| Run tests | `dotnet_test_check` tool or `dotnet test` command | +| Build check | `dotnet_build_check` tool | +| Test framework | xUnit + FluentAssertions (per project conventions) | +| Mocking | Moq (per project conventions) | diff --git a/.github/extensions/superpowers/skills/verification-before-completion.md b/.github/extensions/superpowers/skills/verification-before-completion.md new file mode 100644 index 0000000..9866abd --- /dev/null +++ b/.github/extensions/superpowers/skills/verification-before-completion.md @@ -0,0 +1,105 @@ +# Verification Before Completion + +> Adapted from obra/superpowers (MIT) for Copilot CLI. + +Claiming work is complete without verification is dishonesty, not efficiency. + +**Core principle:** Evidence before claims, always. + +## The Iron Law + +``` +NO COMPLETION CLAIMS WITHOUT FRESH VERIFICATION EVIDENCE +``` + +If you haven't run the verification command in this message, you cannot claim it passes. + +## The Gate Function + +Before claiming any status: + +1. **IDENTIFY:** What command proves this claim? +2. **RUN:** Execute the FULL command (fresh, complete) +3. **READ:** Full output, check exit code, count failures +4. **VERIFY:** Does output confirm the claim? + - If NO → state actual status with evidence + - If YES → state claim WITH evidence +5. **ONLY THEN:** Make the claim + +Skip any step = lying, not verifying. + +## Verification Requirements + +| Claim | Requires | NOT Sufficient | +|-------|----------|----------------| +| Tests pass | `dotnet_test_check` output: 0 failures | Previous run, "should pass" | +| Build succeeds | `dotnet_build_check` output: succeeded | "Linter passed" | +| Bug fixed | Test original symptom: passes | Code changed, assumed fixed | +| Conventions met | `check_conventions` output: clean | "I followed patterns" | +| Security clean | `owasp_security_scan` output: no issues | "I used parameterized queries" | +| Requirements met | Line-by-line checklist against spec | "Tests passing" | + +## Red Flags — STOP + +If you catch yourself: +- Using "should", "probably", "seems to" +- Expressing satisfaction before verification ("Great!", "Done!") +- About to commit without verification +- Relying on partial verification +- Thinking "just this once" + +## Rationalization Prevention + +| Excuse | Reality | +|--------|---------| +| "Should work now" | RUN the verification | +| "I'm confident" | Confidence ≠ evidence | +| "Just this once" | No exceptions | +| "Linter passed" | Linter ≠ compiler ≠ tests | +| "Agent said success" | Verify independently | +| "Partial check is enough" | Partial proves nothing | + +## Key Patterns + +**Tests:** +``` +✅ [Run dotnet_test_check] [See: 34/34 pass] "All 34 tests pass" +❌ "Should pass now" / "Looks correct" +``` + +**Build:** +``` +✅ [Run dotnet_build_check] [See: Build succeeded] "Build passes" +❌ "Code compiles fine" (without running build) +``` + +**Requirements:** +``` +✅ Re-read plan → Create checklist → Verify each → Report gaps or completion +❌ "Tests pass, phase complete" +``` + +**Agent delegation:** +``` +✅ Agent reports success → read_agent → Check actual output → Verify changes → Report +❌ Trust agent report without reading output +``` + +## The Bottom Line + +**No shortcuts for verification.** + +Run the command. Read the output. THEN claim the result. + +Non-negotiable. + +## Copilot CLI Mappings + +| Superpowers Concept | Copilot CLI Equivalent | +|---|---| +| Run tests | `dotnet_test_check` tool | +| Run build | `dotnet_build_check` tool | +| Check conventions | `check_conventions` tool | +| Security scan | `owasp_security_scan` tool | +| Check agent output | `read_agent` tool | +| Check secrets | `check_secrets` tool | diff --git a/.github/extensions/superpowers/skills/writing-plans.md b/.github/extensions/superpowers/skills/writing-plans.md new file mode 100644 index 0000000..9e2441b --- /dev/null +++ b/.github/extensions/superpowers/skills/writing-plans.md @@ -0,0 +1,110 @@ +# Writing Implementation Plans + +> Adapted from obra/superpowers (MIT) for Copilot CLI. + +Write comprehensive implementation plans assuming the engineer has zero context. Document everything: which files to touch, code, testing, how to verify. Bite-sized tasks. DRY. YAGNI. TDD. Frequent commits. + +## Scope Check + +If the spec covers multiple independent subsystems, break into separate plans — one per subsystem. Each plan should produce working, testable software on its own. + +## File Structure First + +Before defining tasks, map out which files will be created or modified: + +- Design units with clear boundaries and well-defined interfaces +- Prefer smaller, focused files over large ones +- Files that change together should live together +- In existing codebases, follow established patterns + +## Bite-Sized Task Granularity + +Each step is one action (2-5 minutes): +- "Write the failing test" — step +- "Run it to make sure it fails" — step +- "Implement the minimal code to make the test pass" — step +- "Run the tests and make sure they pass" — step +- "Commit" — step + +## Plan Document Header + +Every plan MUST start with: + +```markdown +# [Feature Name] Implementation Plan + +**Goal:** [One sentence] +**Architecture:** [2-3 sentences about approach] +**Tech Stack:** [Key technologies] + +--- +``` + +## Task Structure + +```markdown +### Task N: [Component Name] + +**Files:** +- Create: `exact/path/to/file.cs` +- Modify: `exact/path/to/existing.cs` +- Test: `tests/exact/path/to/test.cs` + +- [ ] **Step 1: Write the failing test** + [Actual test code] + +- [ ] **Step 2: Run test to verify it fails** + Run: [exact command] + Expected: FAIL with [reason] + +- [ ] **Step 3: Write minimal implementation** + [Actual implementation code] + +- [ ] **Step 4: Run test to verify it passes** + Run: [exact command] + Expected: PASS + +- [ ] **Step 5: Commit** + `git add [files] && git commit -m "feat: [description]"` +``` + +## No Placeholders — EVER + +These are plan failures: +- "TBD", "TODO", "implement later" +- "Add appropriate error handling" +- "Write tests for the above" (without actual test code) +- "Similar to Task N" (repeat the code) +- Steps without code blocks for code steps +- References to undefined types/functions + +## Self-Review + +After writing the complete plan: + +1. **Spec coverage:** Skim each requirement. Can you point to a task that implements it? +2. **Placeholder scan:** Search for red flags from the "No Placeholders" section +3. **Type consistency:** Do names/signatures match across tasks? + +Fix issues inline. If you find a spec requirement with no task, add the task. + +## Execution Handoff + +After saving the plan, track tasks in SQL todos: + +```sql +INSERT INTO todos (id, title, description, status) VALUES + ('task-1-name', 'Task 1: [Title]', '[Full description]', 'pending'); +``` + +Then load the execution skill: `superpowers_skill(skill: "executing-plans")` or `superpowers_skill(skill: "subagent-driven-development")` + +## Copilot CLI Mappings + +| Superpowers Concept | Copilot CLI Equivalent | +|---|---| +| Save plan | Create `plan.md` in session folder or docs/ | +| TodoWrite | SQL `todos` table with `todo_deps` | +| Subagent execution | `task` tool with agent_type: "general-purpose" | +| Inline execution | Follow executing-plans skill in current session | +| Next skill | `superpowers_skill(skill: "executing-plans")` | diff --git a/.github/hooks/README.md b/.github/hooks/README.md new file mode 100644 index 0000000..54b2a3f --- /dev/null +++ b/.github/hooks/README.md @@ -0,0 +1,52 @@ +# Git Hooks + +Automated scripts that run on git events (pre-commit, post-commit, etc.). + +## At a Glance + +| Aspect | Detail | +|--------|--------| +| **Type** | Standard git hooks — not Copilot-specific | +| **Trigger** | Git events (commit, push, merge, etc.) | +| **Location** | Each hook is a folder with its script(s) | +| **Registration** | Via `.git/hooks/` symlinks or a hook manager (husky, lefthook) | + +## Current Hooks + +| Hook | Trigger | Purpose | +|------|---------|---------| +| `secrets-scanner` | pre-commit | Scans staged files for hardcoded secrets, API keys, and credentials before allowing the commit | + +## How to Create a New Hook + +1. Create a folder under `.github/hooks/` with a descriptive name. +2. Add the hook script (shell, PowerShell, or any executable). +3. Register the script in `.git/hooks/` — either: + - Symlink manually: `ln -s ../../.github/hooks/my-hook/run.sh .git/hooks/pre-commit` + - Use a hook manager like **husky** or **lefthook** for automatic setup. + +## Hook Types Reference + +| Git Hook | When It Runs | +|----------|-------------| +| `pre-commit` | Before a commit is created — use for linting, secret scanning | +| `commit-msg` | After commit message is entered — use for message format validation | +| `pre-push` | Before pushing to remote — use for build/test verification | +| `post-commit` | After a commit is created — use for notifications | + +## Hooks vs. Extensions + +| Concern | Git Hooks | Copilot Extensions | +|---------|-----------|-------------------| +| **Trigger** | Git operations (commit, push) | AI assistant session events | +| **Runtime** | Shell / any executable | Node.js (ES modules) | +| **Purpose** | Code quality gates at git time | AI workflow enhancement | +| **Audience** | All developers | AI-assisted development | + +These are complementary: hooks enforce rules at commit time, extensions enforce rules during AI-assisted coding. + +## See Also + +- [`.github/extensions/`](../extensions/) — Copilot CLI extensions (AI-time hooks and tools) +- [`.github/extensions/build-guardian/`](../extensions/build-guardian/) — Build verification (extension-based complement to git hooks) +- [`.github/extensions/security-scanner/`](../extensions/security-scanner/) — OWASP scanning tools (extension-based complement to secrets-scanner) diff --git a/.github/hooks/secrets-scanner/scan-secrets.ps1 b/.github/hooks/secrets-scanner/scan-secrets.ps1 new file mode 100644 index 0000000..a5781e9 --- /dev/null +++ b/.github/hooks/secrets-scanner/scan-secrets.ps1 @@ -0,0 +1,197 @@ +#!/usr/bin/env pwsh +# +# Secrets Scanner — Git Pre-Commit Hook (PowerShell) +# Adapted from github/awesome-copilot (MIT License) for Windows/cross-platform. +# +# Scans staged files for hardcoded secrets, credentials, and API keys. +# Blocks the commit if critical/high severity secrets are found. +# +# Environment variables: +# SCAN_MODE - "warn" (log only) or "block" (exit non-zero) (default: block) +# SKIP_SECRETS_SCAN - "true" to disable scanning entirely +# SECRETS_ALLOWLIST - Comma-separated patterns to ignore + +param( + [string]$Mode = $env:SCAN_MODE, + [string]$AllowlistRaw = $env:SECRETS_ALLOWLIST +) + +if ($env:SKIP_SECRETS_SCAN -eq "true") { + Write-Host "⏭️ Secrets scan skipped (SKIP_SECRETS_SCAN=true)" + exit 0 +} + +if (-not $Mode) { $Mode = "block" } + +# --------------------------------------------------------------------------- +# Secret detection patterns: Name, Severity, Regex +# Ported from github/awesome-copilot hooks/secrets-scanner +# --------------------------------------------------------------------------- +$Patterns = @( + # Cloud provider credentials + @{ Name = "AWS_ACCESS_KEY"; Severity = "critical"; Regex = 'AKIA[0-9A-Z]{16}' } + @{ Name = "AWS_SECRET_KEY"; Severity = "critical"; Regex = 'aws_secret_access_key\s*[:=]\s*[''"]?[A-Za-z0-9/+=]{40}' } + @{ Name = "GCP_SERVICE_ACCOUNT"; Severity = "critical"; Regex = '"type"\s*:\s*"service_account"' } + @{ Name = "GCP_API_KEY"; Severity = "high"; Regex = 'AIza[0-9A-Za-z_-]{35}' } + @{ Name = "AZURE_CLIENT_SECRET"; Severity = "critical"; Regex = 'azure[_-]?client[_-]?secret\s*[:=]\s*[''"]?[A-Za-z0-9_~.-]{34,}' } + + # GitHub tokens + @{ Name = "GITHUB_PAT"; Severity = "critical"; Regex = 'ghp_[0-9A-Za-z]{36}' } + @{ Name = "GITHUB_OAUTH"; Severity = "critical"; Regex = 'gho_[0-9A-Za-z]{36}' } + @{ Name = "GITHUB_APP_TOKEN"; Severity = "critical"; Regex = 'ghs_[0-9A-Za-z]{36}' } + @{ Name = "GITHUB_FINE_PAT"; Severity = "critical"; Regex = 'github_pat_[0-9A-Za-z_]{82}' } + + # Private keys + @{ Name = "PRIVATE_KEY"; Severity = "critical"; Regex = '-----BEGIN (RSA |EC |OPENSSH |DSA |PGP )?PRIVATE KEY-----' } + + # Generic secrets and tokens + @{ Name = "GENERIC_SECRET"; Severity = "high"; Regex = '(secret|token|password|passwd|pwd|api[_-]?key|apikey|access[_-]?key|auth[_-]?token|client[_-]?secret)\s*[:=]\s*[''"]?[A-Za-z0-9_/+=~.-]{8,}' } + @{ Name = "CONNECTION_STRING"; Severity = "high"; Regex = '(mongodb(\+srv)?|postgres(ql)?|mysql|redis|amqp|mssql)://[^\s''"]{10,}' } + @{ Name = "BEARER_TOKEN"; Severity = "medium"; Regex = '[Bb]earer\s+[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}' } + + # SaaS tokens + @{ Name = "SLACK_TOKEN"; Severity = "high"; Regex = 'xox[baprs]-[0-9]{10,}-[0-9A-Za-z-]+' } + @{ Name = "SLACK_WEBHOOK"; Severity = "high"; Regex = 'https://hooks\.slack\.com/services/T[0-9A-Z]{8,}/B[0-9A-Z]{8,}/[0-9A-Za-z]{24}' } + @{ Name = "STRIPE_SECRET_KEY"; Severity = "critical"; Regex = 'sk_live_[0-9A-Za-z]{24,}' } + @{ Name = "STRIPE_RESTRICTED"; Severity = "high"; Regex = 'rk_live_[0-9A-Za-z]{24,}' } + @{ Name = "SENDGRID_API_KEY"; Severity = "high"; Regex = 'SG\.[0-9A-Za-z_-]{22}\.[0-9A-Za-z_-]{43}' } + @{ Name = "TWILIO_API_KEY"; Severity = "high"; Regex = 'SK[0-9a-fA-F]{32}' } + @{ Name = "NPM_TOKEN"; Severity = "high"; Regex = 'npm_[0-9A-Za-z]{36}' } + + # JWT (structured tokens) + @{ Name = "JWT_TOKEN"; Severity = "medium"; Regex = 'eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}' } +) + +# File extensions to scan (text files only) +$TextExtensions = @( + '.cs', '.razor', '.css', '.js', '.ts', '.json', '.xml', '.yaml', '.yml', + '.toml', '.ini', '.cfg', '.conf', '.md', '.txt', '.sh', '.ps1', '.bat', + '.py', '.rb', '.go', '.rs', '.java', '.html', '.sql', '.env', '.resx', + '.csproj', '.sln', '.props', '.targets', '.config' +) + +# Files to always skip +$SkipFiles = @('package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', '*.lock') + +# Placeholder patterns to ignore (false positives) +$PlaceholderPattern = '(example|placeholder|your[_-]|xxx|changeme|TODO|FIXME|replace[_-]?me|dummy|fake|test[_-]?key|sample)' + +# --------------------------------------------------------------------------- +# Get staged files +# --------------------------------------------------------------------------- +$stagedFiles = git diff --cached --name-only --diff-filter=ACMR 2>$null +if (-not $stagedFiles) { + Write-Host "✨ No staged files to scan" + exit 0 +} + +$files = $stagedFiles -split "`n" | Where-Object { $_.Trim() -ne "" } + +# Parse allowlist +$allowlist = @() +if ($AllowlistRaw) { + $allowlist = $AllowlistRaw -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" } +} + +# --------------------------------------------------------------------------- +# Scan +# --------------------------------------------------------------------------- +$findings = @() + +foreach ($filePath in $files) { + # Skip lock files + $skip = $false + foreach ($pattern in $SkipFiles) { + if ($filePath -like $pattern) { $skip = $true; break } + } + if ($skip) { continue } + + # Skip non-text files + $ext = [System.IO.Path]::GetExtension($filePath).ToLowerInvariant() + if ($ext -and $ext -notin $TextExtensions) { continue } + + # Read staged content (not working tree — what will actually be committed) + $content = $null + try { + $content = git show ":$filePath" 2>$null + } catch { continue } + if (-not $content) { continue } + + $lines = $content -split "`n" + + for ($i = 0; $i -lt $lines.Count; $i++) { + $line = $lines[$i] + + foreach ($p in $Patterns) { + if ($line -match $p.Regex) { + $matchValue = $Matches[0] + + # Skip placeholders/examples + if ($matchValue -match $PlaceholderPattern) { continue } + + # Skip allowlisted + $isAllowed = $false + foreach ($al in $allowlist) { + if ($matchValue -like "*$al*") { $isAllowed = $true; break } + } + if ($isAllowed) { continue } + + # Redact for safe display + if ($matchValue.Length -le 12) { + $redacted = "[REDACTED]" + } else { + $redacted = "$($matchValue.Substring(0,4))...$($matchValue.Substring($matchValue.Length-4))" + } + + $findings += [PSCustomObject]@{ + File = $filePath + Line = $i + 1 + Pattern = $p.Name + Severity = $p.Severity + Match = $redacted + } + } + } + } +} + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- +Write-Host "🔍 Scanned $($files.Count) staged file(s) for secrets..." + +if ($findings.Count -gt 0) { + Write-Host "" + Write-Host "⚠️ Found $($findings.Count) potential secret(s):" -ForegroundColor Yellow + Write-Host "" + Write-Host (" {0,-45} {1,-6} {2,-28} {3}" -f "FILE", "LINE", "PATTERN", "SEVERITY") + Write-Host (" {0,-45} {1,-6} {2,-28} {3}" -f "----", "----", "-------", "--------") + + foreach ($f in $findings) { + $color = switch ($f.Severity) { + "critical" { "Red" } + "high" { "Yellow" } + default { "White" } + } + Write-Host (" {0,-45} {1,-6} {2,-28} {3}" -f $f.File, $f.Line, $f.Pattern, $f.Severity) -ForegroundColor $color + } + + Write-Host "" + + if ($Mode -eq "block") { + $criticalOrHigh = $findings | Where-Object { $_.Severity -in @("critical", "high") } + if ($criticalOrHigh.Count -gt 0) { + Write-Host "🚫 Commit blocked: $($criticalOrHigh.Count) critical/high finding(s). Remove secrets before committing." -ForegroundColor Red + Write-Host " Set SCAN_MODE=warn to log without blocking, or add patterns to SECRETS_ALLOWLIST." -ForegroundColor DarkGray + exit 1 + } else { + Write-Host "💡 Medium-severity findings detected (not blocking). Review recommended." -ForegroundColor Yellow + } + } else { + Write-Host "💡 Review the findings above. Set SCAN_MODE=block to prevent commits with secrets." -ForegroundColor Yellow + } +} else { + Write-Host "✅ No secrets detected in $($files.Count) scanned file(s)" -ForegroundColor Green +} + +exit 0 diff --git a/.github/instructions/README.md b/.github/instructions/README.md new file mode 100644 index 0000000..25ee09b --- /dev/null +++ b/.github/instructions/README.md @@ -0,0 +1,74 @@ +# Copilot Pattern-Matched Instructions + +Markdown files that inject context into the AI agent when it works on files matching specific glob patterns. + +## At a Glance + +| Aspect | Detail | +|--------|--------| +| **Mechanism** | `applyTo` frontmatter with glob patterns | +| **Format** | Markdown with YAML frontmatter | +| **Trigger** | Automatically injected when the agent edits/creates a file matching the pattern | +| **Scope** | Per-file-pattern — only loads when relevant files are touched | + +## File Format + +```markdown +--- +applyTo: "**/*.cs" +--- + +# My Instruction Title + +Rules and patterns the AI should follow when working on matching files... +``` + +## Current Instruction Categories + +| Directory | `applyTo` Pattern | Purpose | +|-----------|-------------------|---------| +| `architecture/` | `**/*.cs` | Clean Architecture layer rules and dependency direction | +| `blazor/` | `**/*.razor, **/*.razor.cs, **/*.razor.css` | Code-behind pattern, CSS isolation, component lifecycle | +| `cqrs/` | `EscrowApp/Features/**/*.cs` | MediatR vertical slice structure and handler patterns | +| `database/` | `EscrowApp/Data/**/*.cs, EscrowApp/Migrations/**/*.cs` | EF Core and PostgreSQL conventions | +| `development/` | `**/*` | MVP-first development rules and anti-over-engineering | +| `domain/` | `EscrowApp/Models/**/*.cs, EscrowApp/Events/**/*.cs` | DDD guidelines — rich models, value objects, aggregates | +| `memory/` | `**/*` | Context window optimization and token budget rules | +| `resilience/` | `EscrowApp/Services/**/*.cs, EscrowApp/Infrastructure/**/*.cs` | Polly retry, circuit breaker, timeout patterns | +| `security/` | `**/*.cs, **/*.razor` | OWASP Top 10 security rules for fintech | +| `testing/` | `**/*Tests*/**/*.cs, **/*Test*/**/*.cs` | xUnit + FluentAssertions testing standards | +| `planning.instructions.md` | *(standalone file)* | Planning doc sync trigger | + +## How to Create a New Instruction + +1. Choose the appropriate category folder (or create a new one for a new concern). +2. Create a `.md` file with `applyTo` frontmatter specifying the glob pattern. +3. Write focused, actionable rules — the AI follows these as constraints. + +## Key Rules + +- **Keep instructions concise** — they consume context window tokens every time they fire. +- **Use narrow `applyTo` patterns** — `EscrowApp/Features/**/*.cs` is better than `**/*.cs` to avoid prompt bloat. +- **One concern per file** — don't mix security rules with testing standards. +- **Actionable over informational** — write rules the AI can follow, not background essays. +- **Test your patterns** — overly broad patterns cause instructions to fire on unrelated edits. + +## How It Works + +``` +Agent edits EscrowApp/Features/HoldFunds/Handler.cs + ↓ +Pattern match: **/*.cs → architecture/ instructions load +Pattern match: EscrowApp/Features/**/*.cs → cqrs/ instructions load + ↓ +Agent receives both instruction sets as additional context + ↓ +Agent follows the combined rules while generating code +``` + +## See Also + +- [`.github/skills/`](../skills/) — On-demand methodology files (loaded explicitly, not pattern-matched) +- [`.github/extensions/`](../extensions/) — Runtime tools and hooks (code, not instructions) +- [`AGENTS.md`](../../AGENTS.md) — Base instructions for all AI agents (always loaded) +- [`CLAUDE.md`](../../CLAUDE.md) — Claude-specific reasoning guidance diff --git a/.github/instructions/architecture/clean-architecture.instructions.md b/.github/instructions/architecture/clean-architecture.instructions.md new file mode 100644 index 0000000..afca27a --- /dev/null +++ b/.github/instructions/architecture/clean-architecture.instructions.md @@ -0,0 +1,108 @@ +--- +applyTo: "**/*.cs" +--- + +# Clean Architecture — Project Conventions + +## Layer Overview + +``` +Presentation (Components/) + ↓ +Application (Features/) + ↓ +Domain (Models/, Events/, Services/Strategies/ interfaces) + ↑ +Infrastructure (Data/, Infrastructure/) +``` + +Inner layers **never** reference outer layers. Dependencies always point inward. + +--- + +## Domain Layer + +**Namespaces:** `MyApp.Models`, `MyApp.Events`, `MyApp.Services.Strategies` + +Contains core business logic with zero framework dependencies. No references to EF Core, ASP.NET, MediatR, or infrastructure packages. Entities own their invariants — validate state transitions inside aggregates. Use `record` types for value objects and domain events. Strategy interfaces define **what** can happen, not **how**. Example: `Order.Process()` validates status before transition and throws `InvalidOperationException` on violations. + +| Directory | Contents | Examples | +|---|---|---| +| `Models/` | Entities, value objects, enums | `Order`, `Customer`, `Address`, `OrderStatus` | +| `Events/` | Domain events | `OrderCreatedEvent`, `OrderCompletedEvent`, `OrderCancelledEvent` | +| `Services/Strategies/` | Strategy interfaces | `IChargeable`, `IRefundable`, `ICancellable` | + +--- + +## Application Layer + +**Namespace:** `MyApp.Features.Orders.*` (vertical slices) + +Orchestrates use cases via MediatR commands/queries. Depends on Domain; never on Infrastructure. Inject **interfaces** only (`IOrderRepository`, `IEventBus`) — never concrete types or `AppDbContext`. Return result DTOs — never expose domain entities to outer layers. FluentValidation validators live next to their commands. + +| Directory | Contents | Examples | +|---|---|---| +| `Features/Orders/CreateOrder/` | Command, handler, result DTO | `CreateOrderCommand`, `CreateOrderHandler`, `CreateOrderResult` | +| `Features/Orders/CompleteOrder/` | Command, handler, result DTO | `CompleteOrderCommand`, `CompleteOrderHandler` | +| `Features/Orders/CancelOrder/` | Command, handler, result DTO | `CancelOrderCommand`, `CancelOrderHandler` | +| `Services/` | Application service interfaces | `IOrderManagerService` | + +--- + +## Infrastructure Layer + +**Namespaces:** `MyApp.Data`, `MyApp.Infrastructure` + +Implements interfaces defined in Domain and Application. Owns all external concerns. External SDK usage (payment providers, messaging, etc.) is confined to this layer. EF Core configurations (Fluent API) live in `Data/Configurations/`. Never expose `DbContext` outside this layer. Repository implementations use `FirstOrDefaultAsync()` with parameterized predicates (never raw SQL concatenation). + +| Directory | Contents | Examples | +|---|---|---| +| `Data/` | EF Core context, repository implementations, migrations | `AppDbContext`, `OrderRepository` | +| `Infrastructure/` | External integrations, auth middleware | Payment service, `InMemoryEventBus` | + +--- + +## Presentation Layer + +**Namespace:** `MyApp.Components` + +Blazor Server pages, layouts, and shared UI components. Depends on Application only. + +**Rules:** +- Never inject repositories, `DbContext`, or infrastructure services +- Always go through `IMediator.Send()` or application service interfaces +- Code-behind pattern mandatory (`.razor` + `.razor.cs` + `.razor.css`) +- Use `[CascadingParameter] Task` for auth — not `IHttpContextAccessor` + +--- + +## DI Registration in Program.cs + +Register dependencies with interface-to-implementation mapping: strategy implementations (domain interfaces), MediatR with assembly scanning, application services, `AppDbContext` with provider config, repositories, and event bus. Use `AddScoped` for request-scoped services, `AddSingleton` for stateless shared services. + +--- + +## Namespace Conventions + +| Layer | Namespace Pattern | Example | +|---|---|---| +| Domain | `MyApp.Models`, `MyApp.Events` | `MyApp.Models.Order` | +| Application | `MyApp.Features.{Aggregate}.{Action}` | `MyApp.Features.Orders.CreateOrder` | +| Infrastructure | `MyApp.Data`, `MyApp.Infrastructure` | `MyApp.Data.AppDbContext` | +| Presentation | `MyApp.Components.Pages` | `MyApp.Components.Pages.Dashboard` | + +--- + +## Anti-Patterns — What NOT to Do + +- Domain must never reference EF Core (`using MyApp.Data` is a violation) +- Application layer must never inject `AppDbContext` — use `IRepository` instead +- Blazor components must never inject repositories directly — use `IMediator.Send()` +- Handlers must never return domain entities to Presentation — map to result DTOs +- Application interfaces must never expose infrastructure types (e.g., `DbSet` is EF Core only) + +--- + +## Reference + +See `docs/` directory for full architecture documentation and decision records. diff --git a/.github/instructions/blazor/component-patterns.instructions.md b/.github/instructions/blazor/component-patterns.instructions.md new file mode 100644 index 0000000..ba8a418 --- /dev/null +++ b/.github/instructions/blazor/component-patterns.instructions.md @@ -0,0 +1,101 @@ +--- +applyTo: "**/*.razor, **/*.razor.cs, **/*.razor.css" +--- + +# Blazor Component Patterns — Project Conventions + +## Mandatory Code-Behind Pattern + +Every Blazor component consists of **three files**: + +``` +ComponentName.razor ← Markup only (HTML + Razor directives) +ComponentName.razor.cs ← Logic (partial class, lifecycle, event handlers) +ComponentName.razor.css ← Scoped styles (Bootstrap 5 overrides only) +``` + +### .razor — Markup +Contains HTML, Razor directives, and component references. **No `@code {}` blocks.** Use `@inject IStringLocalizer L` for localized strings. Render loading indicators while data loads: `@if (_orders is null) { } else { }`. + +### .razor.cs — Code-Behind +Sealed partial class with all logic. Inject services via `[Inject]` properties. Override `OnInitializedAsync` for data loading (not constructor). Implement `IDisposable` if component owns `CancellationTokenSource`. Use `protected` access on fields to allow markup binding. Call `IMediator.Send()` for all data operations. + +--- + +## Component Lifecycle + +| Method | Use When | +|---|---| +| `OnInitializedAsync` | Loading data on first render — primary data-fetch location | +| `OnParametersSetAsync` | Reacting to parameter changes from parent (e.g., selected order ID) | +| `OnAfterRenderAsync(firstRender)` | JS interop setup, DOM measurements — guard with `if (firstRender)` | +| `ShouldRender()` | Skipping re-renders on high-frequency updates (e.g., real-time feeds) | +| `Dispose` / `DisposeAsync` | Cleaning up `CancellationTokenSource`, timers, event subscriptions | + +**Never** use the constructor for async work. Always use `OnInitializedAsync`. + +--- + +## Bootstrap 5 Class Conventions + +Use these standard Bootstrap 5 classes consistently: + +| Element | Classes | +|---|---| +| Primary actions | `btn btn-primary` | +| Danger/cancel | `btn btn-outline-danger` | +| Data tables | `table table-striped table-hover` | +| Table headers | `table-dark` on `` | +| Status badges | `badge bg-success`, `badge bg-warning text-dark`, `badge bg-danger` | +| Cards | `card`, `card-header`, `card-body` | +| Forms | `form-control`, `form-label`, `form-select`, `form-check` | +| Layout | `container-fluid`, `row`, `col-md-*` | +| Spacing | `mt-3`, `mb-4`, `p-3` — use Bootstrap spacing utilities | +| Alerts | `alert alert-info`, `alert alert-danger` | + +**Do NOT** use inline `style` attributes. Apply Bootstrap utility classes or scoped CSS instead. + +--- + +## Localization + +Inject `IStringLocalizer` in every component that renders user-facing text. Resource keys: dot-separated, context-prefixed (e.g., `Dashboard.Title`, `Button.CreateOrder`). Never hardcode user-visible strings — always use localizer keys. In markup: `@Localizer["Key"]`. In code-behind: `L["Key"]`. + +--- + +## Parent-Child Communication + +### EventCallback<T> — Child notifies parent + +Child component declares `[Parameter] public EventCallback OnComplete { get; set; }` and calls `await OnComplete.InvokeAsync(_orderId)`. Parent invokes: ``. + +### CascadingParameter — Reserved for auth state only + +Only use `[CascadingParameter] private Task AuthState` for authentication. Do **not** cascade custom state objects. Use `IMediator` or scoped DI services instead. + +--- + +## StreamRendering for Progressive Loading + +Apply `[StreamRendering]` on pages that fetch data in `OnInitializedAsync`. Renders page shell immediately, streams content as data becomes available. Pair with a loading indicator that displays when `_data is null`. + +--- + +## IDisposable Cleanup + +Implement `IDisposable` when component owns: `CancellationTokenSource`, `Timer`, `PeriodicTimer`, event handler subscriptions, or `IJSObjectReference`. Call `.Cancel()` on `CancellationTokenSource` in `Dispose()`. This prevents memory leaks and circuit issues from dangling async operations. + +--- + +## Hard Rules + +| Rule | Rationale | +|---|---| +| ❌ No `@code { }` blocks in `.razor` files | Separation of concerns — logic lives in `.razor.cs` | +| ❌ No inline `style="..."` attributes | Use Bootstrap utilities or scoped `.razor.css` | +| ❌ No direct repository or DbContext injection | Go through `IMediator.Send()` only | +| ❌ No `IHttpContextAccessor` in components | Use `[CascadingParameter] Task` | +| ✅ Always `partial class` in `.razor.cs` | Required for code-behind to work | +| ✅ Always scoped `.razor.css` per component | Prevents style leakage across components | +| ✅ Always localize user-facing strings | Required for multi-locale support | +| ✅ Always cancel async work on Dispose | Prevents memory leaks and circuit issues | diff --git a/.github/instructions/cqrs/mediatr-patterns.instructions.md b/.github/instructions/cqrs/mediatr-patterns.instructions.md new file mode 100644 index 0000000..a8c7c38 --- /dev/null +++ b/.github/instructions/cqrs/mediatr-patterns.instructions.md @@ -0,0 +1,106 @@ +--- +applyTo: "**/Features/**/*.cs" +--- + +# MediatR & CQRS Patterns — Project Conventions + +## Vertical Slice Structure + +Each use case is a self-contained slice within `Features/{Aggregate}/`: + +``` +Features/ +└── Orders/ + ├── CreateOrder/ + │ ├── CreateOrderCommand.cs ← IRequest + │ ├── CreateOrderCommandValidator.cs ← FluentValidation + │ ├── CreateOrderHandler.cs ← IRequestHandler<,> + │ └── CreateOrderResult.cs ← Result DTO + ├── CompleteOrder/ + │ ├── CompleteOrderCommand.cs + │ ├── CompleteOrderCommandValidator.cs + │ ├── CompleteOrderHandler.cs + │ └── CompleteOrderResult.cs + ├── CancelOrder/ + │ ├── CancelOrderCommand.cs + │ ├── CancelOrderCommandValidator.cs + │ ├── CancelOrderHandler.cs + │ └── CancelOrderResult.cs + └── GetOrders/ + ├── GetOrdersQuery.cs + ├── GetOrdersHandler.cs + └── OrderDto.cs +``` + +**One command/query, one handler, one result per folder.** No shared handlers. + +--- + +## Command vs Query Separation + +| Aspect | Command (Write) | Query (Read) | +|---|---|---| +| Purpose | Mutate state | Return data | +| Naming | `{Verb}{Noun}Command` | `Get{Noun}Query` / `List{Noun}Query` | +| Returns | Result DTO with success/error | DTO or collection | +| Side effects | Yes — DB writes, events, payments | None — read-only | +| Validation | Always — FluentValidation required | Optional | +| Idempotency | Required for mutation commands | N/A | +| EF Tracking | Default tracking | `AsNoTracking()` | + +**Examples:** +- Commands: `CreateOrderCommand`, `CompleteOrderCommand`, `CancelOrderCommand`, `RefundOrderCommand` +- Queries: `GetOrdersQuery`, `GetOrderByIdQuery`, `ListCancelledOrdersQuery` + +--- + +## Command Definition + +Commands are immutable `record` types implementing `IRequest`. Naming: `{Action}{Aggregate}Command` (e.g., `CreateOrderCommand`, `CompleteOrderCommand`). Use business language, not technical language. Always include `IdempotencyKey` for mutation commands to prevent duplicate submissions on retry. + +--- + +## Handler Structure + +Handlers are `sealed` classes with primary constructor injection. Implement `IRequestHandler`. Single responsibility: orchestrate one use case. Log with structured data (correlation IDs, never PII). Propagate `CancellationToken` through every async call. Never inject `DbContext` or repositories directly — use interfaces. Return typed result objects for flow control (never throw for business errors). + +--- + +## Result DTOs + +Use result objects for flow control. Include `IsSuccess` boolean, typed `ErrorCode` enum, and `ErrorMessage` string. Static factory methods for each outcome make handler code readable. Never expose domain entities in results — map to DTOs. + +--- + +## Pipeline Behaviors + +Register cross-cutting concerns as MediatR pipeline behaviors. **Validation Behavior**: runs FluentValidation before handler, throws `ValidationException` if rules fail. **Logging Behavior**: logs request entry/exit with elapsed time via `Stopwatch`. Compose via `cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>))` in Program.cs. + +--- + +## Calling from Blazor Components + +**Never call services directly from components.** Always go through MediatR. Component dispatches via `IMediator.Send(new CreateOrderCommand(...))`. On success, navigate or update state. On failure, display `result.ErrorMessage` to user. This ensures validation, logging, and side effects run consistently. + +--- + +## Idempotency for Mutation Commands + +All commands that trigger external operations **must** include an `IdempotencyKey` string property. Generate keys client-side using `Guid.CreateVersion7().ToString()`. Check for existing idempotency key in handler before processing. Return cached result for duplicate requests. Pass key to external provider's API for safe retries. + +--- + +## Quick Reference + +| Concept | Convention | +|---|---| +| Folder structure | `Features/{Aggregate}/{Action}/{Command,Handler,Validator,Result}.cs` | +| Command naming | `{Verb}{Noun}Command` — `CreateOrderCommand` | +| Query naming | `Get{Noun}Query` — `GetOrdersQuery` | +| Handler class | `sealed class`, primary constructor, inject interfaces | +| Result type | `sealed record` with `IsSuccess`, `ErrorCode`, `ErrorMessage` | +| Validation | FluentValidation `AbstractValidator` per command | +| Pipeline | `ValidationBehavior` → `LoggingBehavior` → Handler | +| Component access | `IMediator.Send()` only — never bypass the pipeline | +| Mutation commands | Always include `IdempotencyKey` property | +| CancellationToken | Propagate through every async call in the chain | diff --git a/.github/instructions/database/ef-core-patterns.instructions.md b/.github/instructions/database/ef-core-patterns.instructions.md new file mode 100644 index 0000000..541027e --- /dev/null +++ b/.github/instructions/database/ef-core-patterns.instructions.md @@ -0,0 +1,66 @@ +--- +applyTo: "**/Data/**/*.cs, **/Migrations/**/*.cs" +--- + +# Entity Framework Core & PostgreSQL Patterns — Project Data Layer + +## PostgreSQL-Specific Conventions + +- Use `Npgsql.EntityFrameworkCore.PostgreSQL` as the database provider. +- Map C# `decimal` to `numeric(18,4)` for monetary values — never use `real` or `double precision`. +- Use `jsonb` columns for semi-structured data (e.g., metadata dictionaries) via `.HasColumnType("jsonb")`. +- Use `uuid` for primary keys — PostgreSQL handles `Guid` natively. +- Use `timestamptz` for all `DateTimeOffset` properties. +- If project conventions dictate **snake_case** column names, configure via `UseSnakeCaseNamingConvention()` from `EFCore.NamingConventions` — do not rename manually in Fluent API. + +## AppDbContext Configuration + +One `DbDbContext` class: `AppDbContext` — registered as scoped. Apply entity configurations via `IEntityTypeConfiguration` in separate files, loaded with `modelBuilder.ApplyConfigurationsFromAssembly(...)`. Define unique constraints on `IdempotencyKey` (prevents duplicates) and composite keys as needed. Define indexes on `Status` (filtered queries), `CreatedAt` (time-range queries), and `ExternalPaymentId` (webhook correlation). Configure relationships explicitly in Fluent API — never rely on convention in DDD models. + +## Repository Pattern + +Define `IOrderRepository` in Application/Domain layer (expresses domain intent). Implementation (`OrderRepository`) lives in Infrastructure/Data and depends on `AppDbContext`. Provide only operations the domain needs: `GetByIdAsync`, `AddAsync`, `UpdateAsync`, `ExistsByIdempotencyKeyAsync`. Return domain entities (DTOs are mapper's job in handlers). Never expose `IQueryable` from repository — leaks persistence concerns into Application layer. + +```csharp +public interface IOrderRepository +{ + Task GetByIdAsync(Guid id, CancellationToken ct); + Task AddAsync(Order order, CancellationToken ct); + Task ExistsByIdempotencyKeyAsync(string key, CancellationToken ct); +} +``` + +## Read-Only Query Patterns + +Use `AsNoTracking()` on every read-only query (eliminates change-tracker overhead). Prefer projections with `Select()` over loading full entities when consumer needs subset. Use compiled queries (`EF.CompileAsyncQuery`) for hot-path lookups (e.g., transaction status). + +## Split Queries + +When `Include()` chain loads multiple collections, use `.AsSplitQuery()` to avoid Cartesian explosion. Single-collection includes can stay as single query — split only when needed. + +## Migration Conventions + +Migration names must be **descriptive**: `AddIdempotencyKeyIndex`, `CreateCustomersTable` (never `Migration1`). Always review generated SQL (`dotnet ef migrations script`) before applying to shared environment. Keep migrations **additive** — avoid destructive changes unless behind planned strategy. Never put seed data or business logic in migrations. Use `migrationBuilder.Sql(...)` sparingly — only for DDL that EF cannot express. + +## Connection String Management + +Never hardcode connection strings in code or `appsettings.json` for production. Use **Options pattern**: bind `PostgresOptions` from configuration, inject `IOptions`. Development: use `dotnet user-secrets` or `appsettings.Development.json`. Production: use environment variables or Azure Key Vault. Configure connection pooling and timeouts explicitly in connection string. + +## Concurrency Control + +`Order` must use **optimistic concurrency** with `RowVersion` / `xmin` concurrency token. For PostgreSQL, use `xmin` system column via `.UseXminAsConcurrencyToken()`. Handle `DbUpdateConcurrencyException` in Application layer — retry or return conflict result, never silently overwrite. + +## Seeding + +Use `HasData()` **only** for reference/lookup data: `OrderStatus` enum table, `Currency` codes. Never seed transactional business data. Seed data must be deterministic and idempotent across migration runs. + +## Anti-Patterns to Avoid + +| Anti-Pattern | Why It's Harmful | Correct Approach | +|---|---|---| +| `DbContext` in Application/Presentation layers | Bypasses repository abstraction, couples layers | Access data only through `IOrderRepository` | +| Lazy loading enabled | Silent N+1 queries, unpredictable performance | Use eager loading with explicit `Include()` | +| Returning `IQueryable` from repository | Leaks persistence concerns, untestable | Return materialized collections or single entities | +| `SaveChanges()` inside repository methods | Breaks unit-of-work boundaries | Call `SaveChangesAsync()` in the handler or via `IUnitOfWork` | +| String interpolation in raw SQL | SQL injection risk | Use `FromSqlInterpolated` or parameterized queries | +| `Find()` / `FindAsync()` for read-only queries | Pollutes change tracker unnecessarily | Use `AsNoTracking().SingleOrDefaultAsync()` | diff --git a/.github/instructions/development/mvp-first.instructions.md b/.github/instructions/development/mvp-first.instructions.md new file mode 100644 index 0000000..4eb84da --- /dev/null +++ b/.github/instructions/development/mvp-first.instructions.md @@ -0,0 +1,77 @@ +--- +applyTo: "**/*.cs, **/*.razor, **/*.razor.cs, **/*.razor.css, **/*.ts, **/*.js" +--- + +# MVP-First Development Rules + +> Ship a working product fast. Iterate from there. These rules override perfectionism. + +**Core Principle:** Working software > perfect architecture. Every decision should be filtered through: _"Does this get us closer to a usable product, or is it premature optimization?"_ + +## 1. The MVP Decision Filter + +Before implementing anything, ask: Does the user see or interact with this? (YES → build it) Does the app crash without it? (YES → build it) Is it a security requirement? (YES → build it) Is it "nice to have"? (NO) Building for 10K users with 10 users? (NO) Abstracting one-off code? (NO). + +## 2. What MVP Means (and Doesn't) + +**MVP IS:** Smallest thing delivering user value with end-to-end vertical slice (UI → API → DB). Hardcoded config instead of admin panels. Direct service calls instead of queues. One database instead of microservices. Manual processes if rare. + +**MVP IS NOT:** Buggy without error handling. Missing authentication/validation. Unpayable technical debt. Throwaway code (should be improvable, not disposable). + +## 3. Build Order for Any Feature + +1. Domain model (entity + value objects) — 30 min +2. Simplest data access (EF Core, direct) — Repository interface + implementation +3. One happy-path API endpoint — MediatR command/query +4. Basic UI that calls it — Blazor page with form +5. Basic validation — FluentValidation on command +6. Basic error handling — Try-catch at handler level +7. One integration test — WebApplicationFactory happy path + +**✅ SHIP IT — everything below is v1.1+** + +8–14: Edge cases, comprehensive tests, performance, UI polish, caching, background jobs, admin dashboards. + +## 4. Anti-Over-Engineering Rules + +**MUST NOT in MVP Phase:** Generic repositories (use specific per aggregate; generalize at 5+ entities). CQRS read models (same EF model for read/write until performance fails). Event sourcing (use simple updates; sourcing is v2+). Microservices (start modular monolith; extract when bottleneck proven). Message queues (direct calls; queues for cross-service communication). Custom middleware (use built-in ASP.NET). Abstract factories (inject directly; factory at 3+ runtime implementations). Specification pattern (use LINQ; spec at 5+ reusable filters). Custom result types (use IActionResult/exceptions; Result when pattern emerges). GraphQL (use REST; GraphQL at 10+ client variations). + +**MUST DO in MVP Phase:** Clean Architecture layers (free, prevents rewrites). Interfaces for external services (swappability). FluentValidation on every command. `[Authorize]` on every endpoint (default deny). One happy-path test per feature. Code-behind pattern (`.razor` + `.razor.cs` from day one). Parameterized queries (never concatenate SQL). Structured logging (`ILogger`). Dependency injection (no `new SomeService()` in business logic). + +## 5. The "Rule of Three" for Abstraction + +Don't abstract until written the same pattern three times: +- **1st time:** Write inline. Ship it. +- **2nd time:** Note duplication. Ship it. +- **3rd time:** Extract abstraction. You have 3 real examples to design from. + +Prevents abstractions for hypothetical futures that never arrive. + +## 6. Time-Boxing Decisions + +Database choice (15 min → PostgreSQL). Auth provider (15 min → ASP.NET Identity). CSS framework (10 min → Bootstrap or Tailwind). Architecture pattern (10 min → Clean Architecture + MediatR). ORM (5 min → EF Core). Testing framework (5 min → xUnit + FluentAssertions). State management (10 min → Scoped services). API style (5 min → Minimal APIs). Logging (5 min → Serilog). Caching (skip — add when measuring). + +## 7. Definition of "Done" for MVP Features + +✅ Happy path end-to-end (UI → API → DB → response). ✅ Input validation prevents bad data. ✅ Authentication required (no anonymous business ops). ✅ Basic error handling (friendly message, not stack trace). ✅ One integration test happy path. ✅ No hardcoded secrets. ✅ Code compiles, zero warnings. + +❌ NOT DONE: Only works in Swagger, no UI. Happy path but crashes on empty input. Works but bypasses authentication. + +## 8. Iteration Cadence + +- Sprint 0: Project scaffold, auth, first entity, CI pipeline +- Sprint 1–3: Core features end-to-end + user feedback +- Sprint 4–5: Polish, edge cases, performance, production readiness +- **✅ MVP RELEASE** +- Sprint 6+: Iterate based on real user feedback, not assumptions + +## 9. When to Break These Rules + +- **Compliance requirements:** PCI-DSS, SOC2 — build regardless of MVP scope +- **Data integrity:** Getting it wrong means data loss/corruption — invest time +- **Security:** Never cut auth, validation, secret management +- **Irreversible decisions:** Database schema choices that hurt to change deserve more thought + +## 10. Red Flags You're Over-Engineering + +Stop if you catch yourself: Building admin panels before users exist. Writing "plugin systems" for one implementation. Debating architecture patterns >30 minutes. Creating more interfaces than concrete classes. Unit testing trivial getters/setters. Caching layer without measuring response times. Designing for millions on day one. Spending more time on infrastructure than features. Creating NuGet packages for single-project code. diff --git a/.github/instructions/domain/ddd-guidelines.instructions.md b/.github/instructions/domain/ddd-guidelines.instructions.md new file mode 100644 index 0000000..36aea99 --- /dev/null +++ b/.github/instructions/domain/ddd-guidelines.instructions.md @@ -0,0 +1,56 @@ +--- +applyTo: "**/Models/**/*.cs, **/Events/**/*.cs" +--- + +# Domain-Driven Design Guidelines — Project Domain + +## Rich Domain Models + +`Order` is the **aggregate root** — all state mutations flow through its public methods. Encapsulate behavior: `Process()`, `Complete()`, `Cancel()`, `AddItem()`. Never expose public setters. Use factory methods or constructors for creation, behavior methods for transitions. Guard every state transition with precondition checks — throw domain-specific exceptions when invariants are violated. + +## Value Objects + +Use Value Objects for concepts with **no identity** — equality based on structural value. Candidates: `Money` (amount + currency), `Currency`, `EmailAddress`, `PhoneNumber`. Implement as `record` or `readonly struct` with self-validation in constructor. Override equality/hash semantics (records do automatically). + +## Aggregate Boundaries + +`Order` is the **aggregate root** for order lifecycle. Child entities (`OrderItem`, line items) accessed **only** through aggregate root — never loaded independently. Persist and load entire aggregate in single unit of work for transactional consistency. Keep aggregates small — resist pulling unrelated concepts (e.g., user profiles) inside boundary. + +## Domain Events + +Raise events **from within aggregate** using base-class `AddDomainEvent()` helper. Events are **past-tense facts**: `OrderCreatedEvent`, `OrderCompletedEvent`. Carry only data needed by handlers (IDs and state), never full graphs. Events are pure data — no service dependencies or async calls. Dispatch **after** persistence (outbox pattern or `SaveChanges` interception) to avoid side effects on rollback. + +## Strategy Interfaces + +Belong in **Domain layer** — define *what* domain needs, not *how* fulfilled. `IChargeable` — charge funds, `IRefundable` — refund on cancellation, `ICancellable` — void pending charge. Infrastructure provides implementations (e.g., `StripePaymentProcessor`). Aggregate references by interface; Application injects concrete via DI. + +## Entity Invariants + +Validate **in constructor** — entity must never exist in invalid state. Use guard clauses at method entry mutating state. Required fields enforced at construction, not by external validators. Status transitions follow explicit state machine (document allowed transitions). + +``` +Created → Processing → Completed | Cancelled +Cancelled → Refunded +``` + +## Pure Domain — No Framework Dependencies + +Domain classes must be **plain C# POCOs** — no EF Core attributes. No references to MediatR, ASP.NET Core, Entity Framework, or infrastructure NuGet packages. Mapping to persistence handled in Infrastructure via Fluent API (`IEntityTypeConfiguration`). Domain events implement thin marker interface (`IDomainEvent`) defined in Domain project — not `INotification` from MediatR. + +## Participant Model + +`Customer` represents **participant** in order (buyer, seller, or role). Entity with identity but not standalone aggregate root. Store role, display name, reference to auth identity. Associated during creation — never modified independently. + +## Address & Contact Value Objects + +- Value objects like `Address` and `EmailAddress` encapsulate validated, identity-less data. +- Modeled as `record` types with self-validation in the constructor. +- Use these to avoid primitive obsession — prefer `EmailAddress` over raw `string` for email fields. +- Validate format in the Value Object; validate existence (e.g., uniqueness) at the Application layer. + +## General Rules + +- Prefer `Guid` for entity identifiers — generated at creation, not by database +- Use `DateTimeOffset` for all timestamps — never `DateTime` +- Collections from aggregates must be `IReadOnlyCollection` — mutation only through aggregate methods +- All domain code must be **synchronous** — async belongs in Application and Infrastructure diff --git a/.github/instructions/memory/memory-optimization.instructions.md b/.github/instructions/memory/memory-optimization.instructions.md new file mode 100644 index 0000000..ffe854c --- /dev/null +++ b/.github/instructions/memory/memory-optimization.instructions.md @@ -0,0 +1,64 @@ +--- +applyTo: "**/*.cs, **/*.razor, **/*.razor.cs, **/*.razor.css, **/*.ts, **/*.js" +--- + +# Memory & Context Window Optimization Rules + +> Universal rules for all AI models working on this codebase. +> Goal: maximize useful context, minimize waste, maintain continuity across sessions. + +## 1. Context Window Discipline + +**Load Only What You Need:** Never bulk-read directories; use `glob`/`grep` first, then read only matched files. Use `view_range` for specific line ranges. Batch parallel reads in a single turn. Suppress verbose output (`--quiet`, `--no-pager`, pipe to `head`). Don't re-read files you've seen unless modified. Don't echo content back unless asked. Trim build/test output — report "Build succeeded" not full logs. + +**Structured Over Verbose:** Use tables, bullet points, and summaries over prose when reporting findings. Show only relevant code snippets with 5–10 lines context, not entire files. + +## 2. Session Priming Strategy + +**First Turn Efficiency:** Use `project_summary` tool instead of reading multiple files. Check `docs/` first to find relevant feature documentation. Narrow `grep`/`glob` to layer directories: `Components/` (UI), `Features/` (business logic), `Data/` (access), `Services/Strategies/` (external providers), `Models/` / `Events/` (domain). + +**Context Checkpoints:** After completing logical units, summarize what's done. Use `/compact` when context grows large. Before compacting, ensure decisions/findings are captured in plan.md or todos. + +## 3. File Access Patterns + +**Read Order (Most Efficient First):** +1. `docs/{feature}/README.md` — high-level understanding +2. Interface/contract files — API surface +3. MediatR command/handler — business flow +4. Implementation — only if needed +5. Tests — only for verification + +**Write Order:** Plan first. Edit bottom-up (Domain → Application → Infrastructure → Presentation). Batch edits per file in a single turn. Don't interleave reads/writes. + +## 4. Search Efficiency + +Fast: `grep pattern:"IPaymentService" glob:"**/*.cs" output_mode:"files_with_matches"` (file paths only) + +Wasteful: `grep pattern:"IPaymentService" output_mode:"content" -A:50` (loads unnecessary context) + +**Progressive Disclosure:** Find files → Count matches (`count`) → Read specific matches (`content` + `-n`) → Deep dive with `view_range`. + +## 5. Sub-Agent Delegation + +**When to Delegate:** Read 1-3 files yourself. Search symbols yourself. Delegate 5+ independent areas to explore agents (parallel benefit). Delegate complex multi-file refactors to general-purpose agents. Delegate build/test to task agents (summary-only return). + +**Context Rules:** Give complete context to sub-agents (no memory sharing). Don't re-read their findings. Trust their status (pass/fail), verify only if suspicious. + +## 6. Memory Across Sessions + +**Session Store Usage:** Before starting major work, check session history with DuckDB session_store_sql. Find prior approaches to similar problems. Check plan.md for unfinished work. Query todos for pending items (`WHERE status != 'done'`). Reference previous sessions on "continue" requests. + +## 7. Token Budget Guidelines + +| Context % | Action | +|-----------|--------| +| < 30% | Normal — read freely | +| 30-60% | Selective — use view_range, prefer summaries | +| 60-80% | Conservative — delegate, summarize | +| > 80% | Critical — suggest /compact, stop reading new files | + +**Cost Estimates:** `grep`/`glob` = very low. `grep` (5 matches) = low. `view` (50 lines) = low. `view` (200 lines) = medium. `view` (500+ lines) = high. Multiple full reads = very high. + +## 8. Anti-Patterns (Never Do These) + +Cat-then-grep (use grep directly). Exploratory full reads without specific question. Re-reading edited files. Verbose confirmations (say "Created X" not full content). Sequential single-file reads (batch parallel). Ignoring docs/ when it has a README. Global unrestricted grep (always scope to directory/type). diff --git a/.github/instructions/planning.instructions.md b/.github/instructions/planning.instructions.md new file mode 100644 index 0000000..a3cff85 --- /dev/null +++ b/.github/instructions/planning.instructions.md @@ -0,0 +1,13 @@ +--- +applyTo: "EscrowApp/Features/**/*.cs, EscrowApp/Components/Pages/**/*.razor, EscrowApp/Components/Pages/**/*.razor.cs, EscrowApp/Models/**/*.cs, EscrowApp/Events/**/*.cs, EscrowApp/Services/**/*.cs, EscrowApp/Data/**/*.cs, EscrowApp/Infrastructure/**/*.cs, EscrowApp.Tests/**/*.cs, EscrowApp/Program.cs" +--- + +# Planning Documentation Sync + +When you complete work that changes project status — implementing a feature, writing tests, replacing stubs, adding new handlers/components/strategies — you **must** update: + +1. **`docs/planning/implementation-plan.md`** — Update phase completion %, move items between "What's Built" and "What's Missing", update MVP priorities. +2. **`docs/planning/task-checklist.md`** — Check off completed items (`[x]`), add new tasks, update phase status markers. +3. **Update `Last synced with codebase` date** at the top of each file. + +Use the `planning_status` tool to verify these files are current before finishing your task. diff --git a/.github/instructions/resilience/polly-patterns.instructions.md b/.github/instructions/resilience/polly-patterns.instructions.md new file mode 100644 index 0000000..3111b4a --- /dev/null +++ b/.github/instructions/resilience/polly-patterns.instructions.md @@ -0,0 +1,48 @@ +--- +applyTo: "**/Services/**/*.cs, **/Infrastructure/**/*.cs" +--- + +# Polly Resilience Patterns — External API Integration + +## Retry Policies — External API Calls + +Use **exponential backoff with jitter** for transient failures. Retry on HTTP `429`, `500`, `502`, `503`, and on `HttpRequestException` / `TimeoutRejectedException`. Start with **3 retries**, base delay 1 second, exponential multiplier 2, plus random jitter. Never retry `4xx` client errors (except `429`) — they indicate invalid requests that won't succeed on retry. + +## Circuit Breaker — External API Availability + +Break after **5 consecutive failures** in **30-second sampling window**. Stay in **open** state for **60 seconds** before **half-open**. In half-open, allow **one probe request** — succeeds → close, fails → re-open. When open, fail fast with `BrokenCircuitException` — don't queue. Log every state transition for visibility. + +## Timeout Policies + +**Always** pass and honor `CancellationToken` on every async method in call chain. Apply **optimistic timeout** of **15 seconds** per external API call (cancels underlying `HttpClient`). Apply **pessimistic timeout** of **30 seconds** as outer policy for entire operation. Handle `TimeoutRejectedException` explicitly — return timeout-specific error result. + +## Bulkhead Isolation + +Limit **concurrent external API operations** to prevent resource exhaustion cascades. Configure bulkhead of **10 concurrent executions** with **queue depth 5** for burst absorption. When rejected, return `503 Service Unavailable` with `Retry-After` header. Use separate bulkheads for critical operations vs. non-critical queries. + +## IHttpClientFactory + Polly Integration + +Register **named or typed `HttpClient`** via `IHttpClientFactory` — never instantiate manually. Attach policies using `.AddPolicyHandler()`. Compose policies via `Policy.WrapAsync()` — order matters: **Bulkhead → Circuit Breaker → Retry → Timeout** (outermost → innermost). + +## Idempotency Keys — Safe Retries + +**Every** state-changing mutation (create, capture, refund) must include `Idempotency-Key` header. Generate **deterministically** from domain operation: `{OrderId}:{Operation}:{Attempt}`. Store on aggregate — check for duplicates before initiating. Payment APIs honor idempotency keys for 24h — retries return original response, preventing duplicates. + +## Fallback Policy + +Define fallback for **every** policy chain — never let unhandled exceptions propagate silently. On failure after retries exhausted, return structured error result with context. Log final failure at `Error` level with exception details, correlation ID, operation context. Never swallow exceptions — fallback must re-throw domain exception or return typed failure. + +## Health Checks + +Expose circuit breaker state as ASP.NET Core `IHealthCheck`. Report `Degraded` when half-open, `Unhealthy` when open, `Healthy` when closed. Register at `/health/external-api` for infrastructure monitoring. Include circuit state in structured logs for incident correlation. + +## Configuration via Options Pattern + +Never hardcode policy values (retry count, timeout, concurrency limits). Bind settings from configuration using `IOptions`. Allow environment-specific overrides (shorter timeouts in tests, higher retry counts in production). + +## General Rules + +- Compose policies in a **PolicyWrap** — do not apply policies ad-hoc in individual service methods. +- Use `Context` to pass correlation IDs and operation metadata through the policy chain for structured logging. +- Test resilience behavior: use Simmy (Polly's chaos engineering library) to inject faults in integration tests. +- Review Polly policy telemetry in production — alert on elevated retry rates or frequent circuit breaks. diff --git a/.github/instructions/security/owasp-top10.instructions.md b/.github/instructions/security/owasp-top10.instructions.md new file mode 100644 index 0000000..d6d6aed --- /dev/null +++ b/.github/instructions/security/owasp-top10.instructions.md @@ -0,0 +1,115 @@ +--- +applyTo: "**/*.cs, **/*.razor" +--- + +# OWASP Top 10 Security — Project Conventions + +> Every code change must be evaluated through a security-first lens. When in doubt, choose the more restrictive option. + +--- + +## A01 — Broken Access Control + +The #1 web application security risk. Default posture: **deny all, allow explicitly.** + +### Mandatory Practices + +Apply `[Authorize]` on **every** Blazor page and API endpoint — no anonymous defaults. Use **policy-based authorization** (never inline role strings). Define all policies in `AuthorizationPolicies` class: constant names, registered via `AddPolicy`. Use **resource-based authorization** for entity-level checks via `AuthorizationService.AuthorizeAsync()`. **Never** rely on UI hiding alone — always enforce server-side. + +--- + +## A02 — Cryptographic Failures + +### Secrets Management + +Never store secrets in `appsettings.json`, source code, or production environment variables. Use **Azure Key Vault** with **Managed Identity** for production. Use `dotnet user-secrets` for local development. Store Stripe API keys in Key Vault, inject via `IOptions` Options pattern. + +### Data Protection + +Enforce **HTTPS everywhere** via `app.UseHsts()` and `app.UseHttpsRedirection()`. Encrypt sensitive fields at rest (PII, financial data). Never log tokens, API keys, connection strings, or PII — log only correlation IDs. + +--- + +## A03 — Injection + +### SQL Injection Prevention + +**Always** use EF Core parameterized queries — never string-concatenate user input. If raw SQL required, use `FromSqlInterpolated` (never `FromSqlRaw` with concatenation). + +### Input Validation + +Validate **all** input at application boundary using FluentValidation. Every MediatR command must have corresponding validator. + +```csharp +public sealed class CreateOrderCommandValidator : AbstractValidator +{ + public CreateOrderCommandValidator() + { + RuleFor(x => x.OrderId).NotEmpty(); + RuleFor(x => x.Amount).GreaterThan(0).LessThanOrEqualTo(1_000_000); + } +} +``` + +### XSS Prevention + +Blazor encodes output by default — never use `@((MarkupString)untrustedContent)`. Sanitize any user-provided HTML before rendering. + +--- + +## A05 — Security Misconfiguration + +### Secure Headers + +Configure in `Program.cs` or middleware: `app.UseHsts()`, `app.UseHttpsRedirection()`, set `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: strict-origin-when-cross-origin`, CSP headers. + +### Environment Configuration + +Never enable Swagger/OpenAPI in production. Use `builder.Environment.IsDevelopment()` guards for debug features. Disable detailed error pages in production — use `UseExceptionHandler`. + +--- + +## A07 — Identification and Authentication Failures + +### Authentication Strategy + +Use **Microsoft Entra ID** (primary) or **Duende IdentityServer** for authentication. Never implement custom auth or store plaintext passwords. Enforce MFA for privileged operations. Use `Microsoft.Identity.Web` for Entra integration. For Blazor Server: use `RevalidatingServerAuthenticationStateProvider`. Configure reasonable session timeout for workflows. + +--- + +## Data Security Standards + +### Sensitive Data Handling + +Never store raw card numbers, CVVs, or full magnetic stripe. Delegate payment processing to PCI-compliant provider (Stripe) — use tokenized references only. Store only external references (PaymentIntent IDs) in Order — never raw credentials. Audit log all sensitive operations with timestamps and user identity. + +### Third-Party API Key Management + +Keys injected via Options pattern, sourced from Key Vault. Register Stripe client with DI. Rotate keys on schedule. Use restricted keys with minimum permissions. Validate Stripe webhook signatures on every event. + +### Idempotency Keys + +All state-changing commands **must** include `IdempotencyKey` to prevent duplicates. Generate client-side (GUID v7 recommended). Store and check server-side before processing. Return cached results for duplicates. + +--- + +## Mass Assignment Prevention + +Never bind request data directly to domain entities. Use DTOs with explicit properties instead. + +--- + +## Anti-Pattern Summary + +| Anti-Pattern | Risk | Fix | +|---|---|---| +| `[AllowAnonymous]` on protected pages | Unauthorized access | `[Authorize(Policy = "...")]` | +| Hardcoded API keys or connection strings | Credential leak | Key Vault + Options pattern | +| `FromSqlRaw` with string concatenation | SQL injection | `FromSqlInterpolated` or LINQ | +| Logging user emails, tokens, card data | Data exposure | Log correlation IDs only | +| Binding domain entities in endpoints | Mass assignment | DTOs with explicit properties | +| Missing FluentValidation on commands | Invalid state / injection | Validator per command | +| Custom password hashing | Broken authentication | Entra ID / IdentityServer | +| Missing `[Authorize]` on new pages | Access control bypass | Default deny-all posture | +| `@((MarkupString)userInput)` in Razor | XSS | Never render untrusted HTML | +| Storing raw card numbers | PCI-DSS violation | Tokenized payment references only | diff --git a/.github/instructions/testing/testing-standards.instructions.md b/.github/instructions/testing/testing-standards.instructions.md new file mode 100644 index 0000000..fd6421f --- /dev/null +++ b/.github/instructions/testing/testing-standards.instructions.md @@ -0,0 +1,77 @@ +--- +applyTo: "**/*Tests*/**/*.cs, **/*Test*/**/*.cs" +--- + +# Testing Standards — Project Conventions + +## Framework & Tooling + +- **Test framework:** xUnit — use `[Fact]` for single cases, `[Theory]` with `[InlineData]` or `[MemberData]` for parameterized tests. +- **Assertions:** FluentAssertions — prefer `.Should().Be()`, `.Should().Throw()` over xUnit's `Assert.*`. +- **Mocking:** Moq or NSubstitute — pick one per project, do not mix. +- **Integration:** `Microsoft.AspNetCore.Mvc.Testing` (`WebApplicationFactory`) for API-level tests. +- **Database:** Testcontainers for PostgreSQL — spin up a real database per test class for integration tests. + +## Naming Convention + +Use pattern: **MethodName_Scenario_ExpectedResult**. Example: `CreateOrder_ValidInput_ReturnsSuccess()`, `CreateOrder_InsufficientBalance_ThrowsPaymentException()`. + +## Arrange-Act-Assert (AAA) + +Every test must have **clearly separated** AAA sections using blank lines and optional comments. Arrange: set up test data with builders. Act: execute single operation. Assert: verify results and mock invocations. + +## Unit Tests + +### MediatR Handler Tests + +Test each command/query handler **in isolation** with mocked dependencies. Mock `IOrderRepository` and all strategy interfaces. Verify correct repository/strategy calls with expected arguments. Test both success and failure paths — assert thrown exceptions. + +### Domain Model Tests + +Test aggregate root methods directly. Verify domain events raised after state transitions. Verify invariant violations throw expected domain exceptions. Test Value Object validation (negative amounts, empty strings rejected). + +### Validation Rule Tests + +Test FluentValidation validators independently — call `validator.TestValidateAsync(model)`. Cover required fields, boundary values, format constraints, and cross-field rules. + +## Integration Tests + +### API / Endpoint Tests + +Use `WebApplicationFactory` to bootstrap application. Override DI registrations to swap real infrastructure with test doubles. Use **Testcontainers** for PostgreSQL (real database). Test full pipeline: routing → binding → validation → handler → persistence → response. + +### Database Integration Tests + +Verify EF Core mappings, constraints, indexes against real PostgreSQL. Test repository implementations end-to-end: persist, retrieve, verify. Each test class gets **fresh database** (Testcontainers per fixture) — never share mutable state. + +## What to Test + +| Layer | What to Test | +|---|---| +| Domain Models | Constructor validation, behavior methods, state transitions, domain event emission, Value Object equality | +| MediatR Handlers | Business logic orchestration, correct repository/strategy calls, error handling | +| Strategy Implementations | `StripePaymentProcessor` with mocked Stripe SDK, correct PaymentIntent parameters | +| FluentValidation Rules | Required fields, boundary values, format constraints | +| API Endpoints (integration) | Full HTTP request/response cycle, status codes, response bodies, error payloads | + +## What NOT to Test + +- **EF Core mappings directly** — these are validated by integration tests against a real database. +- **Private methods** — test through the public interface that exercises them. +- **Framework behavior** — do not test that ASP.NET Core routing works or that DI resolves correctly (unless custom logic is involved). +- **Third-party library internals** — mock the boundary, don't test Stripe SDK behavior. + +## Test Data — Builder Pattern + +Use builders for complex domain objects to keep tests readable and decoupled from constructor changes. Builders with fluent interface allow test-specific configuration. + +## Coverage Targets + +- **Critical business flows** (create, complete, cancel, refund): **>90% line coverage**. +- **Domain model invariants**: **100%** — every state transition path must be tested. +- **API endpoints**: every documented status code (201, 400, 404, 409, 500) must have at least one test. +- Coverage is a guideline, not a goal — a well-tested critical path is more valuable than chasing a vanity metric across utility code. + +## General Rules + +Tests must be **deterministic** — no dependency on wall-clock time, random data, or external services. Use `CancellationToken.None` in unit tests; integration tests should test cancellation explicitly. Clean up resources in `Dispose` / `IAsyncDisposable`. Run tests in parallel by default — ensure no shared mutable state. diff --git a/.github/lsp.json b/.github/lsp.json new file mode 100644 index 0000000..eb59508 --- /dev/null +++ b/.github/lsp.json @@ -0,0 +1,24 @@ +{ + "lspServers": { + "csharp": { + "command": "dotnet", + "args": [ + "tool", + "run", + "csharp-ls", + "--solution", + "MyApp.sln" + ], + "initializationOptions": { + "AutomaticWorkspaceInit": true + }, + "fileExtensions": { + ".cs": "csharp", + ".csproj": "xml", + ".razor": "razor", + ".razor.cs": "csharp" + }, + "description": "C# Language Server (csharp-ls) for .NET 10. Install with: dotnet tool install --global csharp-ls" + } + } +} diff --git a/.github/skills/CATALOG.md b/.github/skills/CATALOG.md new file mode 100644 index 0000000..6335855 --- /dev/null +++ b/.github/skills/CATALOG.md @@ -0,0 +1,260 @@ +# Skills Catalog + +> 42 cross-platform skills organized by category. Each skill follows the Jeffallan `references/` pattern for memory-optimized lazy loading. +> **Version:** 2.2.0 | **Platforms:** Copilot CLI, Claude, Gemini + +--- + +## Quick Reference + +| # | Category | Skills | Description | +|---|----------|--------|-------------| +| 1 | [code-quality](#1-code-quality) | 7 | Code review, refactoring, documentation, debugging, quality metrics, smart refactor, tech debt | +| 2 | [security](#2-security) | 5 | OWASP audit, secret scanning, threat modeling, authentication, authorization | +| 3 | [architecture](#3-architecture) | 5 | Architecture review, design patterns, dependencies, legacy modernization, polyglot analysis | +| 4 | [testing](#4-testing) | 3 | Test generation, TDD coaching, coverage analysis | +| 5 | [database](#5-database) | 2 | Schema review, query optimization | +| 6 | [devops](#6-devops) | 4 | CI/CD, deployment preflight, monitoring, chaos engineering | +| 7 | [documentation](#7-documentation) | 3 | README, ADR, API docs | +| 8 | [research](#8-research) | 4 | Codebase exploration, tech spikes, spec mining, deep context generation | +| 9 | [project-management](#9-project-management) | 3 | Spec writing, issue creation, feature requirements | +| 10 | [ai](#10-ai) | 3 | MCP development, prompt engineering, agent orchestration | +| 11 | [language](#11-language) | 2 | .NET Core expert, C# developer | +| 12 | [workflow](#12-workflow) | 1 | Context window and token optimization | + +--- + +## All Skills + +### 1. Code Quality + +Skills for reviewing, refactoring, documenting, and debugging code. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **code-reviewer** | Review code changes for correctness, style, security, and maintainability | review code, check PR, code review | [SKILL.md](./code-reviewer/SKILL.md) | +| **refactor-planner** | Analyze code and produce a prioritized refactoring plan | refactor, clean up code, reduce tech debt | [SKILL.md](./refactor-planner/SKILL.md) | +| **code-documenter** | Generate inline documentation, XML doc comments, and usage examples | document code, add comments, explain code | [SKILL.md](./code-documenter/SKILL.md) | +| **debugging-wizard** | Systematic debugging with root cause analysis and fix verification | debug, error, stack trace, exception, crash | [SKILL.md](./debugging-wizard/SKILL.md) | +| **quality-analyzer** | Analyze code quality metrics — cyclomatic complexity, cognitive complexity, maintainability index, SATD | analyze quality, code metrics, complexity analysis, maintainability | [SKILL.md](./quality-analyzer/SKILL.md) | +| **smart-refactor** | Metrics-driven refactoring with baseline/after comparison and Fowler patterns | smart refactor, measure refactor, complexity reduction | [SKILL.md](./smart-refactor/SKILL.md) | +| **tech-debt-tracker** | Detect, quantify, and prioritize technical debt — SATD detection, hour estimation, sprint planning | track tech debt, SATD scan, debt inventory, debt report | [SKILL.md](./tech-debt-tracker/SKILL.md) | + +--- + +### 2. Security + +Skills for auditing, scanning, and modeling security threats. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **owasp-audit** | Audit code against OWASP Top 10 vulnerabilities | security audit, owasp check, vulnerability scan | [SKILL.md](./owasp-audit/SKILL.md) | +| **secret-scanner** | Detect hardcoded secrets, API keys, and credentials in source code | scan secrets, find credentials, check for keys | [SKILL.md](./secret-scanner/SKILL.md) | +| **threat-modeler** | Create STRIDE-based threat models for system components | threat model, security design, risk analysis | [SKILL.md](./threat-modeler/SKILL.md) | +| **authentication** | Implement authentication with Entra ID, IdentityServer, or ASP.NET Core Identity | authentication, login, Entra ID, JWT, OIDC | [SKILL.md](./authentication/SKILL.md) | +| **authorization** | Implement policy-based, role-based, and resource-based authorization | authorization, policies, roles, claims, access control | [SKILL.md](./authorization/SKILL.md) | + +--- + +### 3. Architecture + +Skills for reviewing architecture, recommending patterns, analyzing dependencies, and modernizing legacy systems. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **architecture-reviewer** | Review system architecture for quality attributes and anti-patterns | review architecture, check design, architecture audit | [SKILL.md](./architecture-reviewer/SKILL.md) | +| **design-pattern-advisor** | Recommend and apply appropriate design patterns to solve structural problems | suggest pattern, which pattern, design advice | [SKILL.md](./design-pattern-advisor/SKILL.md) | +| **dependency-analyzer** | Analyze project dependencies for risks, updates, and license compliance | check dependencies, audit packages, outdated packages | [SKILL.md](./dependency-analyzer/SKILL.md) | +| **legacy-modernizer** | Plan and execute modernization of legacy codebases to modern architectures | modernize, migrate, upgrade legacy, rewrite | [SKILL.md](./legacy-modernizer/SKILL.md) | +| **polyglot-analyzer** | Analyze multi-language codebases — language distribution, cross-language quality comparison, unified gates | polyglot analysis, multi-language, language distribution | [SKILL.md](./polyglot-analyzer/SKILL.md) | + +--- + +### 4. Testing + +Skills for generating tests, coaching TDD, and analyzing coverage. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **test-generator** | Generate unit and integration tests with Arrange-Act-Assert structure | write tests, generate tests, add test coverage | [SKILL.md](./test-generator/SKILL.md) | +| **tdd-coach** | Guide test-driven development with red-green-refactor cycle | tdd, test first, red green refactor | [SKILL.md](./tdd-coach/SKILL.md) | +| **test-coverage-analyzer** | Analyze test coverage gaps and recommend high-value tests to add | coverage gaps, missing tests, improve coverage | [SKILL.md](./test-coverage-analyzer/SKILL.md) | + +--- + +### 5. Database + +Skills for reviewing schemas and optimizing queries. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **schema-reviewer** | Review database schema design for normalization, indexing, and integrity | review schema, check database design, schema audit | [SKILL.md](./schema-reviewer/SKILL.md) | +| **query-optimizer** | Analyze and optimize SQL queries for performance | optimize query, slow query, query performance | [SKILL.md](./query-optimizer/SKILL.md) | + +--- + +### 6. DevOps + +Skills for building CI/CD pipelines, validating deployments, monitoring, and chaos engineering. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **ci-cd-builder** | Create or improve CI/CD pipeline configurations | build pipeline, create CI, setup CD, github actions | [SKILL.md](./ci-cd-builder/SKILL.md) | +| **deployment-preflight** | Run pre-deployment checks and generate go/no-go reports | preflight check, ready to deploy, deployment review | [SKILL.md](./deployment-preflight/SKILL.md) | +| **monitoring-expert** | Design observability stacks with metrics, logs, traces, and alerting | monitoring, observability, alerts, dashboards, SLO | [SKILL.md](./monitoring-expert/SKILL.md) | +| **chaos-engineer** | Design and execute chaos experiments to verify system resilience | chaos testing, resilience, fault injection, game day | [SKILL.md](./chaos-engineer/SKILL.md) | + +--- + +### 7. Documentation + +Skills for generating READMEs, ADRs, and API documentation. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **readme-generator** | Generate comprehensive README files from project analysis | create readme, write readme, project documentation | [SKILL.md](./readme-generator/SKILL.md) | +| **adr-creator** | Create Architecture Decision Records following the ADR standard | create ADR, document decision, architecture decision | [SKILL.md](./adr-creator/SKILL.md) | +| **api-documenter** | Generate API documentation from code with examples and schemas | document API, API docs, endpoint documentation | [SKILL.md](./api-documenter/SKILL.md) | + +--- + +### 8. Research + +Skills for exploring codebases, planning technical spikes, and mining specifications. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **codebase-explorer** | Explore and map unfamiliar codebases to build understanding | explore codebase, understand code, map architecture | [SKILL.md](./codebase-explorer/SKILL.md) | +| **tech-spike-planner** | Plan time-boxed technical investigations with clear success criteria | plan spike, technical investigation, research task | [SKILL.md](./tech-spike-planner/SKILL.md) | +| **spec-miner** | Extract implicit specifications from code, tests, and documentation | mine specs, extract requirements, reverse engineer | [SKILL.md](./spec-miner/SKILL.md) | +| **deep-context-generator** | Generate LLM-optimized codebase context for onboarding and architecture understanding | generate context, codebase overview, onboarding, architecture map | [SKILL.md](./deep-context-generator/SKILL.md) | + +--- + +### 9. Project Management + +Skills for writing specifications, creating issues, and forging feature requirements. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **spec-writer** | Write comprehensive technical specifications from feature requests | write spec, create specification, define requirements | [SKILL.md](./spec-writer/SKILL.md) | +| **issue-creator** | Create structured GitHub issues with acceptance criteria and sub-task decomposition | create issue, write issue, file bug, create ticket | [SKILL.md](./issue-creator/SKILL.md) | +| **feature-forge** | Generate complete feature breakdowns with stories, tasks, and acceptance criteria | feature breakdown, user stories, requirements, epic | [SKILL.md](./feature-forge/SKILL.md) | + +--- + +### 10. AI + +Skills for MCP development, prompt engineering, and multi-agent orchestration. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **mcp-developer** | Build, debug, and extend MCP servers/clients — tool handlers, resources, transports, schemas | MCP, Model Context Protocol, MCP server, AI tools, JSON-RPC | [SKILL.md](./mcp-developer/SKILL.md) | +| **prompt-engineer** | Write, refactor, and evaluate LLM prompts — templates, structured outputs, evaluation rubrics | prompt engineering, prompt optimization, chain-of-thought, few-shot, system prompts | [SKILL.md](./prompt-engineer/SKILL.md) | +| **agent-orchestrator** | Orchestrate parallel sub-agent fleets with token-aware delegation, DAG dependencies, and result aggregation | orchestrate agents, parallel tasks, multi-agent, fleet management, token budget | [SKILL.md](./agent-orchestrator/SKILL.md) | + +--- + +### 11. Language + +Language-specific skills for .NET Core and C# development. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **dotnet-core-expert** | Deep .NET 10 expertise — minimal APIs, Clean Architecture, EF Core, CQRS/MediatR, JWT auth, AOT | .NET Core, .NET 10, ASP.NET Core, C# 13, minimal API, Entity Framework Core, microservices | [SKILL.md](./dotnet-core-expert/SKILL.md) | +| **csharp-developer** | Senior C# 13 developer — records, pattern matching, primary constructors, Blazor, performance | C#, .NET, Blazor, Entity Framework, EF Core, SignalR, Minimal API | [SKILL.md](./csharp-developer/SKILL.md) | + +--- + +### 12. Workflow + +Skills for optimizing AI-assisted development workflows and context management. + +| Skill | Description | Triggers | Link | +|-------|-------------|----------|------| +| **memory-optimization** | Context window and token optimization rules — load less, achieve more | optimize context, reduce tokens, context window, memory management, token budget | [SKILL.md](./memory-optimization/SKILL.md) | + +--- + +## Directory Structure + +``` +.github/ +└── skills/ + ├── CATALOG.md # ← This file (master index) + ├── adr-creator/SKILL.md + references/ + ├── agent-orchestrator/SKILL.md + references/ + ├── api-documenter/SKILL.md + references/ + ├── architecture-reviewer/SKILL.md + references/ + ├── authentication/SKILL.md + references/ + ├── authorization/SKILL.md + references/ + ├── chaos-engineer/SKILL.md + references/ + ├── ci-cd-builder/SKILL.md + references/ + ├── code-documenter/SKILL.md + references/ + ├── code-reviewer/SKILL.md + references/ + ├── codebase-explorer/SKILL.md + references/ + ├── csharp-developer/SKILL.md + references/ + ├── debugging-wizard/SKILL.md + references/ + ├── deep-context-generator/SKILL.md + references/ + ├── dependency-analyzer/SKILL.md + references/ + ├── deployment-preflight/SKILL.md + references/ + ├── design-pattern-advisor/SKILL.md + references/ + ├── dotnet-core-expert/SKILL.md + references/ + ├── feature-forge/SKILL.md + references/ + ├── issue-creator/SKILL.md + references/ + ├── legacy-modernizer/SKILL.md + references/ + ├── mcp-developer/SKILL.md + references/ + ├── memory-optimization/SKILL.md + references/ + ├── monitoring-expert/SKILL.md + references/ + ├── owasp-audit/SKILL.md + references/ + ├── polyglot-analyzer/SKILL.md + references/ + ├── prompt-engineer/SKILL.md + references/ + ├── quality-analyzer/SKILL.md + references/ + ├── query-optimizer/SKILL.md + references/ + ├── readme-generator/SKILL.md + references/ + ├── refactor-planner/SKILL.md + references/ + ├── schema-reviewer/SKILL.md + references/ + ├── secret-scanner/SKILL.md + references/ + ├── smart-refactor/SKILL.md + references/ + ├── spec-miner/SKILL.md + references/ + ├── spec-writer/SKILL.md + references/ + ├── tdd-coach/SKILL.md + references/ + ├── tech-debt-tracker/SKILL.md + references/ + ├── tech-spike-planner/SKILL.md + references/ + ├── test-coverage-analyzer/SKILL.md + references/ + ├── test-generator/SKILL.md + references/ + └── threat-modeler/SKILL.md + references/ +``` + +## Using Skills + +### Copilot CLI + +```bash +# Skills in .github/skills/ are automatically discovered +# Trigger by name or keyword: +copilot "Use the agent-orchestrator skill to coordinate parallel analysis" +copilot "Use the csharp-developer skill to write a Blazor component" +``` + +### Claude + +1. **Project Knowledge** — Add `SKILL.md` files to your Claude project's knowledge base +2. **Direct Reference** — Ask Claude: *"Follow the prompt-engineer skill to design a prompt for [task]"* + +### Gemini + +1. **Context Window** — Paste the `SKILL.md` content at the start of your conversation +2. **Gems** — Create a custom Gem with the skill content as instructions + +## Conventions + +- Each skill lives in `//SKILL.md` with a `references/` directory +- Reference files are lazy-loaded — only read when their "Load When" condition is met +- All skills target v2.0.0 with `allowed-tools`, `related-skills`, and `output-format` metadata +- Skills are self-contained but declare related skills for cross-referencing +- All skills target the same three platforms: `copilot-cli`, `claude`, `gemini` + +--- + +*Skills Catalog — MIT License* diff --git a/.github/workflows/azure-functions.yml b/.github/workflows/azure-functions.yml index 56cd5eb..fad8b91 100644 --- a/.github/workflows/azure-functions.yml +++ b/.github/workflows/azure-functions.yml @@ -1,26 +1,9 @@ name: Deploy Azure Function -# ============================================================================== -# WORKFLOW OVERVIEW -# ============================================================================== -# This workflow implements a Blue/Green deployment strategy for an Azure Function App. -# It is split into 3 jobs that run in sequence: -# -# 1. BUILD – Compiles and packages the Function App (runs on every trigger). -# 2. DEPLOY-STAGING – Deploys to a staging Function App (runs ONLY on pull requests). -# 3. DEPLOY-PRODUCTION – Deploys to the live Function App (runs ONLY on push/manual to master). -# -# Flow: build ──► deploy-staging (if PR) -# ──► deploy-production (if push to master or manual trigger) -# ============================================================================== +# Deployment Strategy: +# - Push to 'master' (i.e. a merged PR from 'development') deploys to the live Function App. +# - Pull requests to 'master' only build/compile-check — no deploy, no staging environment. -# ============================================================================== -# SECTION 1: TRIGGERS -# Defines the events that cause this workflow to run. -# - push: Runs when code is pushed to 'master' (only if Api/ or this file changed). -# - pull_request: Runs when a PR targeting 'master' is opened/updated (same path filter). -# - workflow_dispatch: Allows running the workflow manually from the GitHub Actions UI. -# ============================================================================== on: push: branches: @@ -37,25 +20,12 @@ on: - '.github/workflows/azure-functions.yml' workflow_dispatch: # Allow manual trigger -# ============================================================================== -# SECTION 2: GLOBAL ENVIRONMENT VARIABLES -# Shared constants available to all jobs. Centralises names and versions so they -# only need to be updated in one place. -# ============================================================================== env: - AZURE_FUNCTIONAPP_NAME: 'cloudzen-api-func-e4gehdaef9ftdhbn' # Production Function App name - AZURE_FUNCTIONAPP_NAME_STAGING: 'cloudzen-api-func-staging' # Staging Function App name - AZURE_FUNCTIONAPP_PACKAGE_PATH: 'Api' # Path to the Azure Function project - DOTNET_VERSION: '8.0.x' # .NET SDK version to use + AZURE_FUNCTIONAPP_NAME: 'cloudzen-api-func-e4gehdaef9ftdhbn' # Update if your production Function App hostname differs + AZURE_FUNCTIONAPP_PACKAGE_PATH: 'Api' # Path to your Azure Function project + DOTNET_VERSION: '8.0.x' jobs: - # ============================================================================ - # JOB 1: BUILD - # Runs on EVERY trigger (push, PR, manual). - # Restores NuGet packages, compiles the project in Release mode, publishes - # the output, and uploads it as a pipeline artifact so downstream deploy - # jobs can consume it. - # ============================================================================ build: runs-on: ubuntu-latest steps: @@ -67,20 +37,15 @@ jobs: with: dotnet-version: ${{ env.DOTNET_VERSION }} - # Restore NuGet dependencies before building - name: Restore dependencies run: dotnet restore ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }} - # Compile the project in Release configuration - name: Build run: dotnet build ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }} --configuration Release --no-restore - # Create the deployment-ready package under ./output - name: Publish run: dotnet publish ${{ env.AZURE_FUNCTIONAPP_PACKAGE_PATH }} --configuration Release --no-build --output ./output - # Upload the published output as an artifact named 'function-app' - # so the deploy jobs can download and deploy it - name: Upload build artifact uses: actions/upload-artifact@v4 with: @@ -88,43 +53,11 @@ jobs: path: ./output include-hidden-files: true - # ============================================================================ - # JOB 2: DEPLOY TO STAGING - # Runs ONLY when the trigger is a pull_request. - # Downloads the build artifact and deploys it to the STAGING Function App - # using a publish profile stored in GitHub Secrets. - # This lets reviewers test the API changes alongside the SWA preview environment. - # ============================================================================ - deploy-staging: - if: github.event_name == 'pull_request' - needs: build # Waits for the build job to complete - runs-on: ubuntu-latest - environment: staging # Uses the 'staging' GitHub environment (for secrets/protection rules) - steps: - - name: Download build artifact - uses: actions/download-artifact@v4 - with: - name: function-app - path: ./output - - # Deploy the artifact to the staging Azure Function App - - name: Deploy to Staging Function App - uses: Azure/functions-action@v1 - with: - app-name: ${{ env.AZURE_FUNCTIONAPP_NAME_STAGING }} - package: './output' - publish-profile: ${{ secrets.AZURE_FUNCTIONAPP_PUBLISH_PROFILE_STAGING }} - - # ============================================================================ - # JOB 3: DEPLOY TO PRODUCTION - # Runs ONLY when code is pushed to 'master' or the workflow is triggered manually. - # Downloads the build artifact and deploys it to the PRODUCTION Function App. - # ============================================================================ deploy-production: if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' - needs: build # Waits for the build job to complete + needs: build runs-on: ubuntu-latest - environment: production # Uses the 'production' GitHub environment (for secrets/protection rules) + environment: production steps: - name: Download build artifact uses: actions/download-artifact@v4 @@ -132,7 +65,6 @@ jobs: name: function-app path: ./output - # Deploy the artifact to the production Azure Function App - name: Deploy to Production Function App uses: Azure/functions-action@v1 with: diff --git a/.github/workflows/azure-static-web-apps.yml b/.github/workflows/azure-static-web-apps.yml index e92516a..34b89e2 100644 --- a/.github/workflows/azure-static-web-apps.yml +++ b/.github/workflows/azure-static-web-apps.yml @@ -1,37 +1,19 @@ -# ============================================================================== -# WORKFLOW OVERVIEW -# ============================================================================== -# This workflow implements a Blue/Green deployment strategy for the Blazor WASM -# Static Web App. It is split into 4 jobs: +# Azure CloudZen Static Web Apps CI/CD # -# 1. BUILD – Compiles and publishes the Blazor WASM app (runs on every trigger). -# For PRs, staging config is applied before publish so the preview -# environment points to the staging Function App. -# 2. DEPLOY-STAGING – Deploys to an SWA preview environment (runs ONLY on pull requests). -# 3. DEPLOY-PRODUCTION – Deploys to the live Static Web App (runs ONLY on push/manual to master). -# 4. CLOSE-STAGING – Destroys the preview environment when a PR is closed/merged. +# Deployment Strategy: +# - Push to 'master' (i.e. a merged PR from 'development') deploys to the live Static Web App. +# - Pull requests to 'master' only build/compile-check — no deploy, no preview environment. # -# Flow: build ──► deploy-staging (if PR opened/updated) -# ──► deploy-production (if push to master or manual trigger) -# close-staging (if PR closed — independent, no build needed) -# -# Build & Deploy details: -# - "dotnet publish" compiles the Blazor WASM app and outputs to "publish_output/wwwroot". -# - "skip_app_build: true" tells Azure SWA to skip its built-in Oryx build engine and -# just upload the pre-built files. Portal Build Details settings are ignored entirely. -# - "app_location: publish_output/wwwroot" tells the SWA deploy action where to find -# the pre-built files to upload. -# ============================================================================== +# Build & Deploy Pipeline: +# 1. "dotnet publish" compiles the Blazor WASM app and outputs to "publish_output/wwwroot". +# 2. "skip_app_build: true" tells Azure Static Web Apps to skip its built-in Oryx build +# engine and just upload the pre-built files. This means the portal's Build Details +# settings (App location, Api location, Artifact location) are ignored entirely. +# 3. "app_location: publish_output/wwwroot" tells the SWA deploy action where to find +# the pre-built files to upload. name: Azure CloudZen Static Web Apps CI/CD -# ============================================================================== -# SECTION 1: TRIGGERS -# Defines the events that cause this workflow to run. -# - push: Runs when code is pushed to 'master' (ignores Api/ and markdown changes). -# - pull_request: Runs when a PR targeting 'master' is opened, updated, or closed. -# - workflow_dispatch: Allows running the workflow manually from the GitHub Actions UI. -# ============================================================================== on: push: branches: @@ -41,7 +23,7 @@ on: - '.github/workflows/azure-functions.yml' - '*.md' # Don't trigger for markdown changes pull_request: - types: [opened, synchronize, reopened, closed] + types: [opened, synchronize, reopened] branches: - master paths-ignore: @@ -50,140 +32,57 @@ on: - '*.md' workflow_dispatch: # Allow manual trigger -# ============================================================================== -# SECTION 2: GLOBAL ENVIRONMENT VARIABLES -# Shared constants available to all jobs. The hostnames are used to swap the -# API endpoint when building for staging vs production. -# ============================================================================== -env: - PRODUCTION_FUNC_HOSTNAME: 'cloudzen-api-func-e4gehdaef9ftdhbn.westus2-01.azurewebsites.net' # Production Function App hostname - STAGING_FUNC_HOSTNAME: 'cloudzen-api-func-staging-hch0amaed0gke2dv.westus2-01.azurewebsites.net' # Staging Function App hostname - DOTNET_VERSION: '8.0.x' # .NET SDK version to use - jobs: - # ============================================================================ - # JOB 1: BUILD - # Runs on EVERY trigger except PR close. - # Restores NuGet packages, compiles the Blazor WASM project in Release mode, - # applies staging configuration when building for a PR, publishes the output, - # and uploads it as a pipeline artifact so downstream deploy jobs can consume it. - # ============================================================================ build: - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.action != 'closed') || github.event_name == 'workflow_dispatch' + if: github.event_name == 'pull_request' runs-on: ubuntu-latest + name: Build Job (PR check, no deploy) steps: - name: Checkout repository uses: actions/checkout@v4 with: submodules: true - - name: Setup .NET ${{ env.DOTNET_VERSION }} + - name: Setup .NET uses: actions/setup-dotnet@v4 with: - dotnet-version: ${{ env.DOTNET_VERSION }} + dotnet-version: '8.0.x' - # Restore NuGet dependencies before building - name: Restore dependencies run: dotnet restore CloudZen.csproj - # Compile the project in Release configuration - name: Build run: dotnet build CloudZen.csproj --configuration Release --no-restore - # For PR builds (staging), swap in staging configuration before publish - # so the preview environment points to the staging Function App API - - name: Apply staging configuration - if: github.event_name == 'pull_request' - run: | - echo "Applying staging configuration for preview environment..." - cp wwwroot/appsettings.Staging.json wwwroot/appsettings.Production.json - sed -i "s|${{ env.PRODUCTION_FUNC_HOSTNAME }}|${{ env.STAGING_FUNC_HOSTNAME }}|g" wwwroot/staticwebapp.config.json - - # Publish the Blazor WASM app to publish_output/wwwroot - # BlazorEnableCompression=false because SWA handles compression at the edge - - name: Publish Blazor App - run: dotnet publish CloudZen.csproj -c Release -o publish_output -p:BlazorEnableCompression=false - - # Upload the published output as an artifact named 'swa-app' - # so the deploy jobs can download and deploy it - - name: Upload build artifact - uses: actions/upload-artifact@v4 - with: - name: swa-app - path: publish_output/wwwroot - include-hidden-files: true - - # ============================================================================ - # JOB 2: DEPLOY TO STAGING - # Runs ONLY when the trigger is a pull_request (opened/updated). - # Downloads the build artifact and deploys it to an SWA preview environment - # with a unique URL, so reviewers can test the Blazor app alongside the - # staging Function App. - # ============================================================================ - deploy-staging: - if: github.event_name == 'pull_request' && github.event.action != 'closed' - needs: build # Waits for the build job to complete + build-and-deploy: + if: github.event_name == 'push' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - environment: staging # Uses the 'staging' GitHub environment (for secrets/protection rules) + name: Build and Deploy Job steps: - - name: Download build artifact - uses: actions/download-artifact@v4 + - name: Checkout repository + uses: actions/checkout@v4 with: - name: swa-app - path: ./swa-output + submodules: true - # Deploy the pre-built Blazor app to an SWA preview environment - # skip_app_build: true — tells SWA to use our pre-built files as-is - - name: Deploy to SWA Preview Environment - uses: Azure/static-web-apps-deploy@v1 + - name: Setup .NET + uses: actions/setup-dotnet@v4 with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} - repo_token: ${{ secrets.GITHUB_TOKEN }} - action: "upload" - skip_app_build: true - app_location: "./swa-output" + dotnet-version: '8.0.x' - # ============================================================================ - # JOB 3: DEPLOY TO PRODUCTION - # Runs ONLY when code is pushed to 'master' or the workflow is triggered manually. - # Downloads the build artifact and deploys it to the live Static Web App. - # ============================================================================ - deploy-production: - if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' - needs: build # Waits for the build job to complete - runs-on: ubuntu-latest - environment: production # Uses the 'production' GitHub environment (for secrets/protection rules) - steps: - - name: Download build artifact - uses: actions/download-artifact@v4 - with: - name: swa-app - path: ./swa-output + - name: Restore dependencies + run: dotnet restore CloudZen.csproj - # Deploy the pre-built Blazor app to the live SWA production slot - # skip_app_build: true — tells SWA to use our pre-built files as-is - - name: Deploy to SWA Production + - name: Build + run: dotnet build CloudZen.csproj --configuration Release --no-restore + + - name: Publish Blazor App + run: dotnet publish CloudZen.csproj -c Release -o publish_output -p:BlazorEnableCompression=false + + - name: Deploy to Azure Static Web Apps uses: Azure/static-web-apps-deploy@v1 with: azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} repo_token: ${{ secrets.GITHUB_TOKEN }} action: "upload" skip_app_build: true - app_location: "./swa-output" - - # ============================================================================ - # JOB 4: CLOSE STAGING - # Runs ONLY when a PR is closed (merged or abandoned). - # Destroys the SWA preview environment to free up resources. - # This job is independent — it does NOT need a build. - # ============================================================================ - close-staging: - if: github.event_name == 'pull_request' && github.event.action == 'closed' - runs-on: ubuntu-latest - steps: - # Tell SWA to tear down the preview environment for this PR - - name: Destroy preview environment - uses: Azure/static-web-apps-deploy@v1 - with: - azure_static_web_apps_api_token: ${{ secrets.AZURE_STATIC_WEB_APPS_API_TOKEN }} - action: "close" \ No newline at end of file + app_location: "publish_output/wwwroot" \ No newline at end of file diff --git a/.gitignore b/.gitignore index cb1b301..677c37c 100644 --- a/.gitignore +++ b/.gitignore @@ -366,3 +366,15 @@ MigrationBackup/ /Api/local.settings.json /Api/local.settings.json /cloudzen-chatbot.jsx + +# Claude Code — sensitive/runtime files +.claude/settings.local.json +.claude/settings.local*.json +.claude/settings.local.*.json +.claude/*.bak +.claude/*.backup +.claude/hooks/.rate-limit-timestamp +.claude/hooks/notifications.log +.claude/hooks/notification-config.json +.claude/hooks/smtp-cred-*.xml +.claude/hooks/test.txt diff --git a/.impeccable/design.json b/.impeccable/design.json new file mode 100644 index 0000000..708cedc --- /dev/null +++ b/.impeccable/design.json @@ -0,0 +1,118 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-06-04T21:11:38.000Z", + "title": "Design System: CloudZen", + "extensions": { + "colorMeta": { + "teal-brand": { "role": "primary", "displayName": "Aqua Signal", "canonical": "#61C2C8", "tonalRamp": ["#DAF6F9","#B8EFF4","#89D6DC","#78BCC2","#659FA5","#61C2C8","#538488","#40676B","#2F4E51","#1F3638","#0F1E1F"] }, + "cta-orange": { "role": "secondary", "displayName": "Action Orange", "canonical": "#f97316", "tonalRamp": ["#fff7ed","#ffedd5","#fed7aa","#fb923c","#f97316","#ea580c","#c2410c"] }, + "teal-600": { "role": "accent", "displayName": "Deep Signal Teal", "canonical": "#40676B" }, + "teal-700": { "role": "ink-body", "displayName": "Ink Teal", "canonical": "#2F4E51" }, + "teal-800": { "role": "ink-heading","displayName": "Heading Teal", "canonical": "#1F3638" }, + "teal-900": { "role": "dark-surface","displayName": "Deep Teal Surface", "canonical": "#0F1E1F" }, + "teal-50": { "role": "tint", "displayName": "Teal Mist", "canonical": "#DAF6F9" }, + "surface-white":{ "role": "neutral-bg", "displayName": "Surface White", "canonical": "#ffffff" }, + "ink-body": { "role": "neutral-ink","displayName": "Ink Body", "canonical": "#374151" }, + "ink-heading": { "role": "neutral-ink","displayName": "Ink Heading", "canonical": "#1f2937" } + }, + "typographyMeta": { + "display": { "displayName": "Display", "purpose": "Hero headlines. One per view. Never exceed 3.75rem." }, + "headline": { "displayName": "Headline", "purpose": "Section headings, card group headers." }, + "title": { "displayName": "Title", "purpose": "Individual card titles, step labels." }, + "body": { "displayName": "Body", "purpose": "All prose. Max 65–72ch line length in reading sections." }, + "label": { "displayName": "Label", "purpose": "Buttons, nav, badges. IBM Plex Sans always." }, + "caption": { "displayName": "Caption", "purpose": "Metadata, timestamps, stat labels." } + }, + "shadows": [ + { "name": "ambient-low", "value": "0 2px 8px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.04)", "purpose": "Workflow node cards at rest; small floating elements." }, + { "name": "ambient-mid", "value": "0 4px 16px rgba(0,0,0,0.10)", "purpose": "Modals, dropdowns, badges floating over content." }, + { "name": "hover-lift", "value": "0 8px 24px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.06)","purpose": "Interactive card hover state." }, + { "name": "glow-teal", "value": "0 0 20px rgba(97,194,200,0.45), 0 0 40px rgba(97,194,200,0.2)","purpose": "Animated node pulse — never static elevation." }, + { "name": "glow-orange", "value": "0 0 20px rgba(249,115,22,0.45), 0 0 40px rgba(249,115,22,0.2)","purpose": "Animated orange-highlight node pulse." }, + { "name": "cta-button", "value": "0 4px 12px rgba(249,115,22,0.3)", "purpose": "CTA button hover shadow." } + ], + "motion": [ + { "name": "ease-standard", "value": "cubic-bezier(0.4, 0, 0.2, 1)", "purpose": "Default easing for all state transitions." }, + { "name": "ease-in-out-soft", "value": "ease-in-out", "purpose": "Float and pulse animations." }, + { "name": "duration-fast", "value": "150ms", "purpose": "Micro-interactions (button active state)." }, + { "name": "duration-base", "value": "200ms", "purpose": "Hover color/border transitions." }, + { "name": "duration-lift", "value": "300ms", "purpose": "Card hover transform and shadow." }, + { "name": "duration-flow", "value": "4s", "purpose": "Dash-flow and node-pulse animations." }, + { "name": "stagger-increment","value": "0.3s–0.5s", "purpose": "Delay increment between sequentially animated siblings." } + ], + "breakpoints": [ + { "name": "sm", "value": "640px" }, + { "name": "md", "value": "768px" }, + { "name": "lg", "value": "1024px" }, + { "name": "xl", "value": "1280px" }, + { "name": "2xl", "value": "1536px" } + ], + "zIndex": [ + { "name": "dropdown", "value": 10 }, + { "name": "sticky-header", "value": 40 }, + { "name": "mobile-overlay", "value": 45 }, + { "name": "modal-backdrop", "value": 50 }, + { "name": "modal", "value": 60 }, + { "name": "toast", "value": 70 }, + { "name": "tooltip", "value": 80 } + ], + "northStar": "The Working Prototype — built things, visible craft, purposeful motion. Every element is evidence of technical judgment.", + "rules": [ + "The Orange Monopoly Rule: orange appears on one element per screen — the primary CTA button.", + "The Teal Contrast Rule: body text minimum is teal-700 (#2F4E51) on white. Never use teal-500+ for prose.", + "The Two-Family Rule: IBM Plex Sans for structure, Helvetica Neue for reading. No third typeface.", + "The Flat-by-Default Rule: cards have no box-shadow at rest; only border-gray-100 boundary and tonal background.", + "The Uppercase Restriction: all-caps only on labels ≤ 4 words at ≤ 0.75rem with letter-spacing ≥ 0.08em.", + "The Motion-as-Flow Rule: stagger animated siblings 0.3–0.5s so reveals read as sequence, not simultaneous burst.", + "The Reduced-Motion Rule: every animation has a @media(prefers-reduced-motion: reduce) fallback." + ] + }, + "components": [ + { + "name": "Primary CTA Button", + "kind": "button", + "refersTo": "button-primary", + "description": "Primary call-to-action. Orange, rounded-full. One per screen.", + "html": "", + "css": ".ds-btn-primary { background: #f97316; color: #fff; padding: 0.75rem 1.75rem; border: none; border-radius: 9999px; font: 600 0.875rem 'IBM Plex Sans', sans-serif; letter-spacing: 0.01em; cursor: pointer; transition: background 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease; } .ds-btn-primary:hover { background: #ea6c0a; transform: translateY(-2px) scale(1.02); box-shadow: 0 4px 12px rgba(249,115,22,0.3); } .ds-btn-primary:focus-visible { outline: 2px solid #40676B; outline-offset: 3px; } @media (prefers-reduced-motion: reduce) { .ds-btn-primary { transition: background 0.2s ease; } .ds-btn-primary:hover { transform: none; } }" + }, + { + "name": "Secondary Button", + "kind": "button", + "refersTo": "button-secondary", + "description": "Ghost variant for secondary actions. White fill, gray border, rounded-full.", + "html": "", + "css": ".ds-btn-secondary { background: #fff; color: #374151; border: 2px solid #e5e7eb; padding: 0.625rem 1.5rem; border-radius: 9999px; font: 500 0.875rem 'IBM Plex Sans', sans-serif; cursor: pointer; transition: all 0.2s ease; } .ds-btn-secondary:hover { background: #DAF6F9; color: #40676B; border-color: #78BCC2; } .ds-btn-secondary:focus-visible { outline: 2px solid #40676B; outline-offset: 3px; }" + }, + { + "name": "Standard Card", + "kind": "card", + "refersTo": "card", + "description": "White rounded-2xl card with a subtle border. Lifts on hover. No shadow at rest.", + "html": "

Feature Title

Description text goes here.

", + "css": ".ds-card { background: #fff; border-radius: 1rem; border: 1px solid #e5e7eb; padding: 1.5rem; transition: transform 0.3s cubic-bezier(0.4,0,0.2,1), box-shadow 0.3s cubic-bezier(0.4,0,0.2,1), border-color 0.3s ease; } .ds-card:hover { transform: translateY(-4px); box-shadow: 0 8px 24px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.06); border-color: #89D6DC; } .ds-card-title { font: 600 1.125rem 'IBM Plex Sans', sans-serif; color: #1f2937; margin: 0 0 0.5rem; } .ds-card-body { font: 400 1rem 'Helvetica Neue', sans-serif; color: #374151; line-height: 1.65; margin: 0; } @media (prefers-reduced-motion: reduce) { .ds-card { transition: none; } .ds-card:hover { transform: none; } }" + }, + { + "name": "Text Input", + "kind": "input", + "refersTo": "input", + "description": "Rounded-xl input, gray surface at rest, white on focus with teal ring.", + "html": "", + "css": ".ds-input { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.75rem; padding: 0.75rem 1rem; font: 400 1rem 'Helvetica Neue', sans-serif; color: #374151; width: 100%; transition: background 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease; } .ds-input:focus { background: #fff; border-color: transparent; outline: none; box-shadow: 0 0 0 2px #40676B; } .ds-input:disabled { opacity: 0.5; cursor: not-allowed; }" + }, + { + "name": "Section Badge", + "kind": "chip", + "description": "Teal-50 pill for section categorization. Short labels only.", + "html": "Services", + "css": ".ds-badge { display: inline-block; background: #DAF6F9; color: #40676B; font: 600 0.875rem 'IBM Plex Sans', sans-serif; padding: 0.375rem 1rem; border-radius: 9999px; letter-spacing: 0.01em; }" + }, + { + "name": "Workflow Node Card", + "kind": "custom", + "description": "Animated pipeline node. Pulses with teal or orange glow in staggered sequence to simulate live data flow. The hero's proof-of-craft element.", + "html": "
📊
Data Source
", + "css": ".ds-node { display:flex; flex-direction:column; align-items:center; gap:0.5rem; background:#fff; padding:0.875rem 1.25rem; border-radius:0.75rem; box-shadow:0 2px 8px rgba(0,0,0,0.08),0 1px 3px rgba(0,0,0,0.04); border:1px solid rgba(0,0,0,0.06); position:relative; min-width:90px; transition:all 0.4s cubic-bezier(0.4,0,0.2,1); } .ds-node--teal { border:1.5px solid rgba(97,194,200,0.4); animation:node-pulse-teal 4s ease-in-out infinite; } .ds-node-icon { width:2.25rem; height:2.25rem; border-radius:0.5rem; display:flex; align-items:center; justify-content:center; font-size:1rem; } .ds-node-label { font:600 0.75rem 'IBM Plex Sans',sans-serif; color:#374151; text-align:center; white-space:nowrap; } .ds-node-dot { position:absolute; width:10px; height:10px; border-radius:50%; top:-5px; right:-5px; border:2px solid #fff; } .ds-node-dot--teal { background:#61C2C8; } @keyframes node-pulse-teal { 0%,100% { box-shadow:0 2px 8px rgba(0,0,0,0.08); border-color:rgba(97,194,200,0.4); } 50% { box-shadow:0 0 20px rgba(97,194,200,0.45),0 0 40px rgba(97,194,200,0.2); border-color:#61C2C8; } } @media (prefers-reduced-motion:reduce) { .ds-node--teal { animation:none; } }" + } + ] +} diff --git a/.impeccable/live/config.json b/.impeccable/live/config.json new file mode 100644 index 0000000..3c4b616 --- /dev/null +++ b/.impeccable/live/config.json @@ -0,0 +1,7 @@ +{ + "files": ["wwwroot/index.html"], + "insertBefore": "", + "commentSyntax": "html", + "framework": "blazor-wasm", + "cspChecked": true +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..344c519 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,307 @@ +# AGENTS.md — CloudZen AI Instructions (Merged) + +> Consolidated from AGENTS.md + CLAUDE.md + GEMINI.md. Original files archived. + +## 1. Project Context + +CloudZen is a **Blazor WebAssembly** frontend plus an **Azure Functions Isolated Worker** API backend under `Api/`. + +- Frontend: .NET 8 WASM SPA +- Backend: Azure Functions v4 (.NET 8, isolated) +- Deployment: Azure Static Web Apps + Azure Functions +- Styling: Tailwind (CDN) + component-scoped CSS + +--- + +## 2. Architecture — Vertical Slices by Feature + +``` +Frontend: Features/{Booking|Contact|Chat|Landing|Profile|Projects|Tickets}/ + Common/ Layout/ Pages/ + +Backend: Api/Features/{Booking|Contact|Chat}/ + Api/Shared/{Security|Services|Models}/ +``` + +Each feature owns its `Components/`, `Models/`, and `Services/`. Use **WASM → Azure Function → external provider** proxy pattern for sensitive operations. + +### Feature Classification + +**Full-stack slices (WASM + API):** Booking, Contact, Chat +**Frontend-only slices:** Landing, Profile, Projects, Tickets + +Only full-stack slices add/modify Azure Function endpoints. + +--- + +## 3. Component Rules (MANDATORY) + +**Always generate three files per component:** + +``` +ComponentName.razor ← Markup only. No @code {} blocks. +ComponentName.razor.cs ← sealed partial class. All logic. +ComponentName.razor.css ← Scoped CSS (when needed) +``` + +**Template:** + +```csharp +// .razor — Markup only +@page "/route" +

@L["Title"]

+ +// .razor.cs — Logic +namespace CloudZen.Features.{Feature}.Components; +public sealed partial class ComponentName +{ + [Inject] private HttpClient Http { get; set; } = default!; + protected override async Task OnInitializedAsync() { } +} +``` + +**Rules:** +1. Keep `.razor` markup-only +2. All logic in `.razor.cs` +3. Tailwind utilities first, `.razor.css` for advanced styling +4. `[Parameter]` for parent-to-child, `EventCallback` for child-to-parent +5. Pages are thin orchestration shells +6. Localize all user text: `@L["Key"]` + +--- + +## 4. Service & DI Rules + +- Register services in `Program.cs` +- Use interfaces: `IEmailService`, `IChatbotService`, `IBookingService` +- Backend services use `HttpClient` + options classes +- Strongly typed options: `EmailServiceOptions`, `ChatbotOptions`, `BookingServiceOptions` +- Bind via `AddOptions().BindConfiguration(...)` +- **Never place secrets in WASM client config** + +--- + +## 5. API & Security (CRITICAL) + +All `/api/*` endpoints must preserve: +1. Input validation via shared validators +2. Rate limiting (Polly-based) per endpoint +3. Security headers + proper CORS +4. Correlation IDs in logs +5. Secrets from environment/Key Vault only +6. **No PII, tokens, or secrets in logs or responses** + +### OWASP Top 10 Checklist + +| # | Check | +|---|---| +| A01 | Authorization on every endpoint? Policy-based? | +| A02 | Secrets in code? PII in logs? TLS enforced? | +| A03 | Parameterized queries? No SQL concatenation? | +| A04 | Threat model reviewed? Business logic bypasses? | +| A05 | HTTPS? HSTS? Debug disabled in prod? | +| A06 | NuGet packages up to date? CVEs? | +| A07 | Token validation? Brute-force protection? | +| A08 | Deserialization safe? Pipeline integrity? | +| A09 | Audit trail? Correlation IDs? No secrets logged? | +| A10 | External URL validation? Allowlisting? | + +**Report format:** Severity (Critical/High/Medium/Low), Location, Issue, Fix. + +--- + +## 6. Endpoint Ownership + +**Current API endpoints:** +- `/api/send-email` +- `/api/chat` +- `/api/book-appointment` + +Place new endpoints under `Api/Features/{Feature}/` and document request/response contracts. + +--- + +## 7. Model Ownership + +WASM and API are separate projects. DTO duplication is acceptable. +- No forced project references between WASM and API +- Transformation logic in API proxy functions + +--- + +## 8. Namespace & Naming + +- Root: `CloudZen.*` +- Mirror folder paths (feature-first) +- File names reflect role: `*Service`, `I*Service`, `*Options`, `*Function` +- Explicit, intention-revealing names + +--- + +## 9. Code Style + +**C# conventions:** +- Explicit types for domain objects: `BookingRequest request` (not `var`) +- File-scoped namespaces: `namespace CloudZen.Features.Booking;` +- Nullable enabled: `string?` for nullable +- `sealed` by default on concrete classes +- `record` types for DTOs with `init` properties +- Primary constructors for DI +- `CancellationToken` in all async methods +- Guard clauses — fail fast + +**Immutability:** +- `record` over `class` for DTOs +- `readonly` fields in services +- `init` properties where mutation not needed +- `IReadOnlyCollection` for returns +- Expression-bodied members for single-line logic + +--- + +## 10. Reasoning & Exploration + +**Before making changes:** +1. Trace inbound/outbound references +2. Identify feature slice ownership +3. Check pattern consistency in same directory +4. Verify interface contracts + all implementations +5. Map dependencies + +**Cross-reference checklist:** + +| Question | Where | +|---|---| +| DI wired? | `Program.cs`, `Api/Program.cs` | +| Services? | `Features/{Feature}/Services/` | +| Azure Functions? | `Api/Features/{Feature}/` | +| Components? | `Features/{Feature}/Components/` | +| Models? | `Features/{Feature}/Models/`, `Api/Shared/Models/` | + +**When refactoring:** +1. Identify smell +2. Trace dependencies +3. Evaluate SOLID impact +4. Plan migration (backward compatibility) +5. Verify invariants (no PII logging, secrets safe) + +--- + +## 11. Component Analysis + +**When working with Blazor components:** +1. Check all three files (`.razor`, `.razor.cs`, `.razor.css`) +2. Verify `[Parameter]` and `EventCallback` usage +3. Confirm localization: `@L["Key"]` or `L["Key"]` +4. Scoped CSS only — no global overrides +5. Tailwind consistency + +**Component inventory:** + +| Type | Location | +|---|---| +| Pages | `Features/{Feature}/Components/` (with `@page`) | +| Layouts | `Layout/` | +| Common | `Common/` | + +--- + +## 12. Feature Workflow + +**When adding/modifying features:** +1. Identify vertical slice in `Features/{Feature}/` +2. Check `docs/03-features/` +3. Map dependencies (services, models, API) +4. Follow existing patterns +5. Update docs +6. Verify DI in `Program.cs` +7. Update `Api/Features/{Feature}/` if API changes + +--- + +## 13. Business Rules + +**Non-negotiable invariants:** + +| Rule | Rationale | +|---|---| +| Validate input at API boundaries | Prevents invalid state | +| Never log PII/tokens/secrets | GDPR compliance | +| Idempotency on external calls | Prevents duplicates on retry | +| Authorization on every API endpoint | Default deny | +| Rate limiting on API | Prevents abuse/DoS | +| Secrets from environment only | Never in code/WASM config | + +--- + +## 14. Error Handling + +- Domain-specific exceptions for business violations +- API catches infrastructure exceptions → meaningful HTTP responses +- Never swallow silently — log with context + correlation IDs +- HTTP status codes: 400 (validation), 404 (not found), 409 (conflict), 500 (unexpected) + +--- + +## 15. Documentation (MANDATORY) + +Update `docs/` when behavior changes. + +**Primary docs:** +- `docs/01-architecture/VERTICAL_SLICE_ARCHITECTURE.md` +- `docs/01-architecture/COMPONENT_ARCHITECTURE.md` +- `docs/01-architecture/AZURE_FUNCTIONS.md` +- `docs/01-architecture/API_ENDPOINTS.md` +- `docs/03-features/`, `docs/04-security/`, `docs/05-troubleshooting/`, `docs/06-patterns/` + +**Update matrix:** + +| Change | Doc | +|---|---| +| Architecture | `docs/01-architecture/*` | +| Feature logic | `docs/03-features/{Feature}.md` | +| Security | `docs/04-security/*` | +| API endpoints | `API_ENDPOINTS.md` | +| Component patterns | `COMPONENT_PATTERNS.md` | + +--- + +## 16. Quality Guardrails + +1. Preserve feature isolation — avoid cross-feature coupling +2. Frontend secrets-free — backend handles sensitive ops +3. Incremental changes over broad rewrites +4. Nullable-enabled, compile-safe C# +5. Follow existing patterns before new abstractions + +--- + +## 17. Source-of-Truth Files + +Check before major changes: +- `README.md` +- `Program.cs` (frontend) +- `Api/Program.cs` (backend) +- `docs/01-architecture/*` +- `.github/copilot-instructions.md` + +--- + +## 18. Skills Catalog + +All skills in `.github/skills/`. Browse: `.github/skills/CATALOG.md` + +**Claude integration:** Skills registered in `.claude/skills/` (bridge files) → redirect to `.github/skills/` (source of truth). + +**Quick reference:** + +| Invoke | Path | +|---|---| +| `/code-reviewer` | `.github/skills/code-reviewer/SKILL.md` | +| `/owasp-audit` | `.github/skills/owasp-audit/SKILL.md` | +| `/test-generator` | `.github/skills/test-generator/SKILL.md` | +| `/architecture-reviewer` | `.github/skills/architecture-reviewer/SKILL.md` | + +--- + +**This merged guide consolidates CloudZen project conventions, code generation standards, security baseline, exploration strategies, and skills integration for all AI agents.** diff --git a/AI_CHATBOT_DOCUMENTATION.md b/AI_CHATBOT_DOCUMENTATION.md deleted file mode 100644 index f9dd5ba..0000000 --- a/AI_CHATBOT_DOCUMENTATION.md +++ /dev/null @@ -1,709 +0,0 @@ -# CloudZen AI Chatbot — Technical Documentation - -> **Version:** 1.0 -> **Last Updated:** March 2026 -> **Branch:** `ai-chatbot-tool-integration` -> **Status:** Active Development - ---- - -## Table of Contents - -1. [Overview](#1-overview) -2. [Architecture](#2-architecture) -3. [Project Structure](#3-project-structure) -4. [How It Works — End-to-End Flow](#4-how-it-works--end-to-end-flow) -5. [Frontend — Blazor WebAssembly UI](#5-frontend--blazor-webassembly-ui) -6. [Backend — Azure Functions API](#6-backend--azure-functions-api) -7. [AI Provider — Anthropic Claude](#7-ai-provider--anthropic-claude) -8. [Security & Abuse Prevention](#8-security--abuse-prevention) -9. [Token Consumption Controls](#9-token-consumption-controls) -10. [Lead Generation & Conversion Strategy](#10-lead-generation--conversion-strategy) -11. [Configuration Reference](#11-configuration-reference) -12. [Error Handling](#12-error-handling) -13. [Local Development](#13-local-development) -14. [Deployment](#14-deployment) -15. [Testing Guide](#15-testing-guide) - ---- - -## 1. Overview - -The CloudZen AI Chatbot is a website-embedded conversational assistant designed to: - -- **Answer visitor questions** about CloudZen's services, process, and portfolio -- **Convert visitors into leads** by guiding them toward booking a free consultation -- **Protect against abuse** with multi-layered rate limiting, input validation, and conversation caps -- **Minimize API costs** through strict token consumption controls - -The chatbot is **not** a general-purpose AI assistant. It is scoped exclusively to CloudZen's business context and trained via a server-side knowledge base that is never exposed to the client. - -### Key Design Principles - -| Principle | Implementation | -|---|---| -| **Security first** | API key stays server-side; knowledge base never sent to client | -| **Cost control** | Capped tokens, capped messages, capped reply length, conversation history trimming | -| **Lead conversion** | 5-question limit → CTA to book consultation; system prompt always redirects to outreach | -| **Jargon-free** | System prompt enforces plain English, 1-2 sentence responses | -| **Abuse resistant** | Per-IP rate limiting, input validation, off-topic rejection via prompt | - ---- - -## 2. Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ BROWSER (Client) │ -│ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ CloudZenChatbot.razor (Blazor WASM) │ │ -│ │ │ │ -│ │ • Floating chat widget (FAB button) │ │ -│ │ • Conversation UI with message bubbles │ │ -│ │ • Suggested questions (quick-start chips) │ │ -│ │ • 5-question client-side cap │ │ -│ │ • "Book a Free Consultation" CTA after limit │ │ -│ │ • "X questions remaining" counter │ │ -│ └─────────────────────┬────────────────────────────────┘ │ -│ │ HTTP POST /api/chat │ -└────────────────────────┼────────────────────────────────────┘ - │ - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ AZURE FUNCTIONS API (Server) │ -│ │ -│ ┌──────────────────────────────────────────────────────┐ │ -│ │ ChatFunction.cs │ │ -│ │ │ │ -│ │ 1. CORS headers & preflight handling │ │ -│ │ 2. Security headers │ │ -│ │ 3. Per-IP rate limiting (Polly) │ │ -│ │ 4. Input validation & size checks │ │ -│ │ 5. Conversation history trimming (last 6 msgs) │ │ -│ │ 6. System prompt injection (knowledge base) │ │ -│ │ 7. Anthropic API proxy call │ │ -│ │ 8. Response truncation (≤500 chars) │ │ -│ │ 9. Error classification & handling │ │ -│ └─────────────────────┬────────────────────────────────┘ │ -│ │ │ -│ ┌─────────────────────┴────────────────────────────────┐ │ -│ │ Supporting Services │ │ -│ │ • PollyRateLimiterService (per-client rate limits) │ │ -│ │ • InputValidator (sanitization) │ │ -│ │ • CorsSettings (origin validation) │ │ -│ │ • IHttpClientFactory ("SecureClient") │ │ -│ └──────────────────────────────────────────────────────┘ │ -│ │ │ -└────────────────────────┼────────────────────────────────────┘ - │ HTTP POST (x-api-key header) - ▼ -┌─────────────────────────────────────────────────────────────┐ -│ ANTHROPIC API (External) │ -│ │ -│ Endpoint: https://api.anthropic.com/v1/messages │ -│ Model: claude-sonnet-4-20250514 │ -│ Version: 2023-06-01 │ -│ │ -│ Receives: system prompt + trimmed conversation history │ -│ Returns: JSON with content[].text blocks │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Architecture Highlights - -- **Blazor WebAssembly** runs entirely in the browser — no server-side rendering required -- **Azure Functions** (isolated worker, .NET 8) acts as a secure proxy — the client **never** contacts Anthropic directly -- The **API key** and **knowledge base** exist only on the server -- **Azure Static Web Apps** links the Blazor frontend to the Functions API under the same domain (`/api/chat`) - ---- - -## 3. Project Structure - -``` -CloudZen/ -├── CloudZen.csproj # Blazor WASM frontend -│ ├── Shared/Chatbot/ -│ │ ├── CloudZenChatbot.razor # Chat widget UI component -│ │ └── CloudZenChatbot.razor.css # Scoped styles (dark theme) -│ ├── Services/ -│ │ ├── Abstractions/ -│ │ │ └── IChatbotService.cs # Service interface -│ │ └── ChatbotService.cs # HTTP client → Azure Function -│ ├── Models/ -│ │ ├── ChatMessage.cs # Client-side message model -│ │ └── Options/ -│ │ └── ChatbotOptions.cs # Client config (URL, timeout) -│ └── wwwroot/ -│ ├── appsettings.json # Base config -│ ├── appsettings.Development.json # Local dev (localhost:7257) -│ └── appsettings.Production.json # Production API URL -│ -├── Api/CloudZen.Api.csproj # Azure Functions backend -│ ├── Functions/ -│ │ └── ChatFunction.cs # Main chat endpoint + knowledge base -│ ├── Models/ -│ │ ├── ChatRequest.cs # API request model -│ │ ├── ChatResponse.cs # API response model -│ │ └── Options/ -│ │ └── RateLimitOptions.cs # Rate limiting config -│ ├── Services/ -│ │ ├── IRateLimiterService.cs # Rate limiter interface -│ │ └── RateLimiterService.cs # Polly-based implementation -│ ├── Security/ -│ │ └── InputValidator.cs # Input sanitization -│ └── local.settings.json # Local dev settings -│ -└── AI_CHATBOT_DOCUMENTATION.md # This file -``` - ---- - -## 4. How It Works — End-to-End Flow - -``` -User clicks chat FAB → Chat panel opens - │ - ▼ -User types message (or clicks suggested question) - │ - ▼ -CloudZenChatbot.razor: - ├── Validates: not empty, not loading, under 5-message limit - ├── Adds user message to local conversation list - ├── Shows typing indicator - └── Calls ChatbotService.SendMessageAsync(messages) - │ - ▼ -ChatbotService.cs: - ├── Serializes full conversation history as JSON - └── POST → /api/chat - │ - ▼ -ChatFunction.cs (Azure Function): - ├── Adds CORS + security headers - ├── Checks rate limit (Polly, per-IP) - ├── Validates request body (size, format, message count) - ├── Validates each message (role, content length — user only) - ├── Retrieves API key from config/env/Key Vault - ├── Trims conversation to last 6 messages - ├── Ensures first message is role "user" - ├── Injects system prompt + knowledge base - ├── Calls Anthropic API (claude-sonnet-4-20250514, max 200 tokens) - ├── Parses response, extracts text - ├── Truncates to ≤500 characters at sentence boundary - └── Returns ChatResponse { Success, Reply } - │ - ▼ -ChatbotService.cs: - └── Returns ChatResult.Ok(reply) or ChatResult.Fail(error) - │ - ▼ -CloudZenChatbot.razor: - ├── Adds assistant message to conversation - ├── If 5th message: adds final CTA message - ├── If limit reached: replaces input with "Book a Free Consultation" CTA - └── StateHasChanged() → UI updates -``` - ---- - -## 5. Frontend — Blazor WebAssembly UI - -### Component: `CloudZenChatbot.razor` - -| Feature | Detail | -|---|---| -| **Toggle** | Floating Action Button (FAB) in bottom-right corner | -| **Chat panel** | 380×560px dark-themed container with header, messages, input | -| **Message bubbles** | User (blue, right-aligned) / Bot (dark, left-aligned) with avatars | -| **Typing indicator** | Three animated dots while waiting for API response | -| **Suggested questions** | 4 quick-start chips shown on first open | -| **Conversation cap** | 5 user messages max per session | -| **Questions counter** | Footer shows "X questions remaining" | -| **CTA after limit** | Input replaced with styled "📧 Book a Free Consultation" mailto button | -| **Final CTA message** | Bot sends a closing message encouraging email outreach | -| **Keyboard support** | Enter to send, Shift+Enter for newline | - -### Suggested Questions (Conversion-Optimized) - -``` -"What does CloudZen do?" -"Can you help modernize my old system?" -"How do I get started?" -"Tell me about your past projects" -``` - -### Service: `ChatbotService.cs` - -- Implements `IChatbotService` -- Uses `HttpClient` with configurable timeout (60s default) -- Sends full conversation history to `/api/chat` -- Handles HTTP errors, timeouts, and deserialization failures gracefully -- Returns `ChatResult` (Success/Fail pattern) - -### Configuration: `ChatbotOptions.cs` - -```json -{ - "ChatbotService": { - "ApiBaseUrl": "/api", - "TimeoutSeconds": 60, - "ChatEndpoint": "chat" - } -} -``` - ---- - -## 6. Backend — Azure Functions API - -### Function: `ChatFunction.cs` - -- **Trigger:** HTTP POST `/api/chat` (also OPTIONS for CORS preflight) -- **Auth Level:** Anonymous (rate-limited instead) -- **Runtime:** .NET 8 isolated worker - -### Request Pipeline - -1. **CORS headers** — added to all responses -2. **Preflight handling** — returns 204 for OPTIONS -3. **Security headers** — added to response -4. **Rate limiting** — per-IP, Polly-based fixed window -5. **Body validation** — size, format, deserialization -6. **Message validation** — role, content length (user messages only) -7. **API key retrieval** — from `IConfiguration` or environment variable -8. **Anthropic API call** — with trimmed history + system prompt -9. **Response parsing** — extract text from content blocks -10. **Response truncation** — ≤500 chars at sentence boundary -11. **Error classification** — billing, rate limit, generic HTTP, timeout - -### Rate Limiter: `PollyRateLimiterService` - -- Built on **Polly** resilience pipelines -- **Per-client** rate limiting (keyed by IP + endpoint) -- **Fixed window** algorithm (default: 10 requests per 60 seconds) -- Optional **circuit breaker** for cascading failure protection -- **Automatic cleanup** of inactive client limiters (memory management) -- Configurable via `RateLimitOptions` - ---- - -## 7. AI Provider — Anthropic Claude - -### Model Configuration - -| Setting | Value | Rationale | -|---|---|---| -| **Model** | `claude-sonnet-4-20250514` | Best balance of quality, speed, and cost | -| **Max Tokens** | `200` | ~800 chars max; naturally constrains output length | -| **Anthropic Version** | `2023-06-01` | Stable API version | - -### Knowledge Base - -The knowledge base is a comprehensive `const string` stored server-side in `ChatFunction.cs`. It contains: - -- **Identity & Brand** — name, tagline, positioning, contact info -- **Mission & Values** — core promise, differentiators -- **Services** (9 categories) — custom software, cloud, legacy modernization, DevOps, dashboards, AI automation, specialist network, QA, agile delivery -- **Case Studies** (3 projects) — assessment platform, SAP pipeline, AI menu optimizer -- **Process** — 6-step: consultation → discovery → proposal → build → launch → support -- **Ideal Client Profile** — non-technical small business owners -- **Pain Points** — the specific problems CloudZen solves -- **Technology Expertise** — Azure, Blazor, .NET, AI/ML, data pipelines -- **Contact & Booking** — email, response time, consultation process -- **Tone Guidelines** — warm, jargon-free, outcome-focused - -### System Prompt Rules - -The system prompt enforces these behavioral constraints: - -| Rule | Purpose | -|---|---| -| **≤500 characters per response** | Cost control; keeps responses scannable | -| **1-2 sentences max** | Prevents lengthy explanations | -| **Always suggest next step** | Every answer ends with consultation CTA | -| **No detailed technical advice** | Redirects to real conversation | -| **No pricing/timeline specifics** | Forces consultation booking | -| **No off-topic engagement** | Rejects jokes, roleplay, unrelated questions | -| **Not a general-purpose AI** | Scoped exclusively to CloudZen | -| **Proactive consultation redirect** | After 2-3 questions, suggests booking | - ---- - -## 8. Security & Abuse Prevention - -### Multi-Layer Security Model - -``` -Layer 1 — CLIENT-SIDE -├── 5 user messages max per session -├── Input disabled after limit -├── Suggested questions (controlled vocabulary) -└── Textarea with placeholder guidance - -Layer 2 — API VALIDATION -├── Max request body: 15,000 bytes -├── Max messages per request: 10 -├── Max user message length: 500 characters -├── Role validation: only "user" or "assistant" -├── Content validation: non-empty, non-whitespace -└── JSON depth limit: 10 - -Layer 3 — RATE LIMITING -├── Polly-based per-IP rate limiter -├── Default: 10 requests / 60 seconds -├── Queue limit: 0 (immediate rejection) -├── Optional circuit breaker -├── Automatic inactive client cleanup -└── Retry-After header on 429 responses - -Layer 4 — API KEY SECURITY -├── Anthropic API key in Azure Key Vault / environment variables -├── Never exposed to client browser -├── Knowledge base stays server-side only -└── CORS + security headers on all responses - -Layer 5 — AI PROMPT HARDENING -├── Off-topic rejection instruction -├── No roleplay / joke engagement -├── Scoped to CloudZen topics only -├── No detailed technical implementation advice -└── Pricing/timeline → "book a consultation" - -Layer 6 — RESPONSE CONTROLS -├── Max 200 tokens per response -├── Server-side truncation at 500 characters -├── Sentence-boundary-aware truncation -└── Empty response fallback message -``` - -### Error Handling by Type - -| Error | HTTP Status | User Message | -|---|---|---| -| Rate limited (app) | 429 | Rate limiter message | -| Rate limited (Anthropic) | 429 | "The AI service is currently busy." | -| Billing/credits issue | 503 | "AI service temporarily unavailable." | -| Generic HTTP error | 500 | "Unable to reach the AI service." | -| Timeout | 500 | "The AI service took too long to respond." | -| Invalid JSON | 400 | "Invalid request format." | -| Unexpected error | 500 | "Something went wrong." | - ---- - -## 9. Token Consumption Controls - -Total cost per conversation is controlled at every level: - -| Control | Setting | Impact | -|---|---|---| -| **Max tokens per response** | 200 | ~50-100 words per reply | -| **Max reply characters** | 500 | Server-side hard truncation | -| **System prompt instruction** | "≤500 chars, 1-2 sentences" | Guides model to be concise | -| **Conversation history trim** | Last 6 messages only | Older messages dropped before API call | -| **Client conversation cap** | 5 user messages | Max 5 API calls per session | -| **Rate limit** | 10 requests / 60 seconds | Per-IP burst protection | -| **User message length** | 500 characters max | Limits input token count | -| **Max messages per request** | 10 | Prevents oversized payloads | - -### Estimated Token Budget Per Conversation - -| Component | Estimated Tokens | -|---|---| -| System prompt (knowledge base) | ~2,500 (fixed, sent once per call) | -| Conversation history (6 msgs × ~100 tokens) | ~600 | -| Response generation | ≤200 | -| **Total per API call** | ~3,300 | -| **Total per session (5 calls)** | ~16,500 | - ---- - -## 10. Lead Generation & Conversion Strategy - -The chatbot is designed as a **lead qualification funnel**, not a support tool: - -### Conversion Tactics - -1. **Suggested questions** — pre-populated, conversion-optimized topics ("How do I get started?") -2. **Short answers** — 1-2 sentences create curiosity, not satisfaction -3. **Every answer includes CTA** — system prompt mandates suggesting consultation or email -4. **Pricing deflection** — "That depends on your situation" → book consultation -5. **5-question hard limit** — forces transition from chatbot to real conversation -6. **Final CTA message** — bot's last message explicitly asks them to email -7. **Visual CTA button** — styled "📧 Book a Free Consultation" mailto link replaces input -8. **Footer reinforcement** — email address + "Replies within 24h" always visible -9. **Proactive redirect** — after 2-3 questions, prompt suggests consultation unprompted - -### Conversion Funnel - -``` -Visitor lands on site - │ - ▼ -Sees floating chat FAB → Curiosity click - │ - ▼ -Reads welcome message + suggested questions → Low-friction engagement - │ - ▼ -Asks 1-2 questions → Gets helpful but brief answers with CTAs - │ - ▼ -Asks 3rd question → Bot proactively suggests consultation - │ - ▼ -Asks 4th-5th question → Counter shows "1 question remaining" - │ - ▼ -Limit reached → "Book a Free Consultation" CTA replaces input - │ - ▼ -Clicks CTA → mailto:cloudzen.inc@gmail.com (pre-filled subject) -``` - ---- - -## 11. Configuration Reference - -### Azure Functions Backend (`local.settings.json`) - -```json -{ - "Values": { - "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", - "ANTHROPIC_API_KEY": "", - "RateLimiting:PermitLimit": "10", - "RateLimiting:WindowSeconds": "60", - "RateLimiting:QueueLimit": "0", - "RateLimiting:EnableCircuitBreaker": "false", - "RateLimiting:CircuitBreakerFailureThreshold": "5", - "RateLimiting:CircuitBreakerDurationSeconds": "30", - "RateLimiting:InactivityTimeoutMinutes": "5" - } -} -``` - -### Blazor Frontend (`wwwroot/appsettings.json`) - -```json -{ - "ChatbotService": { - "ApiBaseUrl": "/api", - "TimeoutSeconds": 60, - "ChatEndpoint": "chat" - } -} -``` - -### Constants in `ChatFunction.cs` - -| Constant | Value | Description | -|---|---|---| -| `AnthropicApiUrl` | `https://api.anthropic.com/v1/messages` | Anthropic Messages API endpoint | -| `AnthropicVersion` | `2023-06-01` | API version header | -| `DefaultModel` | `claude-sonnet-4-20250514` | Claude model identifier | -| `MaxTokens` | `200` | Max tokens per AI response | -| `MaxRequestBodySize` | `15,000` | Max request body in bytes | -| `MaxMessages` | `10` | Max messages per request | -| `MaxConversationHistoryMessages` | `6` | Messages sent to Anthropic (trim) | -| `MaxMessageContentLength` | `500` | Max user message characters | -| `MaxReplyLength` | `500` | Max reply characters (truncation) | - -### Constants in `CloudZenChatbot.razor` - -| Constant | Value | Description | -|---|---|---| -| `MaxUserMessages` | `5` | Client-side user message cap | - ---- - -## 12. Error Handling - -### Anthropic API Error Classification - -The backend parses Anthropic error responses and classifies them: - -```csharp -// Billing errors (insufficient credits) -if (statusCode == 400 && body.Contains("credit balance is too low")) - → throws "billing error" → caught → 503 Service Unavailable - -// Rate limit errors -if (statusCode == 429 || errorType == "rate_limit_error") - → throws "rate limit" → caught → 429 Too Many Requests - -// All other errors - → throws generic → caught → 500 Internal Server Error -``` - -### Client-Side Error Handling (`ChatbotService.cs`) - -- **HTTP errors** → parsed from response body or generic status message -- **Network errors** → "Unable to connect to the chat service." -- **Timeouts** → "Request timed out. Please try again." -- **Unexpected errors** → "Something went wrong. Please try again later." - -### UI Error Display (`CloudZenChatbot.razor`) - -Errors are shown as assistant messages in the chat: - -```csharp -messages.Add(ChatMessage.Assistant( - result.Error ?? "Something went wrong. Please email cloudzen.inc@gmail.com directly.")); -``` - ---- - -## 13. Local Development - -### Prerequisites - -- .NET 8 SDK -- Azure Functions Core Tools v4 -- Azure Storage Emulator or Azurite -- Anthropic API key with credits - -### Running Locally - -**Terminal 1 — Azure Functions API:** - -```powershell -cd Api -func start --port 7257 -``` - -**Terminal 2 — Blazor WASM Frontend:** - -```powershell -dotnet run -``` - -Open `https://localhost:7243` and click the chat FAB. - -### Development Configuration - -`wwwroot/appsettings.Development.json` points to local Functions: - -```json -{ - "ChatbotService": { - "ApiBaseUrl": "http://localhost:7257/api" - } -} -``` - ---- - -## 14. Deployment - -### Production Architecture - -``` -Azure Static Web Apps -├── Frontend: Blazor WASM (static files) -└── Linked API: Azure Functions (.NET 8 isolated) -``` - -### Production Config - -`wwwroot/appsettings.Production.json`: - -```json -{ - "ChatbotService": { - "ApiBaseUrl": "https://cloudzen-api-func-e4gehdaef9ftdhbn.westus2-01.azurewebsites.net/api" - } -} -``` - -### Required Environment Variables (Azure Function App) - -| Variable | Source | Description | -|---|---|---| -| `ANTHROPIC_API_KEY` | Azure Key Vault | Anthropic API key | -| `RateLimiting:PermitLimit` | App Settings | Requests per window | -| `RateLimiting:WindowSeconds` | App Settings | Rate limit window | - ---- - -## 15. Testing Guide - -### Client-Side: Conversation Cap - -1. Open chatbot widget -2. Send 5 messages — verify footer shows decreasing "X questions remaining" -3. After 5th message: input replaced with CTA button, final bot CTA message appears - -### API: Message Validation - -```javascript -// Too-long user message (expect 400) -fetch("/api/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - messages: [{ role: "user", content: "A".repeat(501) }] - }) -}).then(r => r.json()).then(console.log); -``` - -### API: Too Many Messages - -```javascript -// 11 messages (expect 400) -const msgs = Array.from({length: 11}, (_, i) => ({ - role: i % 2 === 0 ? "user" : "assistant", content: "test" -})); -fetch("/api/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: msgs }) -}).then(r => r.json()).then(console.log); -``` - -### API: Rate Limiting - -```javascript -// Burst 11 requests (expect 11th → 429) -for (let i = 0; i < 11; i++) { - fetch("/api/chat", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }) - }).then(r => console.log(`Request ${i+1}: ${r.status}`)); -} -``` - -### AI Behavior (requires Anthropic credits) - -| Test | Expected Behavior | -|---|---| -| Off-topic: "Write me a poem" | Politely redirects to CloudZen topics | -| Pricing: "How much does it cost?" | "Depends on your situation" → book consultation | -| 3+ questions in a row | Proactively suggests consultation | -| Long question about implementation | High-level answer only → redirects to consultation | -| Reply length | Every response ≤500 characters | - ---- - -## Summary of Protection Rules - -| Rule | Where | Value | -|---|---|---| -| User messages per session | Client (Blazor) | 5 max | -| User message length | API validation | 500 chars | -| Messages per API request | API validation | 10 max | -| Request body size | API validation | 15 KB | -| Conversation history to Anthropic | API (trim) | Last 6 messages | -| AI response tokens | Anthropic `max_tokens` | 200 | -| AI response length | API truncation | 500 chars | -| Rate limit | API (Polly) | 10 req / 60s per IP | -| Off-topic rejection | System prompt | Instruction-based | -| Pricing/timeline deflection | System prompt | Instruction-based | -| Response brevity | System prompt + token limit | 1-2 sentences | - ---- - -*Built with ❤️ by CloudZen — Technology That Works.* diff --git a/AZURE_FUNCTIONS_HOSTING_MODELS.md b/AZURE_FUNCTIONS_HOSTING_MODELS.md deleted file mode 100644 index 6e9ef84..0000000 --- a/AZURE_FUNCTIONS_HOSTING_MODELS.md +++ /dev/null @@ -1,683 +0,0 @@ -# Azure Functions: Isolated Worker vs In-Process Model - -## CloudZen Solution Architecture Guide - -This document explains the differences between Azure Functions hosting models and why the **Isolated Worker Model** is the recommended choice for CloudZen. - ---- - -## Table of Contents - -- [Overview](#overview) -- [CloudZen Architecture](#cloudzen-architecture) -- [Detailed Comparison](#detailed-comparison) - - [Process Architecture](#1-process-architecture) - - [Package References](#2-package-references) - - [Code Differences](#3-code-differences) - - [Feature Comparison](#4-feature-comparison) -- [Why Isolated Worker for CloudZen](#5-why-isolated-worker-for-cloudzen) -- [Migration Guide](#migration-guide-if-starting-from-in-process) -- [Troubleshooting](#troubleshooting) -- [Summary](#summary) -- [References](#references) - ---- - -## Overview - -Azure Functions supports two hosting models for .NET applications: - -| Model | Status | .NET Support | -|-------|--------|--------------| -| **Isolated Worker** | ? Recommended | .NET 6, 7, 8, 9+ | -| **In-Process** | ?? Deprecated | .NET 6 only (ends Nov 2026) | - ---- - -## CloudZen Architecture - -``` -???????????????????????????????????????????????????????????????????????????????????? -? CloudZen Solution ? -???????????????????????????????????????????????????????????????????????????????????? -? ? -? ???????????????????????? ?????????????????????????????????????????????????? ? -? ? CloudZen.csproj ? ? CloudZen.Api.csproj ? ? -? ? (Blazor WebAssembly)? ? (Azure Functions v4) ? ? -? ? ? ? ? ? -? ? .NET 8 ? HTTP ? .NET 8 ? ? -? ? Browser runtime ???????? Isolated Worker Model ? ? -? ? ContactForm.razor ? ? SendEmailFunction (email proxy) ? ? -? ? CloudZenChatbot ? ? ChatFunction (AI chatbot proxy) ? ? -? ? ApiEmailService ? ? PollyRateLimiterService ? ? -? ? ChatbotService ? ? InputValidator, CorsSettings ? ? -? ???????????????????????? ?????????????????????????????????????????????????? ? -? ? ? ? -? ? ? ? -? ??????????????????? ?????????????????????? ? -? ? Brevo SMTP ? ? Anthropic API ? ? -? ? (Email) ? ? (Claude AI Chat) ? ? -? ??????????????????? ?????????????????????? ? -???????????????????????????????????????????????????????????????????????????????????? -``` - ---- - -## Detailed Comparison - -### 1. Process Architecture - -#### Isolated Worker Model (CloudZen.Api uses this) ? - -``` -??????????????????????????????????????????????????????????????? -? Azure Functions Host ? -? ??????????????????? ???????????????????????????????? -? ? Host Process ? gRPC ? Worker Process ?? -? ? (Runtime) ??????????? (Your .NET 8 Code) ?? -? ? ? ? ?? -? ? Triggers ? ? SendEmailFunction ?? -? ? Bindings ? ? Custom Middleware ?? -? ? Scaling ? ? Full Dependency Control ?? -? ??????????????????? ???????????????????????????????? -??????????????????????????????????????????????????????????????? -``` - -**Key Benefits:** -- Your code runs in a **separate process** from the Azure Functions runtime -- **Full control** over dependencies and their versions -- **No version conflicts** with the host runtime -- Communication via efficient **gRPC** channel -- Supports multiple function endpoints (`SendEmailFunction`, `ChatFunction`) - -#### In-Process Model (Legacy - Deprecated) - -``` -??????????????????????????????????????????????????????????????? -? Azure Functions Host (Single Process) ? -? ? -? Runtime + Your Code share same process ? -? Dependency version conflicts possible ? -? Limited to host's .NET version (.NET 6 only) ? -? Tightly coupled to host lifecycle ? -??????????????????????????????????????????????????????????????? -``` - -**Limitations:** -- Stuck on **.NET 6** (no .NET 7, 8, or 9 support) -- Dependency conflicts with host packages -- Limited customization options -- **End of support: November 2026** - ---- - -### 2. Package References - -#### CloudZen.Api Current Setup (Isolated Worker) ? - -```xml - - - - net8.0 - V4 - Exe - enable - enable - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -#### In-Process Model Packages (NOT Recommended) - -```xml - - - - net6.0 - V4 - - - - - - - - -``` - ---- - -### 3. Code Differences - -#### Isolated Worker Model (CloudZen.Api Implementation) ? - -**Program.cs - Application Entry Point:** - -```csharp -// Api\Program.cs -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Builder; -using CloudZen.Api.Services; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -var builder = FunctionsApplication.CreateBuilder(args); - -// Full ASP.NET Core service configuration -builder.Services.AddOptions() - .BindConfiguration(RateLimitOptions.SectionName); -builder.Services.AddSingleton(); - -// HTTP client factory for secure outbound calls (used by ChatFunction) -builder.Services.AddHttpClient("SecureClient", client => -{ - client.DefaultRequestHeaders.Add("User-Agent", "CloudZen-Api/1.0"); - client.Timeout = TimeSpan.FromSeconds(30); -}); - -// Configure ASP.NET Core integration for HTTP triggers -builder.ConfigureFunctionsWebApplication(); - -// Application Insights telemetry -builder.Services - .AddApplicationInsightsTelemetryWorkerService() - .ConfigureFunctionsApplicationInsights(); - -var host = builder.Build(); -host.Run(); -``` - -**Function Implementation:** - -```csharp -// Api\Functions\SendEmailFunction.cs -using Microsoft.Azure.Functions.Worker; // Isolated namespace -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.Logging; - -namespace CloudZen.Api.Functions; - -public class SendEmailFunction -{ - private readonly ILogger _logger; - private readonly IConfiguration _config; - private readonly IRateLimiterService _rateLimiter; - - // Constructor Dependency Injection - Full Support - public SendEmailFunction( - ILogger logger, - IConfiguration config, - IRateLimiterService rateLimiter) - { - _logger = logger; - _config = config; - _rateLimiter = rateLimiter; - } - - [Function("SendEmail")] // Isolated worker attribute - public async Task Run( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "send-email")] - HttpRequest req) // Full ASP.NET Core HttpRequest - { - // Full access to HttpContext - req.HttpContext.Response.AddSecurityHeaders(); - var clientIp = req.GetClientIpAddress(); - - // Rate limiting with injected service - var rateLimitResult = await _rateLimiter.TryAcquireAsync(clientIp, "send-email"); - if (!rateLimitResult.IsAllowed) - { - return new ObjectResult(new { error = rateLimitResult.Message }) - { - StatusCode = StatusCodes.Status429TooManyRequests - }; - } - - // Process email request... - return new OkObjectResult(new { success = true }); - } -} -``` - -#### In-Process Model (Legacy Pattern - NOT Recommended) - -```csharp -// What in-process code looks like - DO NOT USE -using Microsoft.Azure.WebJobs; // Different namespace! -using Microsoft.Azure.WebJobs.Extensions.Http; // Different extensions! -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; - -namespace CloudZen.Api.Functions; - -public static class SendEmailFunction // Often static classes -{ - [FunctionName("SendEmail")] // Different attribute name! - public static async Task Run( - [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = "send-email")] - HttpRequest req, - ILogger log) // Logger via parameter injection only - { - // Limited DI options - // No constructor injection - // Must use static service locator patterns - - log.LogInformation("Processing request..."); - - return new OkObjectResult(new { success = true }); - } -} -``` - -**Key Code Differences Summary:** - -| Aspect | Isolated Worker ? | In-Process ?? | -|--------|-------------------|---------------| -| **Namespace** | `Microsoft.Azure.Functions.Worker` | `Microsoft.Azure.WebJobs` | -| **Function Attribute** | `[Function("Name")]` | `[FunctionName("Name")]` | -| **Class Style** | Instance classes | Often static classes | -| **DI Pattern** | Constructor injection | Parameter injection | -| **Entry Point** | `Program.cs` with `FunctionsApplication` | `Startup.cs` (limited) | - ---- - -### 4. Feature Comparison - -| Feature | Isolated Worker ? | In-Process ?? | -|---------|-------------------|---------------| -| **.NET Version Support** | .NET 6, 7, 8, 9+ | .NET 6 only | -| **Process Isolation** | ? Separate process | ? Shared with host | -| **Dependency Control** | ? Full control | ? May conflict with host | -| **Custom Middleware** | ? Supported | ? Not supported | -| **ASP.NET Core Integration** | ? Full integration | ?? Limited | -| **Constructor DI** | ? Full support | ?? Limited | -| **Startup Configuration** | ? `Program.cs` | ?? `Startup.cs` (limited) | -| **NuGet Package Freedom** | ? Any version | ? Host version constraints | -| **Cold Start Performance** | ?? Slightly slower | ? Faster | -| **Memory Footprint** | ?? Higher (two processes) | ? Lower | -| **Debugging Experience** | ? Standard .NET debugging | ? Standard .NET debugging | -| **Future Investment** | ? Active development | ? Maintenance mode | -| **End of Support** | Ongoing | November 2026 | - ---- - -### 5. Why Isolated Worker for CloudZen - -#### ? Requirement 1: .NET 8 Support - -CloudZen targets .NET 8 across all projects. The in-process model **only supports .NET 6**. - -```xml - -net8.0 - - -net8.0 -``` - -#### ? Requirement 2: Security Through Process Isolation - -`SendEmailFunction` and `ChatFunction` handle sensitive API keys (Brevo SMTP, Anthropic). Process isolation provides: -- Better security boundaries -- Isolated memory space -- Reduced attack surface - -```csharp -// Sensitive configuration accessed in isolated process -var smtpKey = _config["BREVO_SMTP_KEY"]; // Email delivery -var aiKey = _config["ANTHROPIC_API_KEY"]; // AI chatbot -``` - -#### ? Requirement 3: Full ASP.NET Core Integration - -CloudZen.Api leverages ASP.NET Core features extensively: - -```csharp -// Security headers via extension methods -req.HttpContext.Response.AddSecurityHeaders(); - -// Client IP extraction for rate limiting -var clientIp = req.GetClientIpAddress(); - -// Full IActionResult support -return new OkObjectResult(new { success = true }); -return new BadRequestObjectResult(new { error = "Invalid" }); -return new ObjectResult(new { error = "Rate limited" }) -{ - StatusCode = StatusCodes.Status429TooManyRequests -}; -``` - -#### ? Requirement 4: Constructor Dependency Injection - -Clean, testable code with proper DI patterns: - -```csharp -public class SendEmailFunction -{ - private readonly ILogger _logger; - private readonly IConfiguration _config; - private readonly IRateLimiterService _rateLimiter; - - public SendEmailFunction( - ILogger logger, - IConfiguration config, - IRateLimiterService rateLimiter) - { - _logger = logger; - _config = config; - _rateLimiter = rateLimiter; - } -} -``` - -#### ? Requirement 5: Custom Services and Middleware - -Rate limiting service registered at startup: - -```csharp -// Api\Program.cs -builder.Services.AddMemoryCache(); -builder.Services.AddSingleton(); -builder.ConfigureFunctionsWebApplication(); -``` - -#### ? Requirement 6: Future-Proof Architecture - -- In-process model ends support **November 2026** -- Isolated worker is Microsoft's **strategic investment** -- New features only added to isolated worker model - ---- - -## Migration Guide (If Starting from In-Process) - -If you encounter legacy in-process Azure Functions code and need to migrate: - -### Step 1: Update Project File - -```xml - - - - net6.0 - V4 - - - - - - - - - - net8.0 - V4 - Exe - enable - enable - - - - - - - - -``` - -### Step 2: Update Namespaces - -```csharp -// BEFORE: In-Process -using Microsoft.Azure.WebJobs; -using Microsoft.Azure.WebJobs.Extensions.Http; - -// AFTER: Isolated Worker -using Microsoft.Azure.Functions.Worker; -``` - -### Step 3: Update Function Attributes - -```csharp -// BEFORE: In-Process -[FunctionName("SendEmail")] -public static async Task Run(...) - -// AFTER: Isolated Worker -[Function("SendEmail")] -public async Task Run(...) -``` - -### Step 4: Create Program.cs - -```csharp -// New file: Api\Program.cs -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -var builder = FunctionsApplication.CreateBuilder(args); - -builder.ConfigureFunctionsWebApplication(); - -// Register your services -builder.Services.AddSingleton(); - -var host = builder.Build(); -host.Run(); -``` - -### Step 5: Convert Static Classes to Instance Classes - -```csharp -// BEFORE: In-Process (static) -public static class MyFunction -{ - [FunctionName("MyFunction")] - public static async Task Run( - [HttpTrigger(...)] HttpRequest req, - ILogger log) - { - // Use log parameter - } -} - -// AFTER: Isolated Worker (instance) -public class MyFunction -{ - private readonly ILogger _logger; - - public MyFunction(ILogger logger) - { - _logger = logger; - } - - [Function("MyFunction")] - public async Task Run( - [HttpTrigger(...)] HttpRequest req) - { - // Use _logger field - } -} -``` - -### Step 6: Update host.json - -```json -{ - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingSettings": { - "isEnabled": true, - "excludedTypes": "Request" - }, - "enableLiveMetricsFilters": true - } - } -} -``` - ---- - -## Troubleshooting - -### Error: "Microsoft.Azure.Functions.Worker not found" - -**Symptoms:** -``` -CS0234: The type or namespace name 'Azure' does not exist in the namespace 'Microsoft' -``` - -**Cause:** Corrupted build artifacts or packages not restored. - -**Solution:** -```powershell -# Clean build artifacts -Remove-Item -Recurse -Force Api\obj, Api\bin -ErrorAction SilentlyContinue - -# Restore packages -dotnet restore Api\CloudZen.Api.csproj - -# Rebuild -dotnet build Api\CloudZen.Api.csproj -``` - -### Error: Blazor Project Including Api Files - -**Symptoms:** -``` -CS0234: The type or namespace name 'Azure' does not exist in the namespace 'Microsoft' -``` -(Error appears when building CloudZen.csproj, not CloudZen.Api.csproj) - -**Cause:** Default glob patterns in Blazor project include all subfolders. - -**Solution:** Add exclusion to `CloudZen.csproj`: -```xml - - $(DefaultItemExcludes);Api\** - -``` - -### Error: Duplicate Assembly Attributes - -**Symptoms:** -``` -CS0579: Duplicate 'System.Reflection.AssemblyCompanyAttribute' attribute -``` - -**Cause:** Corrupted obj folders with stale generated files. - -**Solution:** -```powershell -# Remove all build artifacts -Remove-Item -Recurse -Force obj, bin -ErrorAction SilentlyContinue -Remove-Item -Recurse -Force Api\obj, Api\bin -ErrorAction SilentlyContinue - -# Restore and rebuild -dotnet restore CloudZen.sln -dotnet build CloudZen.sln -``` - -### Error: Function Not Found at Runtime - -**Symptoms:** Function deploys but returns 404. - -**Cause:** Missing `host.json` or incorrect route configuration. - -**Solution:** Verify `Api\host.json` exists: -```json -{ - "version": "2.0", - "extensions": { - "http": { - "routePrefix": "api" - } - } -} -``` - -### Error: Cold Start Taking Too Long - -**Symptoms:** First request takes 10+ seconds. - -**Cause:** Isolated worker has inherently longer cold starts due to process initialization. - -**Solutions:** -1. Use **Premium** or **Dedicated** App Service Plan (always warm) -2. Enable **Always On** setting -3. Implement **health check endpoint** for warming -4. Use **Azure Functions Premium Plan** with pre-warmed instances - ---- - -## Summary - -| Decision Point | CloudZen Choice | Rationale | -|----------------|-----------------|-----------| -| **Hosting Model** | ? Isolated Worker | .NET 8 requirement, security, ASP.NET Core integration | -| **Primary Package** | `Microsoft.Azure.Functions.Worker` | Core isolated worker runtime | -| **HTTP Package** | `Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore` | Full ASP.NET Core HTTP support | -| **Namespace** | `Microsoft.Azure.Functions.Worker` | Isolated worker APIs | -| **Entry Point** | `Program.cs` with `FunctionsApplication.CreateBuilder()` | Standard .NET 8 pattern | -| **DI Pattern** | Constructor injection | Clean, testable code | -| **Future-Proof** | ? Yes | Active Microsoft investment | - ---- - -## References - -### Official Documentation -- [Azure Functions .NET Isolated Process Guide](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide) -- [Migrate .NET Apps to Isolated Worker Model](https://learn.microsoft.com/en-us/azure/azure-functions/migrate-dotnet-to-isolated-model) -- [In-Process Model Deprecation Timeline](https://learn.microsoft.com/en-us/azure/azure-functions/functions-versions?tabs=v4&pivots=programming-language-csharp#in-process-model-deprecation) -- [HTTP Triggers with ASP.NET Core Integration](https://learn.microsoft.com/en-us/azure/azure-functions/dotnet-isolated-process-guide#http-trigger) - -### CloudZen Documentation -- [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) - Complete Azure deployment instructions -- [BLUE_GREEN_DEPLOYMENT.md](BLUE_GREEN_DEPLOYMENT.md) - Staging/production blue/green deployment setup -- [AZURE_FUNCTION_DEPLOYMENT.md](AZURE_FUNCTION_DEPLOYMENT.md) - Function App deployment details -- [SECURITY_ALERT.md](SECURITY_ALERT.md) - Security best practices for Blazor + Azure Functions -- [COMPONENT_ARCHITECTURE.md](COMPONENT_ARCHITECTURE.md) - Frontend component design - -### NuGet Packages -- [Microsoft.Azure.Functions.Worker](https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker) -- [Microsoft.Azure.Functions.Worker.Sdk](https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Sdk) -- [Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore](https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Extensions.Http.AspNetCore) - ---- - -*Last Updated: March 2026* -*CloudZen Solution Version: .NET 8* -*Azure Functions Version: V4 (Isolated Worker)* -*Functions: SendEmailFunction, ChatFunction* diff --git a/Api/Features/Booking/BookAppointmentFunction.cs b/Api/Features/Booking/BookAppointmentFunction.cs new file mode 100644 index 0000000..2a8a3c1 --- /dev/null +++ b/Api/Features/Booking/BookAppointmentFunction.cs @@ -0,0 +1,355 @@ +using CloudZen.Api.Shared.Security; +using CloudZen.Api.Shared.Services; +using CloudZen.Api.Shared.Models; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Azure.Functions.Worker; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; +using System.Text; +using System.Text.Json; + +namespace CloudZen.Api.Features.Booking; + +/// +/// Azure Function that proxies appointment booking requests to the n8n webhook. +/// +/// +/// +/// This function serves as a secure backend proxy for the Blazor WebAssembly booking flow, +/// forwarding requests to the n8n appointment workflow at a configured webhook URL. +/// The n8n webhook cannot be called directly from the browser due to CORS restrictions. +/// +/// +/// Security features: +/// +/// Rate limiting to prevent abuse +/// Input validation and sanitization +/// CORS and security headers +/// Request body size limiting +/// Correlation ID tracking +/// +/// +/// +public class BookAppointmentFunction( + ILogger logger, + IConfiguration config, + IRateLimiterService rateLimiter, + CorsSettings corsSettings, + IHttpClientFactory httpClientFactory) +{ + private readonly ILogger _logger = logger; + private readonly IConfiguration _config = config; + private readonly IRateLimiterService _rateLimiter = rateLimiter; + private readonly CorsSettings _corsSettings = corsSettings; + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; + + private const int MaxRequestBodySize = 5000; + + private static readonly JsonSerializerOptions RequestJsonOptions = new() + { + PropertyNameCaseInsensitive = true, + MaxDepth = 10 + }; + + /// + /// HTTP POST endpoint to book an appointment via the n8n webhook. + /// Also handles OPTIONS preflight requests for CORS. + /// + /// The HTTP request containing a JSON body. + /// + /// An containing: + /// + /// 200 OK — Booking confirmed with bookingId + /// 200 OK — Slot taken (success=false in body) + /// 204 No Content — CORS preflight + /// 400 Bad Request — Validation failure + /// 429 Too Many Requests — Rate limit exceeded + /// 502 Bad Gateway — n8n webhook unreachable + /// + /// + [Function("BookAppointment")] + public async Task Run( + [HttpTrigger(AuthorizationLevel.Anonymous, "post", "options", Route = "book-appointment")] HttpRequest req) + { + // ── CORS ───────────────────────────────────────────────────────── + req.HttpContext.Response.AddCorsHeaders(req, _corsSettings); + + if (req.IsCorsPreflightRequest()) + { + return new StatusCodeResult(StatusCodes.Status204NoContent); + } + + req.HttpContext.Response.AddSecurityHeaders(); + + // ── Logging / Rate limiting ────────────────────────────────────── + var clientIp = req.GetClientIpAddress(); + var correlationId = req.Headers["X-Correlation-Id"].FirstOrDefault() ?? Guid.NewGuid().ToString(); + + using var scope = _logger.BeginScope(new Dictionary + { + ["CorrelationId"] = correlationId, + ["ClientIp"] = InputValidator.SanitizeForLogging(clientIp) + }); + + _logger.LogInformation("BookAppointment triggered from {ClientIp}", InputValidator.SanitizeForLogging(clientIp)); + + try + { + // Rate limit + var rateLimitResult = await _rateLimiter.TryAcquireAsync(clientIp, "book-appointment"); + if (!rateLimitResult.IsAllowed) + { + _logger.LogWarning("Rate limit exceeded for {ClientIp}", InputValidator.SanitizeForLogging(clientIp)); + req.HttpContext.Response.Headers.TryAdd("Retry-After", + rateLimitResult.RetryAfter?.TotalSeconds.ToString("F0") ?? "60"); + + return new ObjectResult(new { success = false, message = rateLimitResult.Message }) + { + StatusCode = StatusCodes.Status429TooManyRequests + }; + } + + // ── Parse & validate ───────────────────────────────────────── + var requestBody = await new StreamReader(req.Body).ReadToEndAsync(); + + _logger.LogInformation("Received request body: {Body}", requestBody); + + if (string.IsNullOrWhiteSpace(requestBody)) + { + return new BadRequestObjectResult(new { success = false, message = "Please fill out all required fields and try again." }); + } + + if (requestBody.Length > MaxRequestBodySize) + { + return new BadRequestObjectResult(new { success = false, message = "Your request contains too much data. Please shorten your entries and try again." }); + } + + var bookingRequest = JsonSerializer.Deserialize(requestBody, RequestJsonOptions); + if (bookingRequest is null) + { + return new BadRequestObjectResult(new { success = false, message = "We couldn't read your booking details. Please try again." }); + } + + _logger.LogInformation( + "Parsed request - Action: {Action}, Name: {Name}, Email: {Email}, Date: {Date}, Time: {Time}", + bookingRequest.Action, + bookingRequest.Name, + bookingRequest.Email, + bookingRequest.Date, + bookingRequest.Time); + + var validationError = ValidateRequest(bookingRequest); + if (validationError is not null) + { + _logger.LogWarning("Validation failed: {Error}", validationError); + return new BadRequestObjectResult(new { success = false, message = validationError }); + } + + // ── Forward to n8n webhook ─────────────────────────────────── + // N8N's "Prepare Base Data" node handles field transformation internally, + // so we send the original request payload directly. + var webhookUrl = _config["N8N_WEBHOOK_URL"] + ?? Environment.GetEnvironmentVariable("N8N_WEBHOOK_URL"); + + if (string.IsNullOrEmpty(webhookUrl)) + { + _logger.LogError("N8N_WEBHOOK_URL is not configured."); + return new ObjectResult(new { success = false, message = "Our booking system is temporarily unavailable. Please try again later." }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + // Use a named HttpClient with appropriate timeout and retry policies configured in Startup.cs for secure external calls + var httpClient = _httpClientFactory.CreateClient("SecureClient"); + + // Send original request body - N8N JavaScript handles the transformation + var jsonContent = new StringContent( + requestBody, + Encoding.UTF8, + "application/json"); + + _logger.LogInformation("Forwarding {Action} request to n8n for {Email}", + bookingRequest.Action, + InputValidator.SanitizeForLogging(bookingRequest.Email)); + + var n8nResponse = await httpClient.PostAsync(webhookUrl, jsonContent); + var n8nBody = await n8nResponse.Content.ReadAsStringAsync(); + + _logger.LogDebug("n8n response {StatusCode}: {Body}", n8nResponse.StatusCode, n8nBody); + + if (!n8nResponse.IsSuccessStatusCode) + { + _logger.LogError("n8n returned {StatusCode}: {Body}", n8nResponse.StatusCode, n8nBody); + return new ObjectResult(new { success = false, message = "We couldn't complete your booking right now. Please try again." }) + { + StatusCode = StatusCodes.Status502BadGateway + }; + } + + // Pass the n8n JSON response through to the frontend as-is + // (it already contains { success, bookingId, message } or { success: false, message }) + return new ContentResult + { + Content = n8nBody, + ContentType = "application/json", + StatusCode = StatusCodes.Status200OK + }; + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error reaching n8n webhook: {Message}", ex.Message); + return new ObjectResult(new { success = false, message = "Our booking system is temporarily unreachable. Please try again in a moment." }) + { + StatusCode = StatusCodes.Status502BadGateway + }; + } + catch (TaskCanceledException ex) + when (ex.InnerException is TimeoutException || !ex.CancellationToken.IsCancellationRequested) + { + _logger.LogError(ex, "Timeout reaching n8n webhook"); + return new ObjectResult(new { success = false, message = "The request took too long. Please try again." }) + { + StatusCode = StatusCodes.Status504GatewayTimeout + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error in BookAppointment: {Message}", ex.Message); + return new ObjectResult(new { success = false, message = "Something went wrong. Please try again later." }) + { + StatusCode = StatusCodes.Status500InternalServerError + }; + } + } + + /// + /// Validates request fields based on the action type. + /// + /// An error message string, or null if valid. + private static string? ValidateRequest(BookAppointmentRequest request) + { + // Validate action + var validActions = new[] { "book", "cancel", "reschedule", "verify" }; + if (!validActions.Contains(request.Action.ToLowerInvariant())) + { + return "Invalid action. Must be 'book', 'cancel', 'reschedule', or 'verify'."; + } + + // Email is always required + var emailResult = InputValidator.ValidateEmail(request.Email); + if (!emailResult.IsValid) return emailResult.ErrorMessage; + + return request.Action.ToLowerInvariant() switch + { + "book" => ValidateBookAction(request), + "cancel" => ValidateCancelAction(request), + "reschedule" => ValidateRescheduleAction(request), + "verify" => ValidateVerifyAction(request), + _ => "Invalid action." + }; + } + + /// + /// Validates fields required for the "book" action. + /// + private static string? ValidateBookAction(BookAppointmentRequest request) + { + var nameResult = InputValidator.ValidateTextInput(request.Name, "Name", maxLength: 100); + if (!nameResult.IsValid) return nameResult.ErrorMessage; + + var phoneResult = InputValidator.ValidateTextInput(request.Phone, "Phone", maxLength: 20); + if (!phoneResult.IsValid) return phoneResult.ErrorMessage; + + var businessResult = InputValidator.ValidateTextInput(request.BusinessName, "Business Name", maxLength: 200); + if (!businessResult.IsValid) return businessResult.ErrorMessage; + + var dateResult = InputValidator.ValidateTextInput(request.Date, "Date", maxLength: 10); + if (!dateResult.IsValid) return dateResult.ErrorMessage; + + var timeResult = InputValidator.ValidateTextInput(request.Time, "Time", maxLength: 5); + if (!timeResult.IsValid) return timeResult.ErrorMessage; + + var endTimeResult = InputValidator.ValidateTextInput(request.EndTime, "End Time", maxLength: 5); + if (!endTimeResult.IsValid) return endTimeResult.ErrorMessage; + + // Validate date format (YYYY-MM-DD) + if (!DateOnly.TryParseExact(request.Date, "yyyy-MM-dd", out _)) + return "Please select a valid date."; + + // Validate time format (HH:mm) + if (!TimeOnly.TryParseExact(request.Time, "HH:mm", out _)) + return "Please select a valid time slot."; + + if (!TimeOnly.TryParseExact(request.EndTime, "HH:mm", out _)) + return "Please select a valid time slot."; + + // Validate phone starts with + + if (!request.Phone.StartsWith('+')) + return "Please enter a valid phone number with country code."; + + return null; + } + + /// + /// Validates fields required for the "cancel" action. + /// + private static string? ValidateCancelAction(BookAppointmentRequest request) + { + if (string.IsNullOrWhiteSpace(request.BookingId)) + return "Booking ID is required to cancel an appointment."; + + // BookingId format: APT-XXXXXXXX-XXXX + if (!request.BookingId.StartsWith("APT-") || request.BookingId.Length < 10) + return "Please enter a valid booking ID (e.g., APT-MN7O3825-TMVP)."; + + return null; + } + + /// + /// Validates fields required for the "reschedule" action. + /// + private static string? ValidateRescheduleAction(BookAppointmentRequest request) + { + // First validate cancel fields (bookingId) + var cancelValidation = ValidateCancelAction(request); + if (cancelValidation is not null) return cancelValidation; + + // Then validate new date/time + if (string.IsNullOrWhiteSpace(request.NewDate)) + return "New date is required for rescheduling."; + + if (string.IsNullOrWhiteSpace(request.NewTime)) + return "New time is required for rescheduling."; + + if (string.IsNullOrWhiteSpace(request.NewEndTime)) + return "New end time is required for rescheduling."; + + if (!DateOnly.TryParseExact(request.NewDate, "yyyy-MM-dd", out _)) + return "Please select a valid new date."; + + if (!TimeOnly.TryParseExact(request.NewTime, "HH:mm", out _)) + return "Please select a valid new time slot."; + + if (!TimeOnly.TryParseExact(request.NewEndTime, "HH:mm", out _)) + return "Please select a valid new time slot."; + + return null; + } + + /// + /// Validates fields required for the "verify" action. + /// + private static string? ValidateVerifyAction(BookAppointmentRequest request) + { + // Verify only needs bookingId and email (same as cancel) + if (string.IsNullOrWhiteSpace(request.BookingId)) + return "Booking ID is required to verify an appointment."; + + // BookingId format: APT-XXXXXXXX-XXXX + if (!request.BookingId.StartsWith("APT-") || request.BookingId.Length < 10) + return "Please enter a valid booking ID (e.g., APT-MN7O3825-TMVP)."; + + return null; + } +} diff --git a/Api/Features/Booking/BookAppointmentRequest.cs b/Api/Features/Booking/BookAppointmentRequest.cs new file mode 100644 index 0000000..6b91f3a --- /dev/null +++ b/Api/Features/Booking/BookAppointmentRequest.cs @@ -0,0 +1,92 @@ +using System.Text.Json.Serialization; + +namespace CloudZen.Api.Features.Booking; + +/// +/// Request model for the BookAppointment function. +/// Supports book, cancel, reschedule, and verify actions via the field. +/// +/// +/// +/// This is the WASM client's JSON contract. The Azure Function transforms it to +/// before forwarding to n8n. +/// +/// +/// Required fields vary by action: +/// +/// book: Name, Email, Phone, BusinessName, Date, Time, EndTime +/// cancel: BookingId, Email +/// reschedule: BookingId, Email, NewDate, NewTime, NewEndTime +/// verify: BookingId, Email +/// +/// +/// +public class BookAppointmentRequest +{ + /// + /// Workflow action to perform: "book", "cancel", "reschedule", or "verify". + /// Defaults to "book". + /// + [JsonPropertyName("action")] + public string Action { get; set; } = "book"; + + /// + /// Unique booking ID (e.g. "APT-MN7O3825-TMVP"). + /// Required for cancel and reschedule actions. + /// + [JsonPropertyName("bookingId")] + public string BookingId { get; set; } = string.Empty; + + /// Full name of the person booking the appointment. + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + /// Email address for calendar invites and confirmations. + [JsonPropertyName("email")] + public string Email { get; set; } = string.Empty; + + /// Phone number in E.164 format (e.g. "+15551234567") for Twilio compatibility. + [JsonPropertyName("phone")] + public string Phone { get; set; } = string.Empty; + + /// Name of the business or organization. + [JsonPropertyName("businessName")] + public string BusinessName { get; set; } = string.Empty; + + /// Appointment date in YYYY-MM-DD format. + [JsonPropertyName("date")] + public string Date { get; set; } = string.Empty; + + /// Start time in HH:mm 24-hour format. + [JsonPropertyName("time")] + public string Time { get; set; } = string.Empty; + + /// End time in HH:mm 24-hour format (start + 30 min). + [JsonPropertyName("endTime")] + public string EndTime { get; set; } = string.Empty; + + /// Reason for the appointment, displayed in the Google Calendar event. + [JsonPropertyName("reason")] + public string Reason { get; set; } = "CloudZen Virtual Meeting"; + + /// + /// New date for rescheduling in YYYY-MM-DD format. + /// Required for reschedule action. + /// + [JsonPropertyName("newDate")] + public string NewDate { get; set; } = string.Empty; + + /// + /// New start time for rescheduling in HH:mm 24-hour format. + /// Required for reschedule action. + /// + [JsonPropertyName("newTime")] + public string NewTime { get; set; } = string.Empty; + + /// + /// New end time for rescheduling in HH:mm 24-hour format. + /// Required for reschedule action. + /// + [JsonPropertyName("newEndTime")] + public string NewEndTime { get; set; } = string.Empty; +} diff --git a/Api/Functions/ChatFunction.cs b/Api/Features/Chat/ChatFunction.cs similarity index 97% rename from Api/Functions/ChatFunction.cs rename to Api/Features/Chat/ChatFunction.cs index 0ee744d..352bfe7 100644 --- a/Api/Functions/ChatFunction.cs +++ b/Api/Features/Chat/ChatFunction.cs @@ -1,6 +1,6 @@ -using CloudZen.Api.Models; -using CloudZen.Api.Security; -using CloudZen.Api.Services; +using CloudZen.Api.Shared.Security; +using CloudZen.Api.Shared.Services; +using CloudZen.Api.Shared.Models; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Azure.Functions.Worker; @@ -9,7 +9,7 @@ using System.Text; using System.Text.Json; -namespace CloudZen.Api.Functions; +namespace CloudZen.Api.Features.Chat; /// /// Azure Function to handle chatbot requests by proxying to the Anthropic (Claude) API. @@ -395,11 +395,17 @@ public async Task Run( return new BadRequestObjectResult(new ChatResponse { Success = false, Error = "Message role must be 'user' or 'assistant'." }); } - // Only enforce content length on user messages - assistant messages are - // generated by the API itself and may exceed the user input limit. - if (msg.Role == "user" && msg.Content.Length > MaxMessageContentLength) + // Only enforce content length and dangerous-content checks on user messages — + // assistant messages are generated by the API itself and may exceed the user input limit. + if (msg.Role == "user") { - return new BadRequestObjectResult(new ChatResponse { Success = false, Error = $"Message content is too long. Maximum {MaxMessageContentLength} characters." }); + var contentValidation = InputValidator.ValidateTextInput( + msg.Content, "Message", maxLength: MaxMessageContentLength); + + if (!contentValidation.IsValid) + { + return new BadRequestObjectResult(new ChatResponse { Success = false, Error = contentValidation.ErrorMessage }); + } } } diff --git a/Api/Models/ChatRequest.cs b/Api/Features/Chat/ChatRequest.cs similarity index 96% rename from Api/Models/ChatRequest.cs rename to Api/Features/Chat/ChatRequest.cs index 6bf9081..fdd3875 100644 --- a/Api/Models/ChatRequest.cs +++ b/Api/Features/Chat/ChatRequest.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Api.Models; +namespace CloudZen.Api.Features.Chat; /// /// Request model for the Chat function. diff --git a/Api/Models/ChatResponse.cs b/Api/Features/Chat/ChatResponse.cs similarity index 92% rename from Api/Models/ChatResponse.cs rename to Api/Features/Chat/ChatResponse.cs index d646953..3254bbe 100644 --- a/Api/Models/ChatResponse.cs +++ b/Api/Features/Chat/ChatResponse.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Api.Models; +namespace CloudZen.Api.Features.Chat; /// /// Response model returned by the Chat function. diff --git a/Api/Models/EmailRequest.cs b/Api/Features/Contact/EmailRequest.cs similarity index 94% rename from Api/Models/EmailRequest.cs rename to Api/Features/Contact/EmailRequest.cs index cc67f89..effdd22 100644 --- a/Api/Models/EmailRequest.cs +++ b/Api/Features/Contact/EmailRequest.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Api.Models; +namespace CloudZen.Api.Features.Contact; /// /// Request model for the SendEmail function. diff --git a/Api/Models/EmailSettings.cs b/Api/Features/Contact/EmailSettings.cs similarity index 97% rename from Api/Models/EmailSettings.cs rename to Api/Features/Contact/EmailSettings.cs index 1d5d66c..b94f9ee 100644 --- a/Api/Models/EmailSettings.cs +++ b/Api/Features/Contact/EmailSettings.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Api.Models; +namespace CloudZen.Api.Features.Contact; /// /// Configuration options for email sending functionality. diff --git a/Api/Functions/SendEmailFunction.cs b/Api/Features/Contact/SendEmailFunction.cs similarity index 93% rename from Api/Functions/SendEmailFunction.cs rename to Api/Features/Contact/SendEmailFunction.cs index 1b4fadd..3bdffc1 100644 --- a/Api/Functions/SendEmailFunction.cs +++ b/Api/Features/Contact/SendEmailFunction.cs @@ -1,6 +1,6 @@ -using CloudZen.Api.Models; -using CloudZen.Api.Security; -using CloudZen.Api.Services; +using CloudZen.Api.Shared.Security; +using CloudZen.Api.Shared.Services; +using CloudZen.Api.Shared.Models; using MailKit.Net.Smtp; using MailKit.Security; using Microsoft.AspNetCore.Http; @@ -13,7 +13,7 @@ using System.Security.Authentication; using System.Text.Json; -namespace CloudZen.Api.Functions; +namespace CloudZen.Api.Features.Contact; /// /// Azure Function to handle email sending through Brevo SMTP relay. @@ -129,14 +129,14 @@ public async Task Run( if (string.IsNullOrWhiteSpace(requestBody)) { _logger.LogWarning("Empty request body received."); - return new BadRequestObjectResult(new { error = "Request body is required." }); + return new BadRequestObjectResult(new { error = "Please fill out all required fields and try again." }); } // Limit request body size if (requestBody.Length > 10000) { _logger.LogWarning("Request body too large: {Size} bytes", requestBody.Length); - return new BadRequestObjectResult(new { error = "Request body too large." }); + return new BadRequestObjectResult(new { error = "Your message contains too much data. Please shorten your entries and try again." }); } var emailRequest = JsonSerializer.Deserialize(requestBody, EmailRequestJsonOptions); @@ -144,7 +144,7 @@ public async Task Run( if (emailRequest == null) { _logger.LogWarning("Failed to deserialize email request."); - return new BadRequestObjectResult(new { error = "Invalid request format." }); + return new BadRequestObjectResult(new { error = "We couldn't read your message details. Please try again." }); } // Validate required fields with security checks @@ -168,7 +168,7 @@ public async Task Run( if (string.IsNullOrEmpty(smtpLogin) || string.IsNullOrEmpty(smtpKey)) { _logger.LogError("Brevo SMTP credentials are not configured. Ensure BREVO_SMTP_LOGIN and BREVO_SMTP_KEY (or BREVO_API_KEY) are set."); - return new ObjectResult(new { error = "Email service is not configured properly." }) + return new ObjectResult(new { error = "Our email service is temporarily unavailable. Please try again later." }) { StatusCode = StatusCodes.Status500InternalServerError }; @@ -189,7 +189,7 @@ public async Task Run( catch (SmtpCommandException ex) { _logger.LogError(ex, "SMTP command error: {Message}, StatusCode: {StatusCode}", ex.Message, ex.StatusCode); - return new ObjectResult(new { error = "Failed to send email. Please try again later." }) + return new ObjectResult(new { error = "We were unable to send your message. Please try again later." }) { StatusCode = StatusCodes.Status500InternalServerError }; @@ -197,7 +197,7 @@ public async Task Run( catch (SmtpProtocolException ex) { _logger.LogError(ex, "SMTP protocol error: {Message}", ex.Message); - return new ObjectResult(new { error = "Failed to send email. Please try again later." }) + return new ObjectResult(new { error = "We were unable to send your message. Please try again later." }) { StatusCode = StatusCodes.Status500InternalServerError }; @@ -205,7 +205,7 @@ public async Task Run( catch (System.Security.Authentication.AuthenticationException ex) { _logger.LogError(ex, "SMTP authentication error: {Message}", ex.Message); - return new ObjectResult(new { error = "Email service configuration error." }) + return new ObjectResult(new { error = "Our email service is temporarily unavailable. Please try again later." }) { StatusCode = StatusCodes.Status500InternalServerError }; @@ -213,12 +213,12 @@ public async Task Run( catch (JsonException ex) { _logger.LogError(ex, "JSON parsing error: {Message}", ex.Message); - return new BadRequestObjectResult(new { error = "Invalid request format." }); + return new BadRequestObjectResult(new { error = "We couldn't read your message details. Please try again." }); } catch (Exception ex) { _logger.LogError(ex, "Unexpected error sending email: {Message}", ex.Message); - return new ObjectResult(new { error = "An unexpected error occurred." }) + return new ObjectResult(new { error = "Something went wrong. Please try again later." }) { StatusCode = StatusCodes.Status500InternalServerError }; @@ -270,8 +270,8 @@ private async Task SendEmailViaSmtpAsync(EmailRequest emailRequest, stri // Send via SMTP using var client = new SmtpClient(); - // IMPORTANT: Disable certificate revocation check BEFORE setting the callback - // This is required because revocation servers may be unreachable in some networks + // Disable revocation check only — revocation servers may be unreachable in + // restricted Azure networks, but full certificate chain validation is preserved. client.CheckCertificateRevocation = false; // Configure certificate validation for Brevo's SMTP server. diff --git a/Api/Program.cs b/Api/Program.cs index 57a584b..b9ff95c 100644 --- a/Api/Program.cs +++ b/Api/Program.cs @@ -1,8 +1,8 @@ using Azure.Identity; -using CloudZen.Api.Models; -using CloudZen.Api.Models.Options; -using CloudZen.Api.Security; -using CloudZen.Api.Services; +using CloudZen.Api.Shared.Models; +using CloudZen.Api.Shared.Security; +using CloudZen.Api.Shared.Services; +using CloudZen.Api.Features.Contact; using Microsoft.Azure.Functions.Worker; using Microsoft.Azure.Functions.Worker.Builder; using Microsoft.Extensions.Configuration; diff --git a/Api/Properties/launchSettings.json b/Api/Properties/launchSettings.json index 404ab25..239bb9a 100644 --- a/Api/Properties/launchSettings.json +++ b/Api/Properties/launchSettings.json @@ -8,7 +8,7 @@ "CloudZen.Api (HTTPS)": { "commandName": "Executable", "executablePath": "func", - "commandLineArgs": "start --port 7257 --useHttps ", + "commandLineArgs": "start --port 7257 --useHttps", "workingDirectory": "bin\\Debug\\net8.0", "launchBrowser": false, "environmentVariables": { diff --git a/Api/Models/Options/RateLimitOptions.cs b/Api/Shared/Models/RateLimitOptions.cs similarity index 99% rename from Api/Models/Options/RateLimitOptions.cs rename to Api/Shared/Models/RateLimitOptions.cs index 49ba786..da14cdb 100644 --- a/Api/Models/Options/RateLimitOptions.cs +++ b/Api/Shared/Models/RateLimitOptions.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Api.Models.Options; +namespace CloudZen.Api.Shared.Models; /// /// Configuration options for rate limiting and resilience policies. diff --git a/Api/Models/RateLimitRejectionReason.cs b/Api/Shared/Models/RateLimitRejectionReason.cs similarity index 96% rename from Api/Models/RateLimitRejectionReason.cs rename to Api/Shared/Models/RateLimitRejectionReason.cs index bb834a3..c26ccbf 100644 --- a/Api/Models/RateLimitRejectionReason.cs +++ b/Api/Shared/Models/RateLimitRejectionReason.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Api.Models; +namespace CloudZen.Api.Shared.Models; /// /// Specifies the reason for a rate limit rejection. diff --git a/Api/Models/RateLimitResult.cs b/Api/Shared/Models/RateLimitResult.cs similarity index 99% rename from Api/Models/RateLimitResult.cs rename to Api/Shared/Models/RateLimitResult.cs index d20b71d..aea3ca9 100644 --- a/Api/Models/RateLimitResult.cs +++ b/Api/Shared/Models/RateLimitResult.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Api.Models; +namespace CloudZen.Api.Shared.Models; /// /// Represents the result of a rate limit check operation. diff --git a/Api/Security/InputValidator.cs b/Api/Shared/Security/InputValidator.cs similarity index 94% rename from Api/Security/InputValidator.cs rename to Api/Shared/Security/InputValidator.cs index 1551bbf..7b6429c 100644 --- a/Api/Security/InputValidator.cs +++ b/Api/Shared/Security/InputValidator.cs @@ -2,7 +2,7 @@ using Microsoft.Extensions.Logging; using System.Text.RegularExpressions; -namespace CloudZen.Api.Security; +namespace CloudZen.Api.Shared.Security; /// /// Provides input validation and sanitization utilities to protect against common security attack vectors @@ -102,25 +102,25 @@ public static partial class InputValidator public static ValidationResult ValidateEmail(string? email) { if (string.IsNullOrWhiteSpace(email)) - return ValidationResult.Invalid("Email is required."); + return ValidationResult.Invalid("Please provide an email address."); if (email.Length > 254) - return ValidationResult.Invalid("Email address is too long."); + return ValidationResult.Invalid("The email address is too long (max 254 characters)."); if (ContainsDangerousContent(email)) - return ValidationResult.Invalid("Invalid email format."); + return ValidationResult.Invalid("Please enter a valid email address."); try { var addr = new System.Net.Mail.MailAddress(email); if (addr.Address != email) - return ValidationResult.Invalid("Invalid email format."); + return ValidationResult.Invalid("Please enter a valid email address."); return ValidationResult.Valid(); } catch { - return ValidationResult.Invalid("Invalid email format."); + return ValidationResult.Invalid("Please enter a valid email address."); } } @@ -159,19 +159,19 @@ public static ValidationResult ValidateTextInput(string? input, string fieldName if (string.IsNullOrWhiteSpace(input)) { return required - ? ValidationResult.Invalid($"{fieldName} is required.") + ? ValidationResult.Invalid($"Please enter your {fieldName.ToLowerInvariant()}.") : ValidationResult.Valid(); } if (input.Length > maxLength) - return ValidationResult.Invalid($"{fieldName} exceeds maximum length of {maxLength} characters."); + return ValidationResult.Invalid($"{fieldName} is too long (max {maxLength} characters)."); if (ContainsDangerousContent(input)) - return ValidationResult.Invalid($"{fieldName} contains invalid content."); + return ValidationResult.Invalid($"{fieldName} contains characters that aren't allowed."); // Check for SQL injection patterns if (ContainsSqlInjectionPatterns(input)) - return ValidationResult.Invalid($"{fieldName} contains invalid content."); + return ValidationResult.Invalid($"{fieldName} contains characters that aren't allowed."); // Check for path traversal patterns (A01: Broken Access Control) if (ContainsPathTraversal(input)) @@ -563,8 +563,17 @@ public static void AddSecurityHeaders(this HttpResponse response) // Control referrer information headers.TryAdd("Referrer-Policy", "strict-origin-when-cross-origin"); - // Content Security Policy - headers.TryAdd("Content-Security-Policy", "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'"); + // Content Security Policy — scoped directives per resource type + headers.TryAdd("Content-Security-Policy", + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' https://fonts.googleapis.com https://cdn.jsdelivr.net; " + + "font-src 'self' https://fonts.gstatic.com https://cdn.jsdelivr.net; " + + "img-src 'self' data: https:; " + + "connect-src 'self'; " + + "frame-ancestors 'none'; " + + "base-uri 'self'; " + + "form-action 'self'"); // Permissions Policy (previously Feature-Policy) headers.TryAdd("Permissions-Policy", "geolocation=(), microphone=(), camera=()"); @@ -663,7 +672,16 @@ public record CorsSettings(string[] AllowedOrigins) public bool IsOriginAllowed(string? origin) { if (string.IsNullOrEmpty(origin)) return false; - if (AllowedOrigins.Contains("*")) return true; // only for staging/testing, not recommended for production + + if (AllowedOrigins.Contains("*")) + { + var env = Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT"); + if (!string.Equals(env, "Development", StringComparison.OrdinalIgnoreCase)) + throw new InvalidOperationException("Wildcard CORS origin '*' is not allowed outside the Development environment."); + + return true; + } + return AllowedOrigins.Contains(origin, StringComparer.OrdinalIgnoreCase); } } diff --git a/Api/Services/IRateLimiterService.cs b/Api/Shared/Services/IRateLimiterService.cs similarity index 97% rename from Api/Services/IRateLimiterService.cs rename to Api/Shared/Services/IRateLimiterService.cs index be63a8a..eab3dd7 100644 --- a/Api/Services/IRateLimiterService.cs +++ b/Api/Shared/Services/IRateLimiterService.cs @@ -1,7 +1,7 @@ -using CloudZen.Api.Models; +using CloudZen.Api.Shared.Models; using System.Threading.RateLimiting; -namespace CloudZen.Api.Services; +namespace CloudZen.Api.Shared.Services; /// /// Service interface for handling rate limiting of API endpoints. diff --git a/Api/Services/RateLimiterService.cs b/Api/Shared/Services/RateLimiterService.cs similarity index 98% rename from Api/Services/RateLimiterService.cs rename to Api/Shared/Services/RateLimiterService.cs index b1a5de1..7701c46 100644 --- a/Api/Services/RateLimiterService.cs +++ b/Api/Shared/Services/RateLimiterService.cs @@ -1,5 +1,4 @@ -using CloudZen.Api.Models; -using CloudZen.Api.Models.Options; +using CloudZen.Api.Shared.Models; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Polly; @@ -7,9 +6,9 @@ using Polly.RateLimiting; using System.Collections.Concurrent; using System.Threading.RateLimiting; -using CloudZen.Api.Security; +using CloudZen.Api.Shared.Security; -namespace CloudZen.Api.Services; +namespace CloudZen.Api.Shared.Services; /// /// Polly-based rate limiter service implementation with per-client rate limiting. diff --git a/Api/host.json b/Api/host.json index a8a393c..60c978c 100644 --- a/Api/host.json +++ b/Api/host.json @@ -1,5 +1,9 @@ { "version": "2.0", + "managedDependency": { + "enabled": true + }, + "processStartupTimeout": "00:02:00", "logging": { "applicationInsights": { "samplingSettings": { diff --git a/BLUE_GREEN_DEPLOYMENT.md b/BLUE_GREEN_DEPLOYMENT.md deleted file mode 100644 index 16079ae..0000000 --- a/BLUE_GREEN_DEPLOYMENT.md +++ /dev/null @@ -1,422 +0,0 @@ -# Blue/Green Deployment — Staging & Production - -Complete guide for the CloudZen blue/green deployment pipeline using Azure Static Web Apps preview environments and separate Azure Function Apps. - -> **Related docs:** [AZURE_FUNCTION_DEPLOYMENT.md](AZURE_FUNCTION_DEPLOYMENT.md) · [DEPLOYMENT_GUIDE.md](DEPLOYMENT_GUIDE.md) · [DEPLOYMENT_CHECKLIST.md](DEPLOYMENT_CHECKLIST.md) · [AZURE_FUNCTIONS_HOSTING_MODELS.md](AZURE_FUNCTIONS_HOSTING_MODELS.md) - ---- - -## Table of Contents - -1. [Architecture](#1-architecture) -2. [Why Not Deployment Slots](#2-why-not-deployment-slots) -3. [Azure Portal Setup](#3-azure-portal-setup) -4. [GitHub Setup](#4-github-setup) -5. [Workflow Configuration](#5-workflow-configuration) -6. [How SWA Preview Environments Work](#6-how-swa-preview-environments-work) -7. [CORS & API Routing](#7-cors--api-routing) -8. [Day-to-Day Workflow](#8-day-to-day-workflow) -9. [Testing Procedure](#9-testing-procedure) -10. [Troubleshooting](#10-troubleshooting) - ---- - -## 1. Architecture - -Two completely independent Function Apps, one SWA with built-in preview environments: - -``` -GitHub Repository (master) - │ - ├─ Push to master ────► SWA Production ──► Production Function App - │ www.cloud-zen.net cloudzen-api-func-e4ge... - │ - └─ PR to master ──────► SWA Preview Env ──► Staging Function App - -.azurestaticapps.net - cloudzen-api-func-staging-hch0... -``` - -| Component | Production | Staging | -|-----------|-----------|---------| -| **SWA** | `www.cloud-zen.net` | `lively-flower-02783cd0f-.azurestaticapps.net` | -| **Function App** | `cloudzen-api-func-e4gehdaef9ftdhbn` | `cloudzen-api-func-staging-hch0amaed0gke2dv` | -| **Blazor config** | `appsettings.Production.json` | `appsettings.Staging.json` (swapped at build time) | -| **CORS** | `AllowedOrigins__0 = https://www.cloud-zen.net` | `AllowedOrigins__0 = *` | -| **Trigger** | Push to `master` | PR to `master` | -| **Lifecycle** | Permanent | Auto-created on PR open, destroyed on PR close/merge | - -The Blazor WASM app calls Function Apps **directly by full URL** (not through SWA's linked API feature). This is what enables each environment to point to its own backend. - ---- - -## 2. Why Not Deployment Slots - -Azure Deployment Slots require **Standard plan or higher** (~$70+/month). Our setup uses two **Consumption plan** Function Apps (pay-per-execution, near-zero cost for low traffic). - -| Factor | Deployment Slots | Separate Function Apps (our setup) | -|--------|-----------------|-----------------------------------| -| **Cost** | ~$70+/month minimum | ~$0 (Consumption plan) | -| **Plan required** | Standard+ | Consumption (free tier) | -| **Independent config** | Slot-sticky settings | Fully independent App Settings | -| **Independent scaling** | Shared plan resources | Independent scaling | -| **Blue/green frontend** | Not applicable to SWA | SWA preview envs handle this | - -**When slots make sense:** High-traffic production apps needing zero-downtime swaps with warm-up, running on Standard/Premium plans already. - ---- - -## 3. Azure Portal Setup - -### 3.1 Create Staging Function App - -1. **Azure Portal → Create a resource → Function App** -2. Configure: - -| Setting | Value | -|---------|-------| -| Name | `cloudzen-api-func-staging-` | -| Runtime | .NET 8, Isolated | -| Plan | Consumption (Serverless) | -| Region | Same as production (West US 2) | -| Resource Group | `CloudZend-RG` | - -### 3.2 Production Function App — Environment Variables - -| Setting | Value | -|---------|-------| -| `FUNCTIONS_WORKER_RUNTIME` | `dotnet-isolated` | -| `FUNCTIONS_EXTENSION_VERSION` | `~4` | -| `WEBSITE_USE_PLACEHOLDER_DOTNETISOLATED` | `1` | -| `AllowedOrigins__0` | `https://www.cloud-zen.net` | -| `ProductionOrigin` | `https://www.cloud-zen.net` | -| `ANTHROPIC_API_KEY` | *(your key)* | -| `BREVO_SMTP_KEY` | *(your key)* | -| `BREVO_SMTP_LOGIN` | *(your login)* | -| `EmailSettings:FromEmail` | `cloudzen.inc@gmail.com` | -| `EmailSettings:CcEmail` | `softevolutionsl@gmail.com` | -| `RateLimiting:PermitLimit` | `10` | -| `RateLimiting:WindowSeconds` | `60` | -| `RateLimiting:QueueLimit` | `0` | -| `RateLimiting:InactivityTimeoutMinutes` | `5` | -| `RateLimiting:EnableCircuitBreaker` | `false` | -| `APPLICATIONINSIGHTS_CONNECTION_STRING` | *(auto-generated)* | -| `AzureWebJobsStorage` | *(auto-generated)* | - -> ⚠️ **Critical:** `FUNCTIONS_WORKER_RUNTIME` must be `dotnet-isolated`. If missing, the host reports `0 functions found` and all endpoints return 404. - -> ⚠️ **Do NOT check "Deployment slot setting"** on any setting. On Consumption plan with no slots, it can cause unexpected behavior. - -### 3.3 Staging Function App — Environment Variables - -Copy all production settings, then override: - -| Setting | Value | Reason | -|---------|-------|--------| -| `AllowedOrigins__0` | `*` | Accept any SWA preview URL | -| `ProductionOrigin` | *(remove or leave empty)* | Not needed for staging | - -All other settings (API keys, email config, rate limiting) should match production for realistic testing. - -### 3.4 SWA — No API Linking Required - -**Do NOT** link a Function App under **Azure Portal → SWA → APIs**. The Blazor app calls Function Apps directly via full URL. Linking would route all preview environments to the same backend, defeating blue/green. - ---- - -## 4. GitHub Setup - -### 4.1 Repository Secrets - -**Settings → Secrets and variables → Actions** (repository level): - -| Secret | Value | -|--------|-------| -| `AZURE_STATIC_WEB_APPS_API_TOKEN` | SWA deployment token | -| `AZURE_FUNCTIONAPP_PUBLISH_PROFILE` | Production Function App publish profile XML | -| `AZURE_FUNCTIONAPP_PUBLISH_PROFILE_STAGING` | Staging Function App publish profile XML | - -> Keep all secrets at **repository level** (not environment level). Both jobs can read repo-level secrets regardless of their `environment:` setting. - -### 4.2 GitHub Environments (Optional) - -**Settings → Environments:** - -| Environment | Protection Rules | Branch Policy | -|-------------|-----------------|---------------| -| `production` | Required reviewers (optional) | `master` only | -| `staging` | None | Any branch | - -If `production` has required reviewers, `deploy-production` will pause and wait for approval in GitHub Actions. - ---- - -## 5. Workflow Configuration - -### 5.1 Function App Workflow (`.github/workflows/azure-functions.yml`) - -```yaml -name: Deploy Azure Function - -on: - push: - branches: [master] - paths: - - 'Api/**' - - '.github/workflows/azure-functions.yml' - pull_request: - types: [opened, synchronize, reopened] - branches: [master] - paths: - - 'Api/**' - - '.github/workflows/azure-functions.yml' - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - steps: [checkout, setup .NET, restore, build, publish, upload-artifact] - - deploy-staging: - if: github.event_name == 'pull_request' - needs: build - environment: staging - # → deploys to staging Function App - - deploy-production: - if: (github.event_name == 'push' || github.event_name == 'workflow_dispatch') && github.ref == 'refs/heads/master' - needs: build - environment: production - # → deploys to production Function App -``` - -**Key design decisions:** -- `workflow_dispatch` included in `deploy-production` condition for manual re-deploys -- `.github/workflows/azure-functions.yml` in `paths` so workflow changes trigger builds -- Artifact upload/download between jobs for clean separation - -### 5.2 SWA Workflow (`.github/workflows/azure-static-web-apps.yml`) - -```yaml -name: Azure CloudZen Static Web Apps CI/CD - -on: - push: - branches: [master] - paths-ignore: - - 'Api/**' - - '.github/workflows/azure-functions.yml' - - '*.md' - pull_request: - types: [opened, synchronize, reopened, closed] - branches: [master] - paths-ignore: [same as above] - workflow_dispatch: - -jobs: - build-and-deploy: - steps: - # ... checkout, setup .NET, restore, build ... - - # PR builds: swap staging config before publish - - name: Apply staging configuration - if: github.event_name == 'pull_request' - run: | - cp wwwroot/appsettings.Staging.json wwwroot/appsettings.Production.json - sed -i "s|$PRODUCTION_FUNC_HOSTNAME|$STAGING_FUNC_HOSTNAME|g" wwwroot/staticwebapp.config.json - - # ... publish, deploy to SWA ... - - close-staging: - if: github.event_name == 'pull_request' && github.event.action == 'closed' - # → destroys preview environment -``` - -**The staging config swap:** Before `dotnet publish`, the workflow copies `appsettings.Staging.json` over `appsettings.Production.json` and updates `staticwebapp.config.json` CSP headers to point to the staging Function App. This makes the preview environment talk to the staging backend. - -### 5.3 Trigger Matrix - -| File Changed | SWA Workflow | Functions Workflow | -|---|---|---| -| Blazor files (`*.razor`, `*.cs`, etc.) | ✅ | ❌ | -| `Api/**` | ❌ | ✅ | -| `azure-static-web-apps.yml` | ✅ | ❌ | -| `azure-functions.yml` | ❌ | ✅ | -| `*.md` | ❌ | ❌ | - ---- - -## 6. How SWA Preview Environments Work - -### URL Generation - -Azure SWA auto-creates preview environments for PRs using the `GITHUB_TOKEN` context: - -``` -Production: https://lively-flower-02783cd0f.azurestaticapps.net - https://www.cloud-zen.net (custom domain) - -PR #5: https://lively-flower-02783cd0f-5.azurestaticapps.net -PR #7: https://lively-flower-02783cd0f-7.azurestaticapps.net -``` - -Custom domains are **never** assigned to preview environments — by design. - -### Lifecycle - -``` -PR opened → SWA creates preview env → unique URL active -PR updated → SWA rebuilds preview env → same URL, new content -PR closed → close-staging job runs → preview env destroyed → URL stops working -``` - -### Free Plan Limits - -| Feature | Limit | -|---------|-------| -| Concurrent preview environments | **3 max** | -| Custom domains | 2 | -| Max app size | 250 MB | - ---- - -## 7. CORS & API Routing - -### Direct API Calls (Our Architecture) - -The Blazor app calls Function Apps **by full URL**, bypassing SWA's API proxy: - -``` -Browser (www.cloud-zen.net) - │ Direct HTTPS (cross-origin) - ▼ -cloudzen-api-func-e4ge....azurewebsites.net/api/chat - │ Function App handles CORS via AllowedOrigins config -``` - -This is what enables blue/green — each environment points to a different Function App URL via `appsettings.*.json`. - -### Wildcard CORS for Staging - -The staging Function App uses `AllowedOrigins__0 = *` because preview environment URLs are unpredictable (contain PR numbers). The `CorsSettings.IsOriginAllowed()` method supports wildcards: - -```csharp -public bool IsOriginAllowed(string? origin) -{ - if (AllowedOrigins.Contains("*")) return true; // staging only - return AllowedOrigins.Contains(origin, StringComparer.OrdinalIgnoreCase); -} -``` - -### The `/api/*` Route in `staticwebapp.config.json` - -This route exists but is **not actively used** — it's for SWA's linked API feature, which we don't use. Harmless to keep. - ---- - -## 8. Day-to-Day Workflow - -### Feature Development (Staging) - -```bash -git checkout -b feature/my-change -# ... make changes ... -git push origin feature/my-change -# Open PR to master on GitHub -``` - -**What happens:** -1. SWA workflow creates a preview environment with staging config -2. Functions workflow deploys to the staging Function App (if `Api/` changed) -3. Preview URL appears in the PR status checks -4. Test at the preview URL — chatbot/email use the staging backend - -### Ship to Production - -```bash -# Merge the PR on GitHub -``` - -**What happens:** -1. Push to `master` triggers both production deploys -2. SWA production is updated at `www.cloud-zen.net` -3. Functions production is deployed (approval required if configured) -4. `close-staging` job destroys the preview environment - ---- - -## 9. Testing Procedure - -### Phase 1: Push Infrastructure to Master - -```bash -git add -A -git commit -m "Add blue/green deployment infrastructure" -git push origin master -``` - -Wait for both workflows to complete at [GitHub Actions](https://github.com/dariemcarlosdev/CloudZen/actions). If `deploy-production` requires approval, approve it. - -### Phase 2: Create Test PR - -```bash -git checkout -b test/staging-pipeline -# Make a small visible change to a Blazor file AND an Api file -git add -A -git commit -m "Test: verify staging pipeline" -git push origin test/staging-pipeline -``` - -Open a PR to `master`. - -### Phase 3: Verify Staging - -1. Find the **preview URL** in the PR status checks or workflow log -2. Open it in a browser -3. **DevTools → Network tab** -4. Test the chatbot — verify requests go to `cloudzen-api-func-staging-hch0...` (not production) -5. Test the contact form — same verification - -### Phase 4: Merge and Verify Production - -1. Merge the PR -2. Watch GitHub Actions — production deploys trigger -3. Approve if required -4. Verify `https://www.cloud-zen.net` works normally -5. Delete the test branch - ---- - -## 10. Troubleshooting - -### Functions Blade Shows Empty / "0 functions found" - -| Check | Fix | -|-------|-----| -| `FUNCTIONS_WORKER_RUNTIME` missing or `dotnet` | Set to `dotnet-isolated` | -| `FUNCTIONS_EXTENSION_VERSION` wrong | Set to `~4` | -| `WEBSITE_USE_PLACEHOLDER_DOTNETISOLATED` wrong | Set to `1` | -| `AllowedOrigins__0` missing | Add `https://www.cloud-zen.net` (production) or `*` (staging) | -| `KEY_VAULT_ENDPOINT` set but auth fails | Remove it if not using Key Vault | - -### `deploy-production` Doesn't Run - -| Symptom | Cause | Fix | -|---------|-------|-----| -| Only `build` runs on manual trigger | Old condition excluded `workflow_dispatch` | Ensure condition: `(push \|\| workflow_dispatch) && refs/heads/master` | -| Job shows "Waiting" | `production` environment has required reviewers | Approve in GitHub Actions | -| Workflow didn't trigger | Changed files not in `paths` filter | Add `.github/workflows/azure-functions.yml` to `paths` | - -### Both Workflows Trigger on One Push - -The SWA workflow uses `paths-ignore`, so it triggers on everything **except** ignored paths. Ensure `azure-functions.yml` is in the SWA's `paths-ignore` and vice versa. - -### 404 on `/api/chat` After Deploy - -1. Check Functions blade — are Chat/SendEmail listed? -2. If empty → startup crash → check **Log stream** or **Application Insights → Failures → Exceptions** -3. If listed → CORS issue → check `AllowedOrigins__0` value -4. Try **Stop → Start** (not just Restart) - ---- - -*Last updated: March 2026 — Blue/Green deployment with separate Function Apps and SWA preview environments.* diff --git a/CLAUDE.md.archived b/CLAUDE.md.archived new file mode 100644 index 0000000..415e7b7 --- /dev/null +++ b/CLAUDE.md.archived @@ -0,0 +1,253 @@ +# CLAUDE.md — Claude-Specific AI Instructions + +> These instructions extend AGENTS.md with guidance optimized for Claude's reasoning capabilities. + +## Project Context + +Read **AGENTS.md** first for full project context. This file adds Claude-specific guidance for: + +- Structured reasoning about architecture decisions +- Step-by-step SOLID analysis during refactoring +- Chain-of-thought for complex handler design +- Security review methodology + +--- + +## Reasoning Approach + +### Architectural Decisions + +When making architectural decisions, reason through this checklist: + +1. **Which layer does this belong to?** Map the change to Presentation / Application / Domain / Infrastructure. +2. **Does it violate dependency direction?** Inner layers must never reference outer layers. +3. **Which pattern applies?** Strategy (external providers), Repository (data access), MediatR (business operations), Event Bus (side effects). +4. **What are the SOLID implications?** + - SRP: Does this class have one reason to change? + - OCP: Can this be extended without modifying existing code? + - LSP: Are subtypes substitutable? + - ISP: Is the interface focused (like `IChargeable` vs a god interface)? + - DIP: Are we depending on abstractions? + +### Refactoring + +When refactoring existing code, think step-by-step: + +1. **Identify the smell.** Name the specific code smell or violation. +2. **Trace dependencies.** Map what depends on the code being changed. +3. **Evaluate SOLID impact.** Which principles are violated? Which will the refactoring satisfy? +4. **Plan the migration.** Backward compatibility matters — ensure existing consumers are not broken. +5. **Verify the invariants.** After refactoring, do business rules still hold? (domain constraints, audit trail, no PII logging) + +--- + +## MediatR Handler Design + +When designing or modifying MediatR handlers, use chain-of-thought through this flow: + +``` +1. Define the Command/Query record + → What data does the caller provide? + → Use records with init properties for immutability + +2. Define the Response + → Success case: what does the caller need back? + → Failure case: use Result pattern or throw domain exceptions? + +3. Implement the Handler + → Validate input (FluentValidation or guard clauses) + → Resolve strategy via factory if needed + → Execute operation via strategy interface + → Persist state change via repository interface + → Publish domain event via IEventBus + → Return response + +4. Register (automatic via assembly scanning in Program.cs) +``` + +**Example thought process for a new "CancelOrder" handler:** + +> The cancel operation needs: order ID, cancellation reason, and the actor performing it. +> It should verify the order is in a cancellable state (e.g., "Pending" or "InProgress"). +> The strategy must implement `ICancellable` (ISP — don't add to existing interfaces). +> After cancellation, publish an `OrderCancelledEvent` via `IEventBus`. +> Update the order status and persist. +> Return the updated order state. + +--- + +## Code Generation Rules + +### C# Code + +- **Always use explicit type annotations.** Prefer `Order order` over `var order` for domain types. `var` is acceptable for obvious types (`var list = new List()`). +- **File-scoped namespaces.** Always `namespace ProjectName.Features.Orders;` — never block-scoped. +- **Nullable enabled.** Use `string?` for nullable, never `string` for potentially null values. +- **Sealed by default.** Add `sealed` to classes not designed for inheritance. +- **Records for DTOs.** Commands, queries, and response models should be `record` types. +- **Primary constructors** for simple DI injection in handlers. +- **Cancellation tokens.** Every async method accepts and propagates `CancellationToken`. + +### Blazor Components + +**Always generate all three files** for every component: + +```csharp +// ComponentName.razor — Markup only +@page "/route" +@using Microsoft.Extensions.Localization +@inject IStringLocalizer L + +
+

@L["PageTitle"]

+
+ +// ComponentName.razor.cs — Logic +using Microsoft.AspNetCore.Components; +using Microsoft.Extensions.Localization; + +namespace ProjectName.Components.Pages; + +public sealed partial class ComponentName +{ + [Inject] private IStringLocalizer L { get; set; } = default!; + + protected override async Task OnInitializedAsync() + { + // Load data via IMediator + } +} + +// ComponentName.razor.css — Scoped styles +.component-wrapper { + /* Bootstrap 5 utilities + custom overrides */ +} +``` + +--- + +## Security Review Methodology + +When reviewing code for security, systematically evaluate each OWASP Top 10 category: + +| # | Category | What to Check | +|---|---|---| +| A01 | Broken Access Control | Is `[Authorize]` on every endpoint? Policy-based, not role strings? | +| A02 | Cryptographic Failures | Secrets in code? PII in logs? TLS enforced? | +| A03 | Injection | Parameterized queries? No string concatenation in SQL/commands? | +| A04 | Insecure Design | Threat model reviewed? Business logic bypasses? | +| A05 | Security Misconfiguration | HTTPS? HSTS? Antiforgery? Debug disabled in prod? | +| A06 | Vulnerable Components | NuGet packages up to date? Known CVEs? | +| A07 | Auth Failures | Token validation? Brute-force protection? Session management? | +| A08 | Data Integrity Failures | Deserialization safe? Pipeline integrity? | +| A09 | Logging Failures | Audit trail present? Correlation IDs? No secrets in logs? | +| A10 | SSRF | External URL validation? Allowlisting? | + +For each finding, provide: +- **Severity:** Critical / High / Medium / Low +- **Location:** File and line reference +- **Issue:** What's wrong +- **Fix:** Specific code change + +--- + +## Immutability Preferences + +Claude should favor immutable constructs wherever possible: + +- `record` over `class` for data transfer objects +- `readonly` fields in services and handlers +- `init` properties on models where mutation is not required +- `IReadOnlyCollection` and `IReadOnlyList` for collection returns +- `sealed` classes to prevent unintended inheritance +- Expression-bodied members for single-line logic + +--- + +## Documentation Updates + +When modifying features, check and update the corresponding doc in `docs/`: + +| Feature Area | Doc to Update | +|---|---| +| Cross-cutting / architecture | `00-Architecture-Overview` | +| Feature-specific logic | `NN-Feature-Name` (matching doc) | +| New external provider | Strategy pattern documentation | +| Identity / auth changes | Authentication/authorization docs | +| Event bus changes | Event bus / domain events docs | +| Localization changes | Localization docs | +| UI components | UI component docs | +| API endpoints | API integration docs | + +If no doc exists for a new feature, create one following the `NN-Feature-Name` convention. + +--- + +## Domain Model Reference + +Define your project's key entities and their relationships here. Example structure: + +``` +Order (Aggregate Root) +├── Id (Guid, PK) +├── CustomerId (Guid, required) — the buyer +├── Amount (Money, required) — order total as value object +├── Status (OrderStatus) — Pending → InProgress → Completed | Cancelled +├── Description (string) — what the order is for +├── CreatedAt (DateTimeOffset) — UTC timestamp +└── CompletedAt (DateTimeOffset?) — set when Status = Completed + +Money (Value Object) +├── Amount (decimal) +└── Currency (string) + +OrderStatus (Enum) +├── Pending +├── InProgress +├── Completed +└── Cancelled +``` + +--- + +## Error Handling Guidance + +- Use domain-specific exceptions for business rule violations (e.g., `InvalidOrderStateException`). +- Handlers catch infrastructure exceptions and translate to meaningful domain errors. +- Global exception middleware handles unhandled exceptions for API endpoints. +- Never swallow exceptions silently — log with context and correlation IDs. +- Return appropriate HTTP status codes: 400 for validation, 404 for not found, 409 for conflicts, 500 for unexpected. + +--- + +## Skills Catalog + +See **AGENTS.md → Skills Catalog** for the complete skill loading instructions, categories, +and usage examples. Skills are universal across all models. + +### Claude Code Integration (`/skills`) + +All skills are registered as **Claude Code skills** in `.claude/skills/`. They appear in +`/skills` and can be invoked via `/skill-name` (e.g., `/owasp-audit`, `/code-reviewer`). + +Each `.claude/skills/{name}/SKILL.md` is a **bridge file** — it registers the skill with +Claude's discovery system and redirects to the full universal definition in `.github/skills/`. + +**How it works:** +1. User types `/owasp-audit` → Claude loads `.claude/skills/owasp-audit/SKILL.md` +2. Bridge tells Claude to read `.github/skills/owasp-audit/SKILL.md` +3. Claude follows the Core Workflow + loads references on demand + +**Architecture:** `.claude/skills/` = Claude registration layer → `.github/skills/` = universal source of truth + +### Quick Reference + +| Invoke | Full Skill Path | +|--------|----------------| +| `/code-reviewer` | `.github/skills/code-reviewer/SKILL.md` | +| `/owasp-audit` | `.github/skills/owasp-audit/SKILL.md` | +| `/test-generator` | `.github/skills/test-generator/SKILL.md` | +| `/architecture-reviewer` | `.github/skills/architecture-reviewer/SKILL.md` | +| `/authentication` | `.github/skills/authentication/SKILL.md` | +| `/agent-orchestrator` | `.github/skills/agent-orchestrator/SKILL.md` | +| Full catalog | `.github/skills/CATALOG.md` | diff --git a/COMPONENT_ARCHITECTURE.md b/COMPONENT_ARCHITECTURE.md deleted file mode 100644 index 6b6cfa9..0000000 --- a/COMPONENT_ARCHITECTURE.md +++ /dev/null @@ -1,780 +0,0 @@ -# CloudZen Component Architecture Documentation - -## Overview - -This document describes the component-based architecture implemented in the CloudZen Blazor WebAssembly application, focusing on the **WhoIAm** page refactoring that demonstrates modern Blazor component design principles. - ---- - -## 🎯 Architecture Goals - -The refactoring was driven by these core principles: - -1. **Separation of Concerns** - Each component has a single, well-defined responsibility -2. **Reusability** - Components can be used across multiple pages -3. **Maintainability** - Easier to understand, test, and modify -4. **Scalability** - Component structure supports future growth -5. **Performance** - Smaller components enable better Blazor rendering optimization - ---- - -## 📂 Project Structure - -``` -CloudZen/ -├── Models/ -│ ├── ProjectInfo.cs # Project data model -│ └── ProjectParticipant.cs # Project participant model -│ └── ServiceInfo.cs # Service data model. Records service details -│ -├── Services/ -│ ├── ProjectService.cs # Project data management service -│ ├── ResumeService.cs # Resume download service -│ ├── PersonalService.cs # Personal info service -│ ├── ChatbotService.cs # AI chatbot HTTP client service -│ ├── Abstractions/ -│ │ └── IChatbotService.cs # Chatbot service interface -│ └── ... (other services) -│ -├── Shared/ -│ ├── Chatbot/ -│ │ ├── CloudZenChatbot.razor # AI chatbot widget (FAB + chat panel) -│ │ └── CloudZenChatbot.razor.css # Scoped dark theme styles -│ │ -│ ├── Profile/ -│ │ ├── ProfileHeader.razor # Profile avatar, name, social links -│ │ ├── ProfileApproach.razor # Professional approach section -│ │ └── ProfileHighlights.razor # Results, expertise, resume button -│ │ -│ ├── Projects/ -│ │ └── ProjectCard.razor # Individual project display card -│ │ -│ ├── WhoIAm.razor # Main page (orchestrator) -│ └── ... (other shared components) -│ -└── Program.cs # Service registration -``` - ---- - -## 🧩 Component Breakdown - -### 1. **WhoIAm.razor** (Page Component) -**Role**: Page orchestrator - composes and coordinates child components - -**Responsibilities**: -- Page routing (`@page "/whoiam"`) -- Component composition and layout -- Data fetching (Projects list) -- Event handling delegation -- Scroll behavior logic - -**Dependencies**: -- `ProfileHeader` - Displays profile information -- `ProfileApproach` - Shows professional methodology -- `ProfileHighlights` - Displays achievements and expertise -- `ProjectCard` - Renders individual project cards -- `ProjectService` - ✅ **Active** - Data access layer for all projects -- `ResumeService` - Resume download functionality - -**Lines of Code**: **73 lines** (down from ~700, **-90% reduction**) - ---- - -### 2. **Profile Components** - -#### **ProfileHeader.razor** -**Purpose**: Display user profile header with avatar, name, and social links - -**Parameters**: -- `AvatarUrl` (string) - URL to profile image -- `AltText` (string) - Image accessibility text -- `Title` (string) - Section heading -- `NameHighlight` (string) - Highlighted name portion -- `RoleDescription` (string) - Short role summary -- `DetailedDescription` (string) - Full professional bio -- `LinkedInUrl` (string?) - LinkedIn profile link (optional) -- `GitHubUrl` (string?) - GitHub profile link (optional) - -**Styling**: Tailwind CSS - responsive design with centered layout - -**Reusability**: Can be used in About, Contact, or other profile pages - ---- - -#### **ProfileApproach.razor** -**Purpose**: Display professional approach and methodology - -**Parameters**: None (currently static content) - -**Future Enhancements**: -- Accept content as parameters for flexibility -- Support markdown rendering - ---- - -#### **ProfileHighlights.razor** -**Purpose**: Display key achievements, expertise, and resume download - -**Parameters**: -- `OnResumeDownload` (EventCallback) - Triggered when resume button is clicked - -**Features**: -- Bullet-pointed key results list -- Tech stack badges display -- Resume download button with event callback - -**Parent Responsibility**: Parent component (WhoIAm) must handle the resume download logic - ---- - -### 3. **Project Components** - -#### **ProjectCard.razor** -**Purpose**: Display individual project information in a card format - -**Parameters**: -- `Project` (ProjectInfo, required) - Complete project data - -**Features**: -- Status badge with color coding -- Role display with icon -- Project type indicator (Side Project / Customer work) -- Participant avatars -- Tech stack tags -- GitHub link (conditional) -- Challenges list -- Outcomes/Results list -- Progress bar with color coding - -**Helper Methods**: -- `GetStatusColor(string status)` - Returns CSS classes for status badge -- `GetProgressColor(int progress)` - Returns CSS classes for progress bar - -**Styling**: Card-based layout with responsive design - ---- - -## 🔄 **Component Interaction: ProjectFilter ↔ WhoIAm** - -### **Communication Pattern** -Child-to-Parent via `EventCallback` - Blazor's standard type-safe event handling - -### **Flow Diagram** - -``` -┌──────────────────────────────────────────────────────────────┐ -│ User Action (ProjectFilter) │ -│ • Dropdown selection changes (Status/Type) │ -│ • Clear All button clicked │ -│ • Individual filter badge removed │ -└──────────────────────────────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────────┐ -│ Status/Type Selection Changed (@bind) │ -│ SelectedStatus or SelectedProjectType updated │ -└──────────────────────────────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────────┐ -│ OnFilterChanged() called (@bind:after trigger) │ -│ private async Task OnFilterChanged() │ -└──────────────────────────────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────────┐ -│ OnFilterChange.InvokeAsync((Status, Type)) [Child→Parent] │ -│ await OnFilterChange.InvokeAsync( │ -│ (SelectedStatus, SelectedProjectType)); │ -└──────────────────────────────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────────┐ -│ HandleFilterChange((Status, Type)) invoked (WhoIAm) │ -│ Parent receives tuple with current filter values │ -└──────────────────────────────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────────┐ -│ FilteredProjects = Projects.Where(...) │ -│ LINQ filtering applied: │ -│ • Filter by Status (if not empty) │ -│ • Filter by ProjectType (if not empty) │ -│ • Update FilteredProjects list │ -└──────────────────────────────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────────┐ -│ StateHasChanged() (implicit) │ -│ Blazor detects component state change automatically │ -└──────────────────────────────────────────────────────────────┘ - ↓ -┌──────────────────────────────────────────────────────────────┐ -│ UI Re-renders with Filtered Projects │ -│ • ProjectCard components render with FilteredProjects │ -│ • Empty state shown if no matches │ -│ • Smooth transition with filtered results │ -└──────────────────────────────────────────────────────────────┘ -``` - -### **Execution Steps** - -| Step | Component | Action | -|------|-----------|--------| -| **1** | WhoIAm (Parent) | Passes `HandleFilterChange` method to child's `OnFilterChange` parameter | -| **2** | ProjectFilter (Child) | User changes dropdown/clicks button → triggers `OnFilterChanged()` | -| **3** | ProjectFilter (Child) | Invokes parent callback: `OnFilterChange.InvokeAsync((Status, Type))` | -| **4** | WhoIAm (Parent) | Receives tuple, applies LINQ filtering, updates `FilteredProjects` | -| **5** | Blazor Framework | Detects state change, re-renders ProjectCard components with filtered data | - -### **Code Implementation** - -**Parent (WhoIAm.razor)** -```razor - - -@code { - private List FilteredProjects = new(); - - private void HandleFilterChange((string Status, string ProjectType) filters) - { - FilteredProjects = Projects - .Where(p => string.IsNullOrEmpty(filters.Status) || p.Status == filters.Status) - .Where(p => string.IsNullOrEmpty(filters.ProjectType) || - (filters.ProjectType == "Customer" - ? p.ProjectType.StartsWith("Customer:") - : p.ProjectType == filters.ProjectType)) - .ToList(); - } -} -``` - -**Child (ProjectFilter.razor)** -```razor -@code { - [Parameter] - public EventCallback<(string Status, string ProjectType)> OnFilterChange { get; set; } - - private async Task OnFilterChanged() - { - await OnFilterChange.InvokeAsync((SelectedStatus, SelectedProjectType)); - } -} -``` - -### **Key Benefits** - -✅ **Type Safety**: Compile-time checking via tuple `(string, string)` -✅ **Async Support**: Native async/await compatibility -✅ **Loose Coupling**: Child doesn't know parent's implementation -✅ **Blazor Optimized**: Efficient automatic re-rendering -✅ **Reusability**: ProjectFilter can be used with any parent component - ---- - -## 📊 Data Models - -### **ProjectInfo.cs** -Represents a complete project in the portfolio. - -**Properties**: -```csharp -public class ProjectInfo -{ - public string Name { get; set; } // Project name - public string Status { get; set; } // "Completed", "In Progress", "Planning" - public string Description { get; set; } // Full description - public string[] TechStack { get; set; } // Technologies used - public int Progress { get; set; } // 0-100 - public List Results { get; set; } // Measurable outcomes - public IEnumerable Participants { get; set; } // Contributors - public string Role { get; set; } // Your role - public List Challenges { get; set; } // Main challenges - public string? GithubUrl { get; set; } // Optional GitHub link - public string ProjectType { get; set; } // "Side Project" / "Customer: {Name}" -} -``` - ---- - -### **ProjectParticipant.cs** -Represents a project contributor. - -**Properties**: -```csharp -public class ProjectParticipant -{ - public string Name { get; set; } // Participant name - public string ImageUrl { get; set; } // Avatar URL -} -``` - ---- - -## 🔧 Services - -#### Service Layer Overview: Same approach should be applied to other services (e.g., ResumeService, PersonalService) - -### **ProjectService.cs** -Centralized service for project data management. - -**Methods**: -- `GetAllProjects()` - Returns all projects sorted by status -- `GetProjectsByStatus(string status)` - Filters by status -- `GetProjectsByType(string projectType)` - Filters by type -- `GetFeaturedProjects()` - Returns top completed projects - -**Future Enhancements**: -- Load from JSON file (`wwwroot/data/projects.json`) -- Fetch from API endpoint -- Cache projects for performance -- Support pagination/filtering - -**Registration** (Program.cs): -```csharp -builder.Services.AddScoped(); -``` - ---- - -## 🎨 Styling & Design System - -### **Color Palette** -- **Primary**: Indigo (`indigo-600`, `indigo-700`, `indigo-800`) -- **Success**: Green (`green-100`, `green-600`, `green-800`) -- **Warning**: Amber (`amber-300`, `amber-500`, `amber-600`) -- **Error**: Red (`red-200`, `red-500`, `red-700`) -- **Neutral**: Gray (`gray-100` through `gray-900`) - -### **Status Colors** -- ✅ **Completed**: Green background (`bg-green-100 text-green-800`) -- 🔄 **In Progress**: Amber background (`bg-amber-300 text-yellow-800`) -- 📋 **Planning**: Red background (`bg-red-200 text-red-700`) - -### **Progress Bar Colors** -- 100%: Blue (`bg-blue-400`) -- 70-99%: Emerald (`bg-emerald-600`) -- 40-69%: Yellow (`bg-yellow-400`) -- <40%: Red (`bg-red-500`) - ---- - -## 🚀 Usage Examples - -### **Using ProjectCard in WhoIAm.razor** -```razor -@foreach (var project in Projects) -{ - -} -``` - -### **Using ProfileHeader** -```razor - -``` - -### **Using ProfileHighlights with Event Callback** -```razor - - -@code { - private async Task DownloadResume() - { - // Handle resume download logic - } -} -``` - ---- - -## 📈 Performance Metrics - -### **Before Refactoring** -- **WhoIAm.razor**: ~700 lines -- **Components**: 0 reusable components -- **Data Models**: Inline in @code block -- **Services**: No service layer -- **Testability**: Low (tightly coupled) - -### **After Refactoring** -- **WhoIAm.razor**: **73 lines (-90%)** ✅ -- **Components**: **4 reusable components** ✅ -- **Data Models**: **2 separate model files** ✅ -- **Services**: **1 dedicated service layer (ProjectService)** ✅ -- **Testability**: **High (loosely coupled)** ✅ - -### **Component Sizes** -- **ProfileHeader**: 81 lines -- **ProfileApproach**: 35 lines -- **ProfileHighlights**: 75 lines -- **ProjectCard**: 139 lines -- **ProjectService**: 363 lines - -### **Refactoring Journey** -| Phase | Action | Lines Before | Lines After | Reduction | -|-------|--------|--------------|-------------|-----------| -| **Initial** | Starting point | 700 | 700 | 0% | -| **Phase 1** | Extracted ProjectCard | 700 | 521 | -25% | -| **Phase 2A** | Created Profile Components | 521 | 521 | 0% | -| **Service Layer** | Moved data to ProjectService | 521 | 104 | -80% | -| **Final** | Integrated all components | 104 | **73** | **-90%** | - -### **Total Impact** -- **Lines Removed**: 627 lines (-90%) -- **New Components Created**: 4 -- **Service Classes Added**: 1 -- **Model Classes Extracted**: 2 -- **Build Status**: ✅ Success -- **Breaking Changes**: None - ---- - -## ✅ Benefits Achieved - -### **1. Maintainability** -- ✅ Each component has a single responsibility -- ✅ Bugs isolated to specific components -- ✅ Easier code navigation - -### **2. Reusability** -- ✅ ProjectCard usable in dedicated Projects page -- ✅ ProfileHeader reusable across multiple pages -- ✅ Components shareable across projects - -### **3. Testability** -- ✅ Unit test individual components -- ✅ Mock dependencies easily -- ✅ Test component interactions - -### **4. Scalability** -- ✅ Easy to add new project fields -- ✅ Simple to extend ProjectService -- ✅ Component composition supports growth - -### **5. Developer Experience** -- ✅ Smaller files reduce cognitive load -- ✅ Clear component boundaries -- ✅ Better IntelliSense support - ---- - -## 🔮 Future Enhancements - -### **Phase 3: Data Externalization** -1. Move project data to `wwwroot/data/projects.json` -2. Implement async data loading in ProjectService -3. Add caching layer for performance - -### **Phase 4: Advanced Features** -1. **Search/Filter**: - - Filter projects by tech stack - - Search by project name/description - - Filter by date range - -2. **Animations**: - - Card hover effects - - Progress bar animations - - Smooth scrolling - -3. **Accessibility**: - - ARIA labels for all interactive elements - - Keyboard navigation support - - Screen reader optimization - -4. **Micro-Components** (Optional): - - `ProjectStatusBadge.razor` - Reusable status indicator - - `ProjectProgressBar.razor` - Standalone progress visualization - - `TechStackBadge.razor` - Individual technology tag - ---- - -## 🛠️ Development Guidelines - -### **Component Creation Checklist** -- [ ] Single responsibility principle -- [ ] XML documentation for public members -- [ ] Parameter validation -- [ ] Responsive design (mobile-first) -- [ ] Accessibility considerations -- [ ] Event callbacks for parent communication - -### **Naming Conventions** -- **Components**: PascalCase (e.g., `ProfileHeader.razor`) -- **Parameters**: PascalCase (e.g., `AvatarUrl`) -- **Methods**: PascalCase (e.g., `GetStatusColor`) -- **CSS Classes**: kebab-case Tailwind utilities - -### **Component Communication** -- **Parent → Child**: Use `[Parameter]` properties -- **Child → Parent**: Use `EventCallback` or `EventCallback` -- **Sibling Communication**: Use shared state service - ---- - -## 📚 References & Resources - -### **Official Documentation** -- [Blazor Component Documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/components) -- [Blazor Component Parameters](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/data-binding) -- [Blazor Event Handling](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling) - -### **Best Practices** -- [Component-Based Architecture](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/component-lifecycle) -- [Blazor Performance Best Practices](https://learn.microsoft.com/en-us/aspnet/core/blazor/performance) - ---- - ---- - -## 📊 **Final Architecture Summary** - -### **Complete Refactoring Results** - -#### **WhoIAm.razor Transformation** -``` -Initial State (Version 0): -├── 700 lines of monolithic code -├── Inline project data -├── Mixed concerns (data + presentation) -└── No reusable components - -Final State (Version 1.1): -├── 73 lines of orchestration code (-90%) -├── 4 reusable components -├── Centralized data service (ProjectService) -└── Clean separation of concerns -``` - -#### **Component Architecture** -``` -CloudZen Application -│ -├── Pages/ -│ └── WhoIAm.razor (73 lines) -│ ├── Uses: ProfileHeader -│ ├── Uses: ProfileApproach -│ ├── Uses: ProfileHighlights -│ ├── Uses: ProjectCard (x9 projects) -│ ├── Injects: ProjectService -│ └── Injects: ResumeService -│ -├── Components/ -│ ├── Shared/Chatbot/ -│ │ ├── CloudZenChatbot.razor # AI chatbot widget -│ │ └── CloudZenChatbot.razor.css # Scoped dark theme -│ │ -│ ├── Shared/Profile/ -│ │ ├── ProfileHeader.razor (81 lines) -│ │ ├── ProfileApproach.razor (35 lines) -│ │ └── ProfileHighlights.razor (75 lines) -│ │ -│ └── Shared/Projects/ -│ └── ProjectCard.razor (139 lines) -│ -├── Services/ -│ ├── ProjectService.cs (363 lines) -│ │ ├── GetAllProjects() -│ │ ├── GetProjectsByStatus() -│ │ ├── GetProjectsByType() -│ │ └── GetFeaturedProjects() -│ │ -│ ├── ChatbotService.cs -│ │ └── SendMessageAsync() → POST /api/chat -│ │ -│ └── ResumeService.cs -│ -└── Models/ - ├── ProjectInfo.cs (74 lines) - └── ProjectParticipant.cs (19 lines) -``` - -#### **Key Achievements** -- ✅ **90% code reduction** in main page component -- ✅ **4 reusable components** created -- ✅ **100% separation** of data and presentation -- ✅ **9 projects** managed through service layer -- ✅ **Zero breaking changes** during refactoring -- ✅ **Full build success** maintained throughout - -#### **Code Quality Improvements** -| Metric | Before | After | Improvement | -|--------|--------|-------|-------------| -| **Cyclomatic Complexity** | High | Low | ✅ | -| **Code Duplication** | ~100 lines | 0 lines | ✅ | -| **Testability Score** | Low | High | ✅ | -| **Maintainability Index** | 45 | 85 | ✅ | -| **Component Cohesion** | Low | High | ✅ | -| **Coupling** | Tight | Loose | ✅ | - ---- - -## 🎓 **Lessons Learned** - -### **What Worked Well** -1. **Incremental Refactoring** - Breaking changes into phases reduced risk -2. **Component Extraction** - Starting with ProjectCard established patterns -3. **Service Layer** - Centralizing data improved maintainability significantly -4. **Build Verification** - Running builds after each change caught issues early -5. **Documentation** - Maintaining COMPONENT_ARCHITECTURE.md kept team aligned - -### **Best Practices Applied** -1. ✅ Single Responsibility Principle (SRP) -2. ✅ Don't Repeat Yourself (DRY) -3. ✅ Separation of Concerns (SoC) -4. ✅ Component-Based Architecture -5. ✅ Service-Oriented Design -6. ✅ Parameter-Based Component Communication -7. ✅ EventCallback for child-to-parent communication - -### **Future Recommendations** -1. **Add Unit Tests** - Test components and services independently -2. **Implement Caching** - Cache projects in ProjectService for performance -3. **Add Loading States** - Show spinners while loading data -4. **Error Handling** - Add try-catch blocks and error boundaries -5. **Accessibility** - Add ARIA labels and keyboard navigation -6. **Analytics** - Track component usage and performance metrics - ---- - -## 🔄 **Migration Guide** - -### **For Developers Joining the Project** - -#### **Understanding the Architecture** -1. **Read this document** - Understand component structure -2. **Review WhoIAm.razor** - See how components are orchestrated -3. **Examine ProjectService** - Learn data management patterns -4. **Check component parameters** - Understand data flow - -#### **Adding New Projects** -```csharp -// In Services/ProjectService.cs - GetProjectsData() method -new ProjectInfo -{ - Name = "Your Project Name", - Status = "Completed", // or "In Progress", "Planning" - Description = "Project description...", - TechStack = new[] { ".NET 8", "Blazor", "Azure" }, - Progress = 100, - Results = new List { "Achievement 1", "Achievement 2" }, - Participants = new[] { - new ProjectParticipant { - Name = "Developer Name", - ImageUrl = "/images/avatar.png" - } - }, - Role = "Your Role", - Challenges = new List { "Challenge 1", "Challenge 2" }, - GithubUrl = "https://github.com/...", - ProjectType = "Side Project" // or "Customer: Name" -} -``` - -#### **Creating New Components** -1. **Follow naming conventions**: PascalCase for components -2. **Add XML documentation**: Document all public members -3. **Use parameters**: Accept data via `[Parameter]` properties -4. **Add event callbacks**: For parent communication -5. **Apply responsive design**: Mobile-first approach -6. **Test thoroughly**: Verify in different screen sizes - ---- - -## 📞 **Support & Contribution** - -### **Getting Help** -- **Architecture Questions**: Review this document first -- **Component Issues**: Check component documentation sections -- **Service Layer**: See ProjectService.cs inline comments -- **Build Problems**: Ensure all dependencies are restored - -### **Contributing** -1. Follow existing patterns and conventions -2. Add/update documentation for changes -3. Run builds before committing -4. Keep components small and focused -5. Write meaningful commit messages - ---- - -## ✅ **Verification Checklist** - -### **Post-Refactoring Verification** -- [x] All builds succeed -- [x] No compilation errors -- [x] No runtime exceptions -- [x] UI renders correctly -- [x] All features work as expected -- [x] No broken links -- [x] Responsive design maintained -- [x] Accessibility preserved -- [x] Performance not degraded -- [x] Documentation updated - -### **ProjectFilter Component Verification** -- [x] Status filter dropdown works correctly -- [x] Project type filter dropdown works correctly -- [x] Filters can be combined (status + type) -- [x] Clear all button resets both filters -- [x] Individual filter remove buttons work -- [x] Active filter counter updates correctly -- [x] Empty state displays when no matches -- [x] Responsive layout on mobile/desktop -- [x] All animations and transitions smooth -- [x] Icons display correctly in dropdowns - ---- - ---- - -## 📝 Change Log - -### **Version 1.2** (Current - March 2026) -- ✅ Added AI Chatbot component (`CloudZenChatbot.razor`) -- ✅ Added chatbot client service (`ChatbotService.cs`, `IChatbotService.cs`) -- ✅ Added chatbot configuration model (`ChatbotOptions.cs`) -- ✅ Added AI chatbot backend (`ChatFunction.cs`, `ChatRequest.cs`, `ChatResponse.cs`) -- ✅ Integrated chatbot widget into main layout -- ✅ See [AI_CHATBOT_DOCUMENTATION.md](AI_CHATBOT_DOCUMENTATION.md) for full chatbot architecture - -### **Version 1.1** (December 2025) -- ✅ Extracted ProjectCard component -- ✅ Created Profile components (Header, Approach, Highlights) -- ✅ Moved data models to Models folder -- ✅ Created ProjectService with full CRUD methods -- ✅ Integrated all components into WhoIAm.razor -- ✅ Moved all project data to ProjectService -- ✅ Reduced WhoIAm.razor from 700 to 73 lines (-90%) -- ✅ Comprehensive architecture documentation - -### **Planned for Version 1.2** -- [ ] Externalize project data to JSON file -- [ ] Add search/filter functionality to projects -- [ ] Implement caching in ProjectService -- [ ] Add unit tests for components and services -- [ ] Add async data loading support -- [ ] Implement error boundaries -- [ ] Add loading states and spinners - ---- - -## 👥 Contributors - -- **Dariem C. Macias** - Principal Consultant / Solution Architect -- **Refactoring Assistance**: GitHub Copilot - ---- - -## 📄 License - -This architecture is part of the CloudZen Inc. portfolio application. - ---- - -**Last Updated**: March 2026 -**Document Version**: 1.3 -**Maintained By**: CloudZen Development Team diff --git a/CONFIGURATION_BEST_PRACTICES.md b/CONFIGURATION_BEST_PRACTICES.md deleted file mode 100644 index b85630f..0000000 --- a/CONFIGURATION_BEST_PRACTICES.md +++ /dev/null @@ -1,1117 +0,0 @@ -# Configuration Management Best Practices with IOptions Pattern - -This document provides guidance on managing configuration in .NET applications using the **IOptions pattern**, with specific sections for **Blazor WebAssembly** and **Azure Functions** architectures. - -## Table of Contents - -### Part 1: Overview -1. [Introduction](#introduction) -2. [IOptions Pattern Variants](#ioptions-pattern-variants) -3. [Consistent Pattern Across Solution](#consistent-pattern-across-solution) - -### Part 2: Blazor WebAssembly Configuration -4. [WASM Configuration Overview](#wasm-configuration-overview) -5. [WASM Options Classes](#wasm-options-classes) -6. [WASM Program.cs Setup](#wasm-programcs-setup) -7. [WASM Configuration Files](#wasm-configuration-files) -8. [WASM Security Considerations](#wasm-security-considerations) - -### Part 3: Azure Functions Configuration -9. [Azure Functions Configuration Overview](#azure-functions-configuration-overview) -10. [Azure Functions Options Classes](#azure-functions-options-classes) -11. [Azure Functions Program.cs Setup](#azure-functions-programcs-setup) -12. [Azure Functions Configuration Files](#azure-functions-configuration-files) -13. [Azure Functions Secrets Management](#azure-functions-secrets-management) - -### Part 4: Advanced Topics -14. [Configuration Validation](#configuration-validation) -15. [Testing with IOptions](#testing-with-ioptions) -16. [Migration Guide](#migration-guide) - ---- - -# Part 1: Overview - -## Introduction - -### What is the IOptions Pattern? - -The IOptions pattern provides a **strongly-typed** way to access groups of related configuration settings. Instead of accessing configuration values through string keys, you define classes that represent your configuration sections. - -### Benefits - -| Benefit | Description | -|---------|-------------| -| **Type Safety** | Compile-time checking of configuration access | -| **IntelliSense** | Full IDE support with auto-completion | -| **Validation** | Built-in support for validating configuration on startup | -| **Testability** | Easy to mock in unit tests | -| **Reloadable** | Support for configuration changes without restart (IOptionsMonitor) | -| **Documentation** | Self-documenting through property names and XML comments | - -### Pattern Variants - -``` -IOptions → Singleton, read once at startup -IOptionsSnapshot → Scoped, re-read per request (not for WASM) -IOptionsMonitor → Singleton with change notifications -``` - ---- - -## IOptions Pattern Variants - -### IOptions (Recommended for Both Projects) - -- **Lifetime**: Singleton - value is computed once and cached -- **When to use**: Configuration that doesn't change during app lifetime -- **Best for**: Blazor WebAssembly, most Azure Functions scenarios - -```csharp -public class MyService -{ - private readonly MyOptions _options; - - public MyService(IOptions options) - { - _options = options.Value; // Read once, cached - } -} -``` - -### IOptionsSnapshot (Server-side only) - -- **Lifetime**: Scoped - new instance per request -- **When to use**: Configuration that may change between requests -- **Note**: ⚠️ **Not available in Blazor WebAssembly** (no request scope) - -```csharp -// Azure Functions or ASP.NET Core only -public class MyFunction -{ - private readonly MyOptions _options; - - public MyFunction(IOptionsSnapshot options) - { - _options = options.Value; // Fresh value per request - } -} -``` - -### IOptionsMonitor - -- **Lifetime**: Singleton with change tracking -- **When to use**: Long-running services that need to react to config changes -- **Best for**: Background services, Azure Functions with dynamic config - -```csharp -public class MyBackgroundService : BackgroundService -{ - private readonly IOptionsMonitor _optionsMonitor; - - public MyBackgroundService(IOptionsMonitor optionsMonitor) - { - _optionsMonitor = optionsMonitor; - - // React to configuration changes - _optionsMonitor.OnChange(options => - { - Console.WriteLine($"Config changed: {options.SomeValue}"); - }); - } -} -``` - ---- - -## Consistent Pattern Across Solution - -### CloudZen Solution Uses `AddOptions().BindConfiguration()` - -Both projects use the **same registration pattern** for consistency: - -```csharp -// Works in BOTH Blazor WASM and Azure Functions -builder.Services.AddOptions() - .BindConfiguration(MyOptions.SectionName); -``` - -| Project | Pattern | Package Required | -|---------|---------|------------------| -| **CloudZen (Blazor WASM)** | `AddOptions().BindConfiguration()` | `Microsoft.Extensions.Options.ConfigurationExtensions` 8.0.0 | -| **CloudZen.Api (Azure Functions)** | `AddOptions().BindConfiguration()` | `Microsoft.Extensions.Options.ConfigurationExtensions` 10.0.0 | - -### Why Use `BindConfiguration()` Over `Configure()`? - -| Feature | `BindConfiguration()` | `Configure(section)` | -|---------|----------------------|------------------------| -| **Syntax** | Cleaner, chainable | Requires section parameter | -| **Validation** | Chainable with `.ValidateDataAnnotations()` | Separate registration | -| **Consistency** | Works same in WASM and server | Different overloads in WASM | -| **Discoverability** | Better IntelliSense | OK | - ---- - -# Part 2: Blazor WebAssembly Configuration - -## WASM Configuration Overview - -### Key Characteristics - -| Aspect | Description | -|--------|-------------| -| **Runtime** | Runs in browser (client-side) | -| **Config Location** | `wwwroot/appsettings.json` | -| **Security** | ⚠️ All configuration is PUBLIC | -| **Secrets** | ❌ NEVER store secrets here | -| **Format** | Standard JSON hierarchy | - -### Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Blazor WebAssembly │ -│ (Browser Runtime) │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ wwwroot/appsettings.json │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ ✅ API endpoints (e.g., "/api") │ │ -│ │ ✅ Timeouts, retry counts │ │ -│ │ ✅ Feature flags │ │ -│ │ ✅ SAS token URLs (read-only blob access) │ │ -│ │ ❌ API keys (NEVER!) │ │ -│ │ ❌ Connection strings (NEVER!) │ │ -│ │ ❌ Passwords (NEVER!) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ IOptions Registration │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ EmailServiceOptions → API base URL, timeouts │ │ -│ │ BlobStorageOptions → SAS token URLs │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -## WASM Options Classes - -### EmailServiceOptions - -**File: `Models/Options/EmailServiceOptions.cs`** - -```csharp -namespace CloudZen.Models.Options; - -/// -/// Configuration options for the email service client. -/// -/// -/// -/// This class is used with the IOptions pattern to configure the -/// . Settings are configured in -/// wwwroot/appsettings.json under the EmailService section. -/// -/// -/// Important: In Blazor WebAssembly, do NOT store sensitive values here. -/// API keys should only exist in the Azure Functions backend. -/// -/// -public class EmailServiceOptions -{ - /// - /// The configuration section name for binding. - /// - public const string SectionName = "EmailService"; - - /// - /// Gets or sets the base URL for the email API backend. - /// - /// Defaults to "/api" for Azure Static Web Apps linked functions. - public string ApiBaseUrl { get; set; } = "/api"; - - /// - /// Gets or sets the HTTP request timeout in seconds. - /// - public int TimeoutSeconds { get; set; } = 30; - - /// - /// Gets or sets the maximum number of retry attempts. - /// - public int MaxRetries { get; set; } = 3; - - /// - /// Gets or sets the email endpoint path. - /// - public string SendEmailEndpoint { get; set; } = "send-email"; - - /// - /// Gets the full URL for the send email endpoint. - /// - public string SendEmailUrl => $"{ApiBaseUrl.TrimEnd('/')}/{SendEmailEndpoint}"; -} -``` - -### BlobStorageOptions - -**File: `Models/Options/BlobStorageOptions.cs`** - -```csharp -namespace CloudZen.Models.Options; - -/// -/// Configuration options for Azure Blob Storage access. -/// -/// -/// Security Note: Only SAS token URLs should be stored here. -/// Never store connection strings or account keys in client-side configuration. -/// -public class BlobStorageOptions -{ - /// - /// The configuration section name for binding. - /// - public const string SectionName = "BlobStorage"; - - /// - /// Gets or sets the full URL (with SAS token) for the resume PDF. - /// - public string ResumeUrl { get; set; } = string.Empty; - - /// - /// Gets or sets the blob container name. - /// - public string ContainerName { get; set; } = "documents"; - - /// - /// Gets or sets the storage account name (for logging only). - /// - public string? StorageAccountName { get; set; } -} -``` - ---- - -## WASM Program.cs Setup - -**File: `Program.cs`** - -```csharp -using CloudZen; -using CloudZen.Models.Options; -using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.Components.WebAssembly.Hosting; -using CloudZen.Services; -using CloudZen.Services.Abstractions; - -var builder = WebAssemblyHostBuilder.CreateDefault(args); -builder.RootComponents.Add("#app"); -builder.RootComponents.Add("head::after"); - -// ============================================================================= -// IOPTIONS PATTERN CONFIGURATION (Blazor WebAssembly) -// ============================================================================= -// Using AddOptions().BindConfiguration() for consistency with Azure Functions -// Requires: Microsoft.Extensions.Options.ConfigurationExtensions package -// ============================================================================= - -// Configure Email Service options -// Section: "EmailService" in wwwroot/appsettings.json -builder.Services.AddOptions() - .BindConfiguration(EmailServiceOptions.SectionName); - -// Configure Blob Storage options -// Section: "BlobStorage" in wwwroot/appsettings.json -builder.Services.AddOptions() - .BindConfiguration(BlobStorageOptions.SectionName); - -// ============================================================================= -// HTTP CLIENT -// ============================================================================= - -builder.Services.AddScoped(sp => new HttpClient -{ - BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) -}); - -// ============================================================================= -// SERVICE REGISTRATIONS -// ============================================================================= - -// Email service using IOptions -builder.Services.AddScoped(); - -// Other services... -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -await builder.Build().RunAsync(); -``` - ---- - -## WASM Configuration Files - -### File Structure - -``` -wwwroot/ -├── appsettings.json # Base configuration (committed to Git) -├── appsettings.Development.json # Development overrides (git-ignored) -└── appsettings.Production.json # Production overrides (optional) -``` - -### appsettings.json (Base - Committed) - -**File: `wwwroot/appsettings.json`** - -```json -{ - "EmailService": { - "ApiBaseUrl": "/api", - "TimeoutSeconds": 30, - "MaxRetries": 3, - "SendEmailEndpoint": "send-email" - }, - - "BlobStorage": { - "ResumeUrl": "https://cloudzenstorage.blob.core.windows.net/container/resume.pdf?sv=...", - "ContainerName": "cloudzencontainer", - "StorageAccountName": "cloudzenstorage" - }, - - "EmailSettings": { - "Provider": "Brevo", - "FromEmail": "cloudzen.inc@gmail.com", - "CcEmail": "softevolutionsl@gmail.com" - } -} -``` - -### appsettings.Development.json (Local - Git-ignored) - -**File: `wwwroot/appsettings.Development.json`** - -```json -{ - "EmailService": { - "ApiBaseUrl": "http://localhost:7071/api", - "TimeoutSeconds": 60 - } -} -``` - -### .gitignore Entries - -```gitignore -# Environment-specific config files -**/wwwroot/appsettings.Development.json -**/wwwroot/appsettings.*.json -!**/wwwroot/appsettings.json -``` - ---- - -## WASM Security Considerations - -### ⚠️ Critical: Everything is PUBLIC - -``` -┌─────────────────────────────────────────────────────────────┐ -│ ⚠️ BLAZOR WEBASSEMBLY SECURITY WARNING │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ Everything in wwwroot/ is downloadable by anyone! │ -│ │ -│ Browser DevTools → Network → appsettings.json │ -│ Result: ALL configuration is visible to users │ -│ │ -│ ❌ NEVER include: │ -│ • API keys │ -│ • Connection strings │ -│ • Passwords │ -│ • Private endpoints │ -│ • Tokens (except SAS with limited scope) │ -│ │ -│ ✅ SAFE to include: │ -│ • Public API endpoints │ -│ • Timeouts and retry settings │ -│ • Feature flags │ -│ • Read-only SAS URLs (limited expiry) │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### What Can/Cannot Be Done in WASM - -| ❌ Cannot Do | ✅ Can Do | -|-------------|----------| -| Access Azure Key Vault directly | Call backend APIs that access Key Vault | -| Use connection strings | Use SAS tokens for Blob Storage | -| Store API keys in config | Store non-sensitive settings | -| Use DefaultAzureCredential | Use public endpoints with SAS | -| Send emails directly | Call Azure Function to send emails | - ---- - -# Part 3: Azure Functions Configuration - -## Azure Functions Configuration Overview - -### Key Characteristics - -| Aspect | Description | -|--------|-------------| -| **Runtime** | Server-side (Azure or local) | -| **Config Location** | `local.settings.json` + Environment Variables | -| **Security** | ✅ Can store secrets securely | -| **Secrets** | ✅ Use Key Vault or App Settings | -| **Format** | Flat key-value in `Values` section | - -### Architecture - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Azure Functions │ -│ (Server Runtime) │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ Configuration Sources (Priority Order): │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ 1. local.settings.json (local development) │ │ -│ │ 2. Environment Variables (Azure App Settings) │ │ -│ │ 3. Azure Key Vault (secrets) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ IOptions Registration │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ RateLimitOptions → Rate limiting configuration │ │ -│ │ EmailSettings → Email addresses (not keys!) │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -│ Direct Configuration Access (for secrets) │ -│ ┌─────────────────────────────────────────────────────┐ │ -│ │ IConfiguration["BREVO_API_KEY"] │ │ -│ │ IConfiguration["KEY_VAULT_ENDPOINT"] │ │ -│ └─────────────────────────────────────────────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - ---- - -## Azure Functions Options Classes - -### RateLimitOptions - -**File: `Api/Models/RateLimitOptions.cs`** - -```csharp -namespace CloudZen.Api.Models; - -/// -/// Configuration options for rate limiting and resilience policies. -/// -public class RateLimitOptions -{ - /// - /// The configuration section name for binding. - /// - public const string SectionName = "RateLimiting"; - - /// - /// Gets or sets the number of requests allowed per time window. - /// - public int PermitLimit { get; set; } = 10; - - /// - /// Gets or sets the time window duration in seconds. - /// - public int WindowSeconds { get; set; } = 60; - - /// - /// Gets or sets the maximum queued requests. - /// - public int QueueLimit { get; set; } = 0; - - /// - /// Gets or sets the inactivity timeout in minutes. - /// - public int InactivityTimeoutMinutes { get; set; } = 5; - - /// - /// Gets or sets whether circuit breaker is enabled. - /// - public bool EnableCircuitBreaker { get; set; } = false; - - /// - /// Gets or sets the circuit breaker failure threshold. - /// - public int CircuitBreakerFailureThreshold { get; set; } = 5; - - /// - /// Gets or sets the circuit breaker duration in seconds. - /// - public int CircuitBreakerDurationSeconds { get; set; } = 30; -} -``` - -### EmailSettings - -**File: `Api/Models/EmailSettings.cs`** - -```csharp -namespace CloudZen.Api.Models; - -/// -/// Configuration options for email sending functionality. -/// -/// -/// Note: API keys should NOT be stored in this options class. -/// Use IConfiguration directly for secrets from Key Vault or environment variables. -/// -public class EmailSettings -{ - /// - /// The configuration section name for binding. - /// - public const string SectionName = "EmailSettings"; - - /// - /// Gets or sets the sender email address. - /// - public string FromEmail { get; set; } = "cloudzen.inc@gmail.com"; - - /// - /// Gets or sets the CC email address. - /// - public string? CcEmail { get; set; } - - /// - /// Gets or sets the recipient email address. - /// - public string? ToEmail { get; set; } - - /// - /// Gets or sets the sender display name. - /// - public string FromName { get; set; } = "CloudZen Contact"; -} -``` - ---- - -## Azure Functions Program.cs Setup - -**File: `Api/Program.cs`** - -```csharp -using Azure.Identity; -using CloudZen.Api.Models; -using CloudZen.Api.Security; -using CloudZen.Api.Services; -using Microsoft.Azure.Functions.Worker; -using Microsoft.Azure.Functions.Worker.Builder; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -var builder = FunctionsApplication.CreateBuilder(args); - -// ============================================================================= -// CONFIGURATION SOURCES -// ============================================================================= -// Priority order (last wins): -// 1. local.settings.json (local development) -// 2. Environment variables (Azure App Settings in production) -// 3. Azure Key Vault (secrets, if KEY_VAULT_ENDPOINT is set) -// ============================================================================= - -builder.Configuration - .AddJsonFile("local.settings.json", optional: true, reloadOnChange: true) - .AddEnvironmentVariables(); - -// Add Azure Key Vault for secrets management -var keyVaultEndpoint = Environment.GetEnvironmentVariable("KEY_VAULT_ENDPOINT"); -if (!string.IsNullOrEmpty(keyVaultEndpoint)) -{ - builder.Configuration.AddAzureKeyVault( - new Uri(keyVaultEndpoint), - new DefaultAzureCredential(new DefaultAzureCredentialOptions - { - ExcludeVisualStudioCredential = true, - ExcludeVisualStudioCodeCredential = true, - ExcludeInteractiveBrowserCredential = true, - ExcludeEnvironmentCredential = false, - ExcludeManagedIdentityCredential = false, - ExcludeAzureCliCredential = false, - ExcludeAzurePowerShellCredential = false - })); -} - -// ============================================================================= -// IOPTIONS PATTERN CONFIGURATION (Azure Functions) -// ============================================================================= -// Using AddOptions().BindConfiguration() for consistency with Blazor WASM -// Requires: Microsoft.Extensions.Options.ConfigurationExtensions package -// ============================================================================= - -// Configure rate limiting options -// Section: "RateLimiting" in local.settings.json -builder.Services.AddOptions() - .BindConfiguration(RateLimitOptions.SectionName); - -// Configure email settings options -// Section: "EmailSettings" in local.settings.json -builder.Services.AddOptions() - .BindConfiguration(EmailSettings.SectionName); - -// ============================================================================= -// CORS CONFIGURATION -// ============================================================================= - -var isDevelopment = builder.Environment.IsDevelopment() || - Environment.GetEnvironmentVariable("AZURE_FUNCTIONS_ENVIRONMENT") == "Development"; - -string[] allowedOrigins; -var configuredOrigins = builder.Configuration.GetSection("AllowedOrigins").Get(); - -if (configuredOrigins is not null && configuredOrigins.Length > 0) -{ - allowedOrigins = configuredOrigins; -} -else if (isDevelopment) -{ - allowedOrigins = - [ - "https://localhost:5001", - "https://localhost:7001", - "http://localhost:5000", - "https://localhost:44370", - "https://localhost:7257" - ]; -} -else -{ - throw new InvalidOperationException( - "CORS 'AllowedOrigins' must be configured in production."); -} - -builder.Services.AddSingleton(new CorsSettings(allowedOrigins)); - -// ============================================================================= -// SERVICE REGISTRATIONS -// ============================================================================= - -builder.Services.AddSingleton(); - -builder.Services.AddHttpClient("SecureClient", client => -{ - client.DefaultRequestHeaders.Add("User-Agent", "CloudZen-Api/1.0"); - client.Timeout = TimeSpan.FromSeconds(30); -}); - -// ============================================================================= -// APPLICATION INSIGHTS -// ============================================================================= - -builder.Services - .AddApplicationInsightsTelemetryWorkerService(options => - { - options.EnableAdaptiveSampling = true; - options.EnableQuickPulseMetricStream = true; - }) - .ConfigureFunctionsApplicationInsights(); - -builder.ConfigureFunctionsWebApplication(); - -var app = builder.Build(); -app.Run(); -``` - ---- - -## Azure Functions Configuration Files - -### File Structure - -``` -Api/ -├── local.settings.json # Local development (git-ignored) -├── host.json # Host configuration (committed) -└── (Azure App Settings) # Production in Azure Portal -``` - -### local.settings.json (Local - Git-ignored) - -**File: `Api/local.settings.json`** - -```json -{ - "IsEncrypted": false, - "Values": { - "AzureWebJobsStorage": "UseDevelopmentStorage=true", - "FUNCTIONS_WORKER_RUNTIME": "dotnet-isolated", - "AZURE_FUNCTIONS_ENVIRONMENT": "Development", - - "BREVO_API_KEY": "your-api-key-here", - - "EmailSettings:FromEmail": "cloudzen.inc@gmail.com", - "EmailSettings:CcEmail": "admin@example.com", - - "RateLimiting:PermitLimit": "10", - "RateLimiting:WindowSeconds": "60", - "RateLimiting:QueueLimit": "0", - "RateLimiting:InactivityTimeoutMinutes": "5", - "RateLimiting:EnableCircuitBreaker": "false", - "RateLimiting:CircuitBreakerFailureThreshold": "5", - "RateLimiting:CircuitBreakerDurationSeconds": "30" - } -} -``` - -### host.json (Committed) - -**File: `Api/host.json`** - -```json -{ - "version": "2.0", - "logging": { - "applicationInsights": { - "samplingSettings": { - "isEnabled": true, - "excludedTypes": "Request" - }, - "enableLiveMetricsFilters": true - } - }, - "extensions": { - "http": { - "routePrefix": "api" - } - } -} -``` - -### Configuration Format Note - -Azure Functions uses a **flat key-value format** in `local.settings.json`: - -```json -{ - "Values": { - "SectionName:PropertyName": "value" - } -} -``` - -This maps to: -```csharp -public class SectionName -{ - public string PropertyName { get; set; } -} -``` - ---- - -## Azure Functions Secrets Management - -### Secrets Strategy - -``` -┌─────────────────────────────────────────────────────────────┐ -│ Azure Functions Secrets │ -├─────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────────┐ ┌─────────────────────────────────┐│ -│ │ IOptions │ │ IConfiguration (Direct) ││ -│ │ (Non-secrets) │ │ (Secrets only) ││ -│ ├─────────────────┤ ├─────────────────────────────────┤│ -│ │ EmailSettings │ │ BREVO_API_KEY ││ -│ │ • FromEmail │ │ KEY_VAULT_ENDPOINT ││ -│ │ • CcEmail │ │ Connection strings ││ -│ │ │ │ ││ -│ │ RateLimitOptions│ │ Source: ││ -│ │ • PermitLimit │ │ • Environment variables ││ -│ │ • WindowSeconds │ │ • Azure Key Vault ││ -│ └─────────────────┘ └─────────────────────────────────┘│ -│ │ -└─────────────────────────────────────────────────────────────┘ -``` - -### Why Secrets Use IConfiguration (Not IOptions) - -```csharp -// ✅ CORRECT: API key from IConfiguration -var apiKey = _config["BREVO_API_KEY"]; - -// ❌ WRONG: Don't put secrets in IOptions classes -// They may have default values that could leak -public class BadOptions -{ - public string ApiKey { get; set; } = "default-key"; // DON'T DO THIS -} -``` - -### Accessing Secrets in Functions - -```csharp -public class SendEmailFunction -{ - private readonly IConfiguration _config; - private readonly EmailSettings _emailSettings; - - public SendEmailFunction( - IConfiguration config, // For secrets - IOptions options) // For non-secrets - { - _config = config; - _emailSettings = options.Value; - } - - public async Task Run(HttpRequest req) - { - // Secret from IConfiguration (comes from Key Vault or env var) - var apiKey = _config["BREVO_API_KEY"]; - - // Non-secret from IOptions - var fromEmail = _emailSettings.FromEmail; - - // ... - } -} -``` - -### Azure Key Vault Integration - -```csharp -// In Program.cs -var keyVaultEndpoint = Environment.GetEnvironmentVariable("KEY_VAULT_ENDPOINT"); -if (!string.IsNullOrEmpty(keyVaultEndpoint)) -{ - builder.Configuration.AddAzureKeyVault( - new Uri(keyVaultEndpoint), - new DefaultAzureCredential()); -} - -// Key Vault secret named "BREVO-API-KEY" becomes accessible as: -var apiKey = _config["BREVO-API-KEY"]; -``` - ---- - -# Part 4: Advanced Topics - -## Configuration Validation - -### Data Annotations - -```csharp -using System.ComponentModel.DataAnnotations; - -public class EmailServiceOptions -{ - public const string SectionName = "EmailService"; - - [Required] - public string ApiBaseUrl { get; set; } = "/api"; - - [Range(1, 300)] - public int TimeoutSeconds { get; set; } = 30; - - [Range(0, 10)] - public int MaxRetries { get; set; } = 3; -} -``` - -### Registration with Validation - -```csharp -// Blazor WASM (validation on first access) -builder.Services.AddOptions() - .BindConfiguration(EmailServiceOptions.SectionName) - .ValidateDataAnnotations(); - -// Azure Functions (validation on startup - fail fast) -builder.Services.AddOptions() - .BindConfiguration(EmailServiceOptions.SectionName) - .ValidateDataAnnotations() - .ValidateOnStart(); -``` - -### Custom Validator - -```csharp -public class EmailServiceOptionsValidator : IValidateOptions -{ - public ValidateOptionsResult Validate(string? name, EmailServiceOptions options) - { - var errors = new List(); - - if (string.IsNullOrWhiteSpace(options.ApiBaseUrl)) - errors.Add("ApiBaseUrl is required"); - - if (options.TimeoutSeconds <= 0) - errors.Add("TimeoutSeconds must be positive"); - - return errors.Count > 0 - ? ValidateOptionsResult.Fail(errors) - : ValidateOptionsResult.Success; - } -} - -// Register -builder.Services.AddSingleton, - EmailServiceOptionsValidator>(); -``` - ---- - -## Testing with IOptions - -### Unit Test Helper - -```csharp -using Microsoft.Extensions.Options; - -// Create IOptions for testing -var options = Options.Create(new EmailServiceOptions -{ - ApiBaseUrl = "https://test-api.example.com/api", - TimeoutSeconds = 10, - SendEmailEndpoint = "send-email" -}); -``` - -### Full Test Example - -```csharp -using Microsoft.Extensions.Options; -using Moq; -using Xunit; - -public class ApiEmailServiceTests -{ - [Fact] - public async Task SendEmailAsync_UsesConfiguredEndpoint() - { - // Arrange - var options = Options.Create(new EmailServiceOptions - { - ApiBaseUrl = "https://test-api.example.com/api", - TimeoutSeconds = 10, - SendEmailEndpoint = "send-email" - }); - - var mockHandler = new Mock(); - // Setup mock... - - var httpClient = new HttpClient(mockHandler.Object); - var logger = Mock.Of>(); - - var service = new ApiEmailService(httpClient, options, logger); - - // Act - var result = await service.SendEmailAsync( - "Test", "Message", "Name", "test@example.com"); - - // Assert - Assert.True(result.Success); - } -} -``` - ---- - -## Migration Guide - -### From `Configure()` to `AddOptions().BindConfiguration()` - -**Before:** -```csharp -builder.Services.Configure( - builder.Configuration.GetSection(RateLimitOptions.SectionName)); -``` - -**After:** -```csharp -builder.Services.AddOptions() - .BindConfiguration(RateLimitOptions.SectionName); -``` - -### Migration Checklist - -- [ ] Add `Microsoft.Extensions.Options.ConfigurationExtensions` package -- [ ] Update all `Configure()` calls to `AddOptions().BindConfiguration()` -- [ ] Add validation with `.ValidateDataAnnotations()` where needed -- [ ] Test configuration binding -- [ ] Update documentation - ---- - -## Quick Reference - -### Blazor WASM Quick Setup - -```csharp -// 1. Install package -// dotnet add package Microsoft.Extensions.Options.ConfigurationExtensions - -// 2. Create options class -public class MyOptions -{ - public const string SectionName = "MySection"; - public string MySetting { get; set; } = "default"; -} - -// 3. Add to wwwroot/appsettings.json -// { "MySection": { "MySetting": "value" } } - -// 4. Register in Program.cs -builder.Services.AddOptions() - .BindConfiguration(MyOptions.SectionName); - -// 5. Inject in service -public MyService(IOptions options) -{ - var setting = options.Value.MySetting; -} -``` - -### Azure Functions Quick Setup - -```csharp -// 1. Install package -// dotnet add package Microsoft.Extensions.Options.ConfigurationExtensions - -// 2. Create options class (same as WASM) - -// 3. Add to local.settings.json -// { "Values": { "MySection:MySetting": "value" } } - -// 4. Register in Program.cs (same as WASM) -builder.Services.AddOptions() - .BindConfiguration(MyOptions.SectionName); - -// 5. Inject in function (same as WASM) -public MyFunction(IOptions options) -{ - var setting = options.Value.MySetting; -} -``` - ---- - -## References - -- [Options pattern in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/options) -- [Configuration in ASP.NET Core](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/configuration/) -- [Azure Functions Configuration](https://learn.microsoft.com/en-us/azure/azure-functions/functions-how-to-use-azure-function-app-settings) -- [Blazor WebAssembly Configuration](https://learn.microsoft.com/en-us/aspnet/core/blazor/fundamentals/configuration) -- [Azure Key Vault Configuration Provider](https://learn.microsoft.com/en-us/aspnet/core/security/key-vault-configuration) diff --git a/CloudZen.csproj b/CloudZen.csproj index d3da6b0..a710a5d 100644 --- a/CloudZen.csproj +++ b/CloudZen.csproj @@ -1,4 +1,4 @@ - + net8.0 @@ -7,6 +7,10 @@ service-worker-assets.js 21460780-92de-4d50-aa0e-8113a148f085 $(DefaultItemExcludes);Api\** + + true + true + true @@ -15,23 +19,9 @@ - - - - - - - - - - - - - - @@ -39,4 +29,5 @@ - + + diff --git a/Shared/Common/AnimatedCounterCircle.razor b/Common/Components/AnimatedCounterCircle.razor similarity index 100% rename from Shared/Common/AnimatedCounterCircle.razor rename to Common/Components/AnimatedCounterCircle.razor diff --git a/Common/Components/AutomationProgressCard.razor b/Common/Components/AutomationProgressCard.razor new file mode 100644 index 0000000..177f674 --- /dev/null +++ b/Common/Components/AutomationProgressCard.razor @@ -0,0 +1,127 @@ +@namespace CloudZen.Common.Components + + + +
+
+ +
+
+ + + +
+ @Title +
+ + +
+ +
+
+ @ProgressLabel + @_currentProgress% +
+
+
+
+
+ + +
+
+
+ +
+
@_currentTasks
+
Tasks
+
+
+
+ +
+
@_currentHours
+
Hours
+
+
+
+ +
+
@_currentWorkflows
+
Workflows
+
+
+ + +
+
+ @for (var i = 0; i < _visibleMessages.Count; i++) + { + var message = _visibleMessages[i]; + var delay = i * 0.15; +
+ @(message.IsHighlight ? "+" : ">") + @message.Text +
+ } + @if (_showCursor) + { +
+ } +
+
+
+
+
diff --git a/Common/Components/AutomationProgressCard.razor.cs b/Common/Components/AutomationProgressCard.razor.cs new file mode 100644 index 0000000..3d67edb --- /dev/null +++ b/Common/Components/AutomationProgressCard.razor.cs @@ -0,0 +1,322 @@ +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Common.Components; + +/// +/// Displays an animated floating card with progress bar, stat counters, and terminal log. +/// Animations loop continuously, restarting after completion. +/// +public partial class AutomationProgressCard : ComponentBase, IDisposable +{ + #region Parameters + + /// + /// Window title displayed in the chrome bar. + /// + [Parameter] public string Title { get; set; } = "cloudzen-workflow.ai"; + + /// + /// Label for the progress bar. + /// + [Parameter] public string ProgressLabel { get; set; } = "Automation Progress"; + + /// + /// Target progress percentage (0-100). Animation will cycle from 0 to 100. + /// + [Parameter] public int TargetProgress { get; set; } = 100; + + /// + /// Number of tasks to display in stats. + /// + [Parameter] public int TasksCount { get; set; } = 847; + + /// + /// Number of hours to display in stats. + /// + [Parameter] public int HoursCount { get; set; } = 124; + + /// + /// Number of workflows to display in stats. + /// + [Parameter] public int WorkflowsCount { get; set; } = 18; + + /// + /// Custom terminal log messages. Uses default messages if not provided. + /// + [Parameter] public List? TerminalMessages { get; set; } + + /// + /// Whether to loop the animation continuously. Defaults to true. + /// + [Parameter] public bool Loop { get; set; } = true; + + /// + /// Delay in milliseconds before restarting the animation loop. Defaults to 2000ms. + /// + [Parameter] public int LoopDelayMs { get; set; } = 2000; + + /// + /// Delay in milliseconds between each terminal message appearing. Defaults to 800ms. + /// + [Parameter] public int TerminalMessageDelayMs { get; set; } = 800; + + #endregion + + #region State + + private bool _isVisible; + private bool _statsVisible; + private bool _showCursor = true; + private int _currentProgress; + private int _currentTasks; + private int _currentHours; + private int _currentWorkflows; + private List _visibleMessages = []; + private CancellationTokenSource? _cts; + + // Animation timing constants + private const int ProgressSteps = 50; + private const int ProgressStepDelayMs = 120; // Slower animation (was 60ms) + private static int ProgressDurationMs => ProgressSteps * ProgressStepDelayMs; // 6000ms total + + #endregion + + #region Computed Properties + + private string CardCssClass => _isVisible ? "automation-card float-animation" : "automation-card"; + + private string GetStatCardCssClass(int delayIndex) => + _statsVisible ? $"stat-card stat-enter stat-delay-{delayIndex}" : "stat-card stat-hidden"; + + private static List DefaultMessages => + [ + new("Scanning legacy systems", false), + new("12 automation opportunities found", true), + new("Building custom dashboard", false), + new("Connecting with autopilot-framework",true), + new("Deployment complete", true) + ]; + + #endregion + + #region Lifecycle + + /// + /// Blazor lifecycle hook invoked after the component has rendered. + /// On the very first render, initializes the cancellation token and kicks off + /// the animation loop so the card appears with its entrance + progress sequence. + /// Subsequent renders are ignored to prevent duplicate animation loops. + /// + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _cts = new CancellationTokenSource(); + await RunAnimationLoopAsync(); + } + } + + /// + /// Disposes the component by cancelling any in-flight animation tasks and releasing + /// the . Calling + /// satisfies the dispose pattern since no finalizer is needed. + /// + public void Dispose() + { + _cts?.Cancel(); + _cts?.Dispose(); + GC.SuppressFinalize(this); + } + + #endregion + + #region Animation Methods + + /// + /// Main animation orchestrator that drives the full card lifecycle. + /// Each iteration resets state, runs the entrance + progress + counter + terminal + /// sequence, then optionally pauses for before restarting. + /// The loop exits gracefully when is cancelled (component disposal) + /// or when is false (single-play mode). + /// + private async Task RunAnimationLoopAsync() + { + try + { + do + { + // Reset state for new animation cycle + ResetAnimationState(); + await InvokeAsync(StateHasChanged); + + // Run the animation sequence + await StartAnimationSequenceAsync(); + + if (Loop && !_cts!.Token.IsCancellationRequested) + { + // Hold at 100% briefly before restarting + await Task.Delay(LoopDelayMs, _cts.Token); + } + + } while (Loop && !_cts!.Token.IsCancellationRequested); + } + catch (TaskCanceledException) + { + // Component disposed during animation - expected behavior + } + } + + /// + /// Resets all mutable animation state (progress percentage, stat counters, + /// visible terminal messages, and stats visibility) back to zero/empty so + /// the next animation cycle starts from a clean slate. The card's own + /// visibility () is intentionally preserved after + /// the first run to avoid a jarring disappear-reappear flash between loops. + /// + private void ResetAnimationState() + { + _currentProgress = 0; + _currentTasks = 0; + _currentHours = 0; + _currentWorkflows = 0; + _visibleMessages = []; + _statsVisible = false; + // Keep card visible after first run for smooth transitions + } + + /// + /// Executes a single animation cycle from start to finish. On the very first + /// cycle the card fades in; on subsequent cycles it skips that step. After a + /// short stagger delay the stat cards appear, then the progress bar, counters, + /// and terminal messages all animate concurrently via , + /// ensuring they finish at roughly the same time regardless of message count. + /// + private async Task StartAnimationSequenceAsync() + { + // Initial delay before card appears (only on first run) + if (!_isVisible) + { + await Task.Delay(200, _cts!.Token); + _isVisible = true; + await InvokeAsync(StateHasChanged); + } + + // Small delay before starting new cycle + await Task.Delay(300, _cts!.Token); + + // Show stats cards with stagger animation + _statsVisible = true; + await InvokeAsync(StateHasChanged); + + // Run all animations concurrently - they will complete at approximately the same time + var progressTask = AnimateProgressAsync(); + var countersTask = AnimateCountersAsync(); + var terminalTask = ShowTerminalMessagesAsync(); + + // Wait for all animations to complete + await Task.WhenAll(progressTask, countersTask, terminalTask); + } + + /// + /// Smoothly animates the progress bar from 0 to + /// over increments. Each step adds a fixed fraction + /// of the target, capped with to prevent overshooting, + /// and triggers a re-render so the CSS width binding updates in real time. + /// Total duration equals × . + /// + private async Task AnimateProgressAsync() + { + var increment = (double)TargetProgress / ProgressSteps; + + for (var i = 1; i <= ProgressSteps && !_cts!.Token.IsCancellationRequested; i++) + { + _currentProgress = (int)Math.Min(increment * i, TargetProgress); + await InvokeAsync(StateHasChanged); + await Task.Delay(ProgressStepDelayMs, _cts.Token); + } + + _currentProgress = TargetProgress; + await InvokeAsync(StateHasChanged); + } + + /// + /// Animates the three stat counters (Tasks, Hours, Workflows) from 0 to their + /// target values using a cubic ease-out curve (1 − (1−t)³) so the numbers + /// accelerate quickly then settle smoothly. The step delay is derived from + /// to keep counters synchronized with the + /// progress bar. Final values are snapped to exact targets after the loop to + /// avoid rounding drift. + /// + private async Task AnimateCountersAsync() + { + // Match counter animation to progress duration + const int steps = 30; + var delayMs = ProgressDurationMs / steps; // Sync with progress bar + + for (var i = 1; i <= steps && !_cts!.Token.IsCancellationRequested; i++) + { + var progress = (double)i / steps; + // Cubic ease-out for smoother animation + var eased = 1 - Math.Pow(1 - progress, 3); + + _currentTasks = (int)(TasksCount * eased); + _currentHours = (int)(HoursCount * eased); + _currentWorkflows = (int)(WorkflowsCount * eased); + + await InvokeAsync(StateHasChanged); + await Task.Delay(delayMs, _cts!.Token); + } + + // Ensure final values are exact + _currentTasks = TasksCount; + _currentHours = HoursCount; + _currentWorkflows = WorkflowsCount; + await InvokeAsync(StateHasChanged); + } + + /// + /// Reveals terminal log messages one at a time with a staggered delay, simulating + /// a real CLI output stream. The delay between messages is calculated so all + /// messages appear within the progress bar's total duration (minus a 200 ms buffer), + /// but is capped at so messages never feel + /// sluggish. Uses when provided, otherwise falls + /// back to . + /// + private async Task ShowTerminalMessagesAsync() + { + var messages = TerminalMessages ?? DefaultMessages; + var messageCount = messages.Count; + + if (messageCount == 0) return; + + // Calculate delay so all messages appear within the progress bar duration + // Subtract a small buffer to ensure last message appears before progress completes + var totalTimeForMessages = ProgressDurationMs - 200; // 200ms buffer + var calculatedDelay = totalTimeForMessages / messageCount; + + // Use the calculated delay or the parameter, whichever fits the timeframe + var delayMs = Math.Min(TerminalMessageDelayMs, calculatedDelay); + + foreach (var message in messages) + { + if (_cts!.Token.IsCancellationRequested) break; + + _visibleMessages.Add(message); + await InvokeAsync(StateHasChanged); + await Task.Delay(delayMs, _cts.Token); + } + } + + #endregion + + #region Nested Types + + /// + /// Represents a terminal log message with optional highlight styling. + /// + /// The message text to display. + /// Whether to highlight this message (shown in orange with + prefix). + public record TerminalMessage(string Text, bool IsHighlight); + + #endregion +} diff --git a/Common/Components/AutomationProgressCard.razor.css b/Common/Components/AutomationProgressCard.razor.css new file mode 100644 index 0000000..0d3a35c --- /dev/null +++ b/Common/Components/AutomationProgressCard.razor.css @@ -0,0 +1,307 @@ +/* AutomationProgressCard - Scoped CSS */ + +/* Wrapper for positioning */ +.automation-card-wrapper { + display: flex; + justify-content: center; + align-items: center; + padding: 2rem; + perspective: 1000px; +} + +/* Main Card */ +.automation-card { + background: #ffffff; + border-radius: 1rem; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(0, 0, 0, 0.05); + width: 100%; + max-width: 480px; + overflow: hidden; + transform: translateY(20px); + opacity: 0; + transition: opacity 0.5s ease, transform 0.5s ease; +} + +.automation-card.float-animation { + opacity: 1; + transform: translateY(0); + animation: float 6s ease-in-out infinite; +} + +@keyframes float { + 0%, 100% { + transform: translateY(0px) rotateX(0deg); + } + 50% { + transform: translateY(-10px) rotateX(1deg); + } +} + +/* Window Chrome (macOS-style title bar) */ +.window-chrome { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.875rem 1rem; + background: #fafafa; + border-bottom: 1px solid #f0f0f0; +} + +.window-dots { + display: flex; + gap: 0.5rem; +} + +.dot { + width: 12px; + height: 12px; + border-radius: 50%; + transition: opacity 0.2s ease; +} + +.dot:hover { + opacity: 0.8; +} + +.dot-red { + background: #ff5f57; +} + +.dot-yellow { + background: #ffbd2e; +} + +.dot-green { + background: #28c840; +} + +.window-title { + font-size: 0.875rem; + color: #6b7280; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; +} + +/* Card Content Container */ +.card-content { + padding: 1.5rem; +} + +/* Progress Section */ +.progress-section { + margin-bottom: 1.5rem; +} + +.progress-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.5rem; +} + +.progress-label { + font-size: 0.9rem; + font-weight: 500; + color: #61C2C8; +} + +.progress-value { + font-size: 0.875rem; + color: #6b7280; + font-weight: 500; +} + +.progress-track { + height: 8px; + background: #e5e7eb; + border-radius: 9999px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: linear-gradient(90deg, #f59e0b, #fbbf24); + border-radius: 9999px; + transition: width 0.1s ease-out; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} + +.stat-card { + background: #fafafa; + border: 1px solid #f0f0f0; + border-radius: 0.75rem; + padding: 1rem; + text-align: center; + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); +} + +.stat-hidden { + opacity: 0; + transform: translateY(20px); +} + +.stat-enter { + animation: statEnter 0.5s ease forwards; +} + +.stat-delay-1 { + animation-delay: 0.2s; +} + +.stat-delay-2 { + animation-delay: 0.4s; +} + +.stat-delay-3 { + animation-delay: 0.6s; +} + +@keyframes statEnter { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.stat-icon { + width: 2rem; + height: 2rem; + margin: 0 auto 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +.stat-icon-tasks { + color: #61C2C8; +} + +.stat-icon-hours { + color: #6b7280; +} + +.stat-icon-workflows { + color: #6b7280; +} + +.stat-value { + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + line-height: 1.2; +} + +.stat-label { + font-size: 0.75rem; + color: #9ca3af; + text-transform: capitalize; +} + +/* Terminal Section */ +.terminal-section { + background: #1e1b2e; + border-radius: 0.75rem; + overflow: hidden; +} + +.terminal-content { + padding: 1rem; + font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; + font-size: 0.8rem; + line-height: 1.8; + min-height: 120px; +} + +.terminal-line { + display: flex; + gap: 0.5rem; + opacity: 0; + transform: translateX(-10px); +} + +.terminal-line.message-enter { + animation: messageEnter 0.4s ease forwards; +} + +@keyframes messageEnter { + from { + opacity: 0; + transform: translateX(-10px); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.terminal-prefix { + color: #9ca3af; + flex-shrink: 0; +} + +.terminal-text { + color: #d1d5db; +} + +.terminal-line.highlight .terminal-prefix, +.terminal-line.highlight .terminal-text { + color: #f59e0b; +} + +.terminal-cursor { + width: 8px; + height: 16px; + background: #61C2C8; + margin-top: 0.25rem; + animation: blink 1s step-end infinite; +} + +@keyframes blink { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0; + } +} + +/* Responsive Adjustments */ +@media (max-width: 480px) { + .automation-card-wrapper { + padding: 1rem; + } + + .stats-grid { + gap: 0.5rem; + } + + .stat-card { + padding: 0.75rem 0.5rem; + } + + .stat-value { + font-size: 1.25rem; + } + + .terminal-content { + font-size: 0.7rem; + padding: 0.75rem; + } +} diff --git a/Common/Components/Pagination.razor b/Common/Components/Pagination.razor new file mode 100644 index 0000000..e5dabf5 --- /dev/null +++ b/Common/Components/Pagination.razor @@ -0,0 +1,80 @@ +@* + Pagination Component + + PURPOSE: + Reusable page navigation bar. Displays numbered page buttons with + previous/next arrows and ellipsis for large page counts. + Parent owns page state; this component only emits OnPageChanged events. +*@ + +@if (TotalPages > 1) +{ + + + @* Page Info *@ +

+ Page @CurrentPage of @TotalPages + · + @TotalItems project@(TotalItems != 1 ? "s" : "") total +

+} + +@code { + /// + /// Tailwind classes for previous/next arrow buttons. + /// + private static string PrevNextClasses(bool disabled) => disabled + ? "w-9 h-9 flex items-center justify-center rounded-xl bg-gray-100 text-gray-300 cursor-not-allowed" + : "w-9 h-9 flex items-center justify-center rounded-xl bg-white border border-gray-200 text-gray-600 " + + "hover:border-teal-cyan-aqua-300 hover:text-teal-cyan-aqua-600 hover:shadow-md " + + "transition-all duration-200 cursor-pointer"; + + /// + /// Tailwind classes for numbered page buttons — active vs inactive. + /// + private string PageButtonClasses(int page) => page == CurrentPage + ? "w-9 h-9 flex items-center justify-center rounded-xl text-sm font-bold " + + "bg-gradient-to-br from-teal-cyan-aqua-600 to-teal-cyan-aqua-400 text-white shadow-lg shadow-teal-cyan-aqua-500/30" + : "w-9 h-9 flex items-center justify-center rounded-xl text-sm font-medium " + + "bg-white border border-gray-200 text-gray-600 " + + "hover:border-teal-cyan-aqua-300 hover:text-teal-cyan-aqua-600 hover:shadow-md " + + "transition-all duration-200 cursor-pointer"; +} diff --git a/Common/Components/Pagination.razor.cs b/Common/Components/Pagination.razor.cs new file mode 100644 index 0000000..558fa4d --- /dev/null +++ b/Common/Components/Pagination.razor.cs @@ -0,0 +1,89 @@ +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Common.Components; + +/// +/// Reusable pagination component that displays page navigation controls. +/// Accepts total item count, page size, and current page; emits page-change events to the parent. +/// +/// +/// SOLID alignment: +/// - S: Single purpose — pagination navigation only. +/// - O: Configurable via parameters (page size, visible page count) without code changes. +/// - I: Focused parameter surface — only what's needed. +/// - D: No service dependencies; pure presentation component driven by parent state. +/// +public partial class Pagination +{ + // ── Parameters ──────────────────────────────────────────────────────── + + /// Total number of items across all pages. + [Parameter, EditorRequired] + public int TotalItems { get; set; } + + /// Number of items displayed per page. + [Parameter, EditorRequired] + public int PageSize { get; set; } = 5; + + /// The current active page (1-based). + [Parameter, EditorRequired] + public int CurrentPage { get; set; } = 1; + + /// Maximum number of page buttons visible in the navigation bar. + [Parameter] + public int MaxVisiblePages { get; set; } = 5; + + /// Fires when the user selects a different page. + [Parameter, EditorRequired] + public EventCallback OnPageChanged { get; set; } + + // ── Computed ────────────────────────────────────────────────────────── + + private int TotalPages => (int)Math.Ceiling((double)TotalItems / PageSize); + private bool HasPrevious => CurrentPage > 1; + private bool HasNext => CurrentPage < TotalPages; + + // ── Handlers ───────────────────────────────────────────────────────── + + private async Task GoToPage(int page) + { + if (page < 1 || page > TotalPages || page == CurrentPage) return; + await OnPageChanged.InvokeAsync(page); + } + + // ── Helpers ────────────────────────────────────────────────────────── + + /// + /// Computes the visible page numbers with ellipsis gaps when the total + /// page count exceeds . + /// Returns null entries to represent ellipsis ("...") placeholders. + /// + internal IEnumerable GetVisiblePageNumbers() + { + if (TotalPages <= MaxVisiblePages) + { + for (int i = 1; i <= TotalPages; i++) + yield return i; + yield break; + } + + int half = MaxVisiblePages / 2; + int start = Math.Max(2, CurrentPage - half); + int end = Math.Min(TotalPages - 1, CurrentPage + half); + + // Adjust window when near edges + if (start <= 2) end = Math.Min(TotalPages - 1, MaxVisiblePages - 1); + if (end >= TotalPages - 1) start = Math.Max(2, TotalPages - MaxVisiblePages + 2); + + yield return 1; + + if (start > 2) yield return null; // left ellipsis + + for (int i = start; i <= end; i++) + yield return i; + + if (end < TotalPages - 1) yield return null; // right ellipsis + + yield return TotalPages; + } +} diff --git a/Shared/Common/ScrollToTopButton.razor b/Common/Components/ScrollToTopButton.razor similarity index 89% rename from Shared/Common/ScrollToTopButton.razor rename to Common/Components/ScrollToTopButton.razor index 75c0f56..b501426 100644 --- a/Shared/Common/ScrollToTopButton.razor +++ b/Common/Components/ScrollToTopButton.razor @@ -22,14 +22,14 @@ - OnAfterRenderAsync: Initializes JS listener with retry logic for timing issues - UpdateVisibility: Called by JS when scroll position crosses threshold - ScrollToTop: Invoked on button click to trigger smooth scroll - - Dispose: Cleans up DotNetObjectReference to prevent memory leaks + - DisposeAsync: Removes JS scroll listener and cleans up DotNetObjectReference to prevent memory leaks USAGE: Add to MainLayout.razor or any page component. No parameters required - works out of the box. *@ -@implements IDisposable +@implements IAsyncDisposable @inject IJSRuntime JSRuntime @* Scroll to Top Button - Modern floating action button *@ @@ -142,12 +142,20 @@ } /// - /// Cleans up the DotNetObjectReference when component is destroyed. - /// Essential to prevent memory leaks in Blazor WebAssembly. - /// Called automatically by Blazor framework when component is removed. + /// Removes the JavaScript scroll listener and cleans up the DotNetObjectReference + /// when the component is destroyed. The JS listener must be removed first so it + /// cannot fire callbacks against the already-disposed .NET reference. /// - public void Dispose() + public async ValueTask DisposeAsync() { + try + { + await JSRuntime.InvokeVoidAsync("disposeScrollToTop"); + } + catch (JSException) + { + // JS runtime may already be unavailable during hot-reload or shutdown + } _dotNetHelper?.Dispose(); } } diff --git a/Models/Options/BlobStorageOptions.cs b/Common/Options/BlobStorageOptions.cs similarity index 97% rename from Models/Options/BlobStorageOptions.cs rename to Common/Options/BlobStorageOptions.cs index 6009498..82abb5f 100644 --- a/Models/Options/BlobStorageOptions.cs +++ b/Common/Options/BlobStorageOptions.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models.Options; +namespace CloudZen.Common.Options; /// /// Configuration options for Azure Blob Storage access. diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..b730e4c --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,300 @@ +--- +name: CloudZen +description: Personal cloud consulting showcase — empowering, minimal, practical. +colors: + teal-brand: "#61C2C8" + teal-hover: "#74b7bb" + teal-light: "#76cbd2" + cta-orange: "#f97316" + cta-orange-hover: "#ea6c0a" + teal-50: "#DAF6F9" + teal-100: "#B8EFF4" + teal-200: "#89D6DC" + teal-300: "#78BCC2" + teal-400: "#659FA5" + teal-500: "#538488" + teal-600: "#40676B" + teal-700: "#2F4E51" + teal-800: "#1F3638" + teal-900: "#0F1E1F" + teal-950: "#081314" + surface-white: "#ffffff" + surface-subtle: "#f9fafb" + surface-muted: "#f3f4f6" + ink-heading: "#1f2937" + ink-body: "#374151" + ink-muted: "#6b7280" + ink-faint: "#9ca3af" + border-default: "#e5e7eb" + border-accent: "#89D6DC" + focus-ring: "#40676B" +typography: + display: + fontFamily: "IBM Plex Sans, Arial, Helvetica, sans-serif" + fontSize: "clamp(2.25rem, 5vw, 3.75rem)" + fontWeight: 700 + lineHeight: 1.05 + letterSpacing: "-0.02em" + headline: + fontFamily: "IBM Plex Sans, Arial, Helvetica, sans-serif" + fontSize: "clamp(1.5rem, 3vw, 2.25rem)" + fontWeight: 700 + lineHeight: 1.2 + letterSpacing: "-0.015em" + title: + fontFamily: "IBM Plex Sans, Arial, Helvetica, sans-serif" + fontSize: "1.25rem" + fontWeight: 600 + lineHeight: 1.3 + letterSpacing: "-0.01em" + body: + fontFamily: "Helvetica Neue, Helvetica, Arial, sans-serif" + fontSize: "1rem" + fontWeight: 400 + lineHeight: 1.65 + letterSpacing: "normal" + label: + fontFamily: "IBM Plex Sans, Arial, Helvetica, sans-serif" + fontSize: "0.875rem" + fontWeight: 600 + lineHeight: 1.4 + letterSpacing: "0.01em" + caption: + fontFamily: "Helvetica Neue, Helvetica, Arial, sans-serif" + fontSize: "0.75rem" + fontWeight: 400 + lineHeight: 1.5 + letterSpacing: "0.01em" +rounded: + sm: "4px" + md: "8px" + lg: "12px" + xl: "16px" + "2xl": "1rem" + full: "9999px" +spacing: + xs: "4px" + sm: "8px" + md: "16px" + lg: "24px" + xl: "32px" + "2xl": "48px" + "3xl": "64px" +components: + button-primary: + backgroundColor: "{colors.cta-orange}" + textColor: "{colors.ink-primary}" + rounded: "{rounded.full}" + padding: "12px 28px" + note: "Dark teal ink (#0F1E1F) on orange-400 — 8.1:1 contrast, WCAG AA pass" + button-primary-hover: + backgroundColor: "{colors.cta-orange-hover}" + button-secondary: + backgroundColor: "{colors.surface-white}" + textColor: "{colors.ink-body}" + rounded: "{rounded.full}" + padding: "10px 24px" + button-secondary-hover: + backgroundColor: "{colors.teal-50}" + textColor: "{colors.teal-600}" + card: + backgroundColor: "{colors.surface-white}" + rounded: "{rounded.2xl}" + padding: "{spacing.xl}" + input: + backgroundColor: "{colors.surface-subtle}" + textColor: "{colors.ink-body}" + rounded: "{rounded.xl}" + padding: "12px 16px" + input-focus: + backgroundColor: "{colors.surface-white}" +--- + +# Design System: CloudZen + +## 1. Overview + +**Creative North Star: "The Working Prototype"** + +CloudZen's design system communicates capability through craft. Every surface, transition, and typographic decision is evidence of technical judgment — the kind a client or collaborator evaluates before they read a single word of copy. The system is built, not polished: it shows process (animated workflow nodes, connector paths, staged reveals) rather than announcing results. Motion is structural, not decorative. Spacing is deliberate, not generous by default. + +The palette is a tonal progression from near-black deep teal to crisp white, anchored by a warm cyan brand accent (`#61C2C8`) and a single high-energy orange for primary calls to action (`#f97316`). These two chromatic notes — cool technical teal and energetic warm orange — create a controlled tension that reads as competent and approachable at the same time. + +This system explicitly rejects: stock-heavy corporate CV layouts, aggressive salesy CTAs, dated gradient overlays, the "black box" agency feel of opaque process descriptions, and disconnected section-to-section transitions that make a page feel assembled rather than designed. Warm cream backgrounds (`#fcf9f5` and equivalents) are permitted for specific variant experiments only — the default surface is white or the lightest step of the teal scale, never a warm-neutral tint. + +**Key Characteristics:** +- Tonal depth via color ramp steps, not pervasive box-shadows +- Orange CTAs only; teal is for accents, links, focus, and decoration — never primary actions +- IBM Plex Sans for all display/heading copy; Helvetica Neue for all body prose +- Motion as flow: elements animate in sequence, not in unison +- Technical warmth: precision of a well-built component, human enough to invite contact + +--- + +## 2. Colors: The Deep Signal Palette + +A cool, precise ramp from near-black teal through crisp white, accented by a single warm orange reserved exclusively for primary actions. The palette earns its restraint; the orange CTA is the only warm note on any screen that uses a white surface. + +### Primary +- **Aqua Signal** (`#61C2C8`): Brand accent. Icon fills, focus rings, hover borders, validation highlights, and animated connector paths. Not used on text body. Not used as a primary CTA button. +- **Deep Signal Teal** (`#40676B` / `teal-600`): Primary accent on dark-safe surfaces — links, active nav states, inline highlights, accent decorative lines. Passes 4.5:1 on white. +- **Ink Teal** (`#2F4E51` / `teal-700`): Paragraph text on white and subtle surfaces. +- **Heading Teal** (`#1F3638` / `teal-800`): Card titles, section headings that live on tinted surfaces. + +### Secondary +- **Action Orange** (`#f97316`): All primary CTA buttons. Only warm color on light surfaces. Used nowhere else (no headings, no borders, no backgrounds except intentional highlight spots). **CTA button text must use deep teal ink (`#0F1E1F` / `text-teal-cyan-aqua-900`) — not white — to pass WCAG AA (8.1:1 on orange-400).** +- **Orange Hover** (`#ea6c0a`): Pressed/hover state for Action Orange. + +### Tertiary +- **Teal Mist** (`#DAF6F9` / `teal-50`): Icon backgrounds, badge fills, section tints. Always paired with a teal-600 or teal-700 element. +- **Teal Haze** (`#89D6DC` / `teal-200`): Hover borders, header scroll accent line. + +### Neutral +- **Surface White** (`#ffffff`): Default card and page surface. +- **Subtle Gray** (`#f9fafb`): Alternate section backgrounds. +- **Muted Gray** (`#f3f4f6`): Input backgrounds at rest. +- **Border Default** (`#e5e7eb`): Card and input borders at rest. +- **Ink Heading** (`#1f2937`): Top-level headings on white surface. +- **Ink Body** (`#374151`): Paragraph text on white. 7.4:1 contrast. Never use anything lighter than this for body prose. +- **Ink Muted** (`#6b7280`): Secondary info, metadata — only for text ≥ 14px at weight ≥ 600. +- **Ink Faint** (`#9ca3af`): Nav underlines, decorative dividers only. Never for readable text. + +### Dark Surfaces +- **Deep Teal** (`#0F1E1F` / `teal-900`) → `#1F3638` (`teal-800`): Dark sidebar and dark section gradient. Use with `teal-100`/`teal-200` text. + +### Named Rules +**The Orange Monopoly Rule.** Orange appears on one element per screen: the primary CTA button. If a second orange element is needed, reconsider the hierarchy before adding it. + +**The Teal Contrast Rule.** Body text on white must use `teal-700` (#2F4E51) or `ink-body` (#374151) at minimum. Teal-600 (#40676B) is the lightest permitted value for body-weight text; never use teal-500 or above for prose on white. + +--- + +## 3. Typography + +**Display Font:** IBM Plex Sans (Arial, Helvetica, sans-serif fallback) +**Body Font:** Helvetica Neue (Helvetica, Arial, sans-serif fallback) +**Label Font:** IBM Plex Sans (same as display) + +**Character:** IBM Plex Sans brings engineering precision — tight tracking at large sizes, confident weight contrast — without the coldness of a purely geometric sans. Helvetica Neue in body keeps reading comfortable and neutral. Together they read as "built by someone technically capable who also knows how to communicate." + +### Hierarchy + +- **Display** (700, `clamp(2.25rem, 5vw, 3.75rem)`, lh 1.05, tracking −0.02em): Hero headlines and major section openers. Maximum one per view. Use `text-wrap: balance`. Never exceed 3.75rem (≈ 60px). +- **Headline** (700, `clamp(1.5rem, 3vw, 2.25rem)`, lh 1.2, tracking −0.015em): Section headings, card group headers. Use `text-wrap: balance` on lines shorter than 30ch. +- **Title** (600, `1.25rem`, lh 1.3, tracking −0.01em): Individual card titles, step labels, feature names. +- **Body** (400, `1rem`, lh 1.65): All prose. IBM Plex body font family (`Helvetica Neue`). Constrain line length to 65–72ch in reading-focused sections. +- **Label** (600, `0.875rem`, lh 1.4, tracking 0.01em): Button text, nav links, badge copy, short field labels. IBM Plex. +- **Caption** (400, `0.75rem`, lh 1.5): Metadata, timestamps, secondary stat labels. + +### Named Rules +**The Two-Family Rule.** IBM Plex Sans for anything structural or named (headings, labels, buttons). Helvetica Neue for anything read continuously (body, descriptions, quotes). No third family. + +**The Uppercase Restriction.** All-caps is allowed only on labels ≤ 4 words at caption size (`0.75rem`) with explicit letter-spacing ≥ `0.08em`. No all-caps headings, no all-caps body copy. + +--- + +## 4. Elevation + +CloudZen uses **tonal elevation** as the primary depth system: surfaces are differentiated by color step (white → subtle gray → teal-50 → teal-100 → dark teal) rather than stacked box-shadows. Shadows are ambient and state-driven, not structural. + +### Shadow Vocabulary + +- **Rest (none)**: Default card state on white surface. `border: 1px solid #e5e7eb` provides the boundary. No box-shadow. +- **Ambient-low** (`0 2px 8px rgba(0,0,0,0.08), 0 1px 3px rgba(0,0,0,0.04)`): Workflow node cards at rest. Subtle lift for small floating elements. +- **Ambient-mid** (`0 4px 16px rgba(0,0,0,0.10)`): Modals, dropdowns, badges that float over content. +- **Hover-lift** (`0 8px 24px rgba(0,0,0,0.12), 0 4px 8px rgba(0,0,0,0.06)`): Cards on hover. Applied via `transition-shadow duration-300`. +- **Accent-glow-teal** (`0 0 20px rgba(97,194,200,0.45), 0 0 40px rgba(97,194,200,0.2)`): Animated pulse on interactive workflow nodes. Not used for static elevation. +- **Accent-glow-orange** (`0 0 20px rgba(249,115,22,0.45), 0 0 40px rgba(249,115,22,0.2)`): Animated pulse on highlighted nodes. +- **CTA-button** (`0 4px 12px rgba(249,115,22,0.3)`): CTA button hover shadow. + +### Named Rules +**The Flat-by-Default Rule.** Cards and containers have no box-shadow at rest — only a `1px border-gray-100` boundary and a background tint distinguishing them from the page surface. Shadows activate exclusively on hover, focus, or explicit "elevated" state. + +--- + +## 5. Components + +### Buttons + +Technical and warm: rounded-full silhouette (approachable), precise internal padding (deliberate), immediate hover feedback (responsive). + +- **Shape:** `border-radius: 9999px` (`rounded-full`) +- **Primary (Action Orange):** `background: #f97316; color: #fff; padding: 0.75rem 1.75rem; font: 600 0.875rem IBM Plex Sans; letter-spacing: 0.01em` +- **Primary Hover/Focus:** `background: #ea6c0a; transform: translateY(-2px) scale(1.02); box-shadow: 0 4px 12px rgba(249,115,22,0.3); transition: all 0.2s ease` +- **Secondary (Ghost):** `background: #fff; color: #374151; border: 2px solid #e5e7eb; padding: 0.625rem 1.5rem` +- **Secondary Hover:** `background: #DAF6F9; color: #40676B; border-color: #78BCC2` +- **Text Link:** `color: #40676B; text-decoration: underline 1px currentColor; underline-offset: 3px` +- **Focus ring:** `outline: 2px solid #40676B; outline-offset: 3px` + +### Cards / Containers + +- **Corner Style:** `border-radius: 1rem` (`rounded-2xl` = 16px) for standard cards; `rounded-xl` (12px) for compact workflow nodes +- **Background:** `#ffffff` on gray-50/100 page surface; `teal-50` for tinted feature cards +- **Border:** `1px solid #e5e7eb` at rest; `border-color: #89D6DC` on hover +- **Shadow Strategy:** None at rest (flat-by-default); `hover-lift` shadow on interactive hover +- **Internal Padding:** `1.5rem` standard; `0.875rem 1.25rem` for compact node cards +- **Hover:** `transform: translateY(-4px); border-color: #89D6DC; box-shadow: 0 8px 24px rgba(0,0,0,0.12); transition: all 0.3s cubic-bezier(0.4,0,0.2,1)` + +### Inputs / Fields + +- **Style:** `background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.75rem; padding: 0.75rem 1rem` +- **Focus:** `background: #fff; outline: none; ring: 2px solid #40676B; border-color: transparent` +- **Validation (invalid):** `outline: 1px solid #61C2C8` +- **Validation message:** `color: #40676B; font-size: 0.75rem; margin-top: 0.25rem` +- **Disabled:** `opacity: 0.5; cursor: not-allowed` + +### Chips / Badges + +- **Section badge:** `background: #DAF6F9; color: #40676B; font: 600 0.875rem IBM Plex Sans; padding: 0.375rem 1rem; border-radius: 9999px` +- **Status badge:** Same shape with contextual tint (teal-50/teal for neutral; `bg-orange-50 text-orange-600` for highlighted) +- **Inverted (on dark):** `background: rgba(255,255,255,0.12); color: #B8EFF4; backdrop-filter: blur(4px)` + +### Navigation + +- **Default:** `color: #374151; font: 500 0.9375rem IBM Plex Sans; text-decoration: none` +- **Hover:** animated underline expanding from center (width 0 → 100%, `transition: 0.25s cubic-bezier(0.4,0,0.2,1)`) +- **Active:** `color: #61C2C8` with persistent underline in `#9ca3af` +- **Scrolled state:** header gains `background: rgba(255,255,255,0.85); backdrop-filter: blur(12px); border-bottom: 1px solid #d1d5db` +- **Mobile:** slide-down menu (`animation: slide-down 0.25s cubic-bezier(0.4,0,0.2,1)`) with hamburger → X icon morph + +### Signature Component: Animated Workflow Node Board + +The hero section's animated node board is the system's most expressive component: white rounded-xl cards representing pipeline steps (data sources, AI filters, output destinations) connected by animated SVG dashed paths. Nodes pulse with a teal or orange glow in staggered sequence (4s ease-in-out, 0.5s delay increment), simulating live data flow. This component communicates the consultant's domain (automation, cloud pipelines) without a single word. Treat it as the visual proof-of-craft standard the rest of the site is held to. + +- **Node card:** `background: #fff; border: 1px solid rgba(0,0,0,0.06); border-radius: 0.75rem; box-shadow: 0 2px 8px rgba(0,0,0,0.08)` +- **Teal node variant:** `border: 1.5px solid rgba(97,194,200,0.4)` → pulses to full `#61C2C8` +- **Orange highlight node:** `border: 2px solid #f97316; box-shadow: 0 4px 16px rgba(249,115,22,0.15)` +- **Connector paths:** SVG `stroke-dasharray: 8,6` in `#9ca3af` (static) or animated teal/orange (`stroke-dasharray: 12,200` running `dash-flow` 4s infinite) +- **Reduced-motion fallback:** Remove all pulse, flow, and float animations; show static node positions with full opacity + +--- + +## 6. Do's and Don'ts + +### Do + +- **Do** use Action Orange (`#f97316`) exclusively for the single primary CTA per screen. Its scarcity is the point. +- **Do** set body text to `#374151` (ink-body) or `#2F4E51` (teal-700) minimum — never lighter — to maintain ≥ 4.5:1 contrast on white. +- **Do** cap display headings at `3.75rem`. Above that is shouting. +- **Do** use `text-wrap: balance` on h1–h3 and `text-wrap: pretty` on prose blocks. +- **Do** stagger animated elements in sequence (0.3s–0.5s increments) so motion reads as flow, not simultaneous burst. +- **Do** wrap all animations in `@media (prefers-reduced-motion: reduce)` with a crossfade or instant-state fallback. +- **Do** use the teal-cyan-aqua scale for all tinted UI surfaces, dark gradients, and dark sidebars — never legacy `cloudzen-steel` (`#2c194d`). +- **Do** use rounded-full silhouettes for all buttons to maintain the approachable-technical balance. +- **Do** ensure interactive cards have a visible hover state (`translateY(-4px)` + border-color shift) with `transition: all 0.3s cubic-bezier(0.4,0,0.2,1)`. +- **Do** keep line length at 65–72ch for reading-flow body sections. + +### Don't + +- **Don't** use orange for anything other than the primary CTA button. No orange headings, orange borders, orange backgrounds on content sections. +- **Don't** use gradient text (`background-clip: text` + gradient). All heading text is a solid color. +- **Don't** introduce a warm cream/beige body background (`#fcf9f5`, `#fdf8f5`, or any OKLCH L > 0.93, C > 0.01, hue 40–100). This is the WarmTealWash experiment pattern — it may exist in variant components but is not the site default surface. +- **Don't** add a third typeface. IBM Plex Sans + Helvetica Neue is the complete system. More than two feels like indecision. +- **Don't** use all-caps for anything longer than 4 words or larger than `0.75rem` body text. +- **Don't** add a colored left-border stripe (`border-left > 1px`) as a card or callout accent. Use background tint or full border instead. +- **Don't** animate layout properties (width, height, top, left). Animate transform and opacity only. +- **Don't** stock-photo or generic-service-bureau the hero — every visual element should demonstrate the consultant's actual domain (cloud, automation, .NET). +- **Don't** use eyebrow labels (small uppercase tracked text above every section heading) as a default scaffold. Use structural variation — leading numbers on genuine sequences, descriptive subheadings, or nothing. +- **Don't** use the legacy `cloudzen-steel` (`#2c194d`) purple on any new component. Replace with `teal-900`/`teal-800` on dark surfaces. +- **Don't** use `z-index: 999` or `z-index: 9999` arbitrary stacking. The established z-scale is: dropdown (10) → sticky header (40) → mobile overlay (45) → modal-backdrop (50) → modal (60) → toast (70) → tooltip (80). +- **Don't** gate content visibility on a class-triggered animation. Elements must be fully visible in their default state; transitions enhance, they don't reveal. diff --git a/Features/Booking/BookingServiceOptions.cs b/Features/Booking/BookingServiceOptions.cs new file mode 100644 index 0000000..d2725c0 --- /dev/null +++ b/Features/Booking/BookingServiceOptions.cs @@ -0,0 +1,44 @@ +namespace CloudZen.Features.Booking; + +/// +/// Configuration options for the appointment booking API endpoint. +/// Bound from the "BookingService" section of appsettings.json. +/// +/// +/// +/// The frontend calls the Azure Functions proxy at /api/book-appointment, +/// which then forwards to the n8n webhook server-side (avoiding CORS issues). +/// +/// +/// In local development, ApiBaseUrl is overridden in Program.cs to point +/// to the local Functions host (e.g. "http://localhost:7257/api"). +/// +/// +public class BookingServiceOptions +{ + /// + /// The configuration section name used to bind these options from appsettings.json. + /// + public const string SectionName = "BookingService"; + + /// + /// Gets or sets the base URL for the booking API backend. + /// Defaults to "/api" for Azure Static Web Apps linked functions. + /// + public string ApiBaseUrl { get; set; } = "/api"; + + /// + /// Gets or sets the booking endpoint path (appended to ). + /// + public string BookEndpoint { get; set; } = "book-appointment"; + + /// + /// HTTP request timeout in seconds. + /// + public int TimeoutSeconds { get; set; } = 30; + + /// + /// Gets the full URL for the book-appointment endpoint. + /// + public string BookAppointmentUrl => $"{ApiBaseUrl.TrimEnd('/')}/{BookEndpoint}"; +} diff --git a/Features/Booking/Components/BookingCalendar.razor b/Features/Booking/Components/BookingCalendar.razor new file mode 100644 index 0000000..cbe1105 --- /dev/null +++ b/Features/Booking/Components/BookingCalendar.razor @@ -0,0 +1,55 @@ + +@* BookingCalendar.razor — Calendar grid with month navigation for date selection. *@ + +
+

Select Date & Time

+ + +
+ + + @DisplayMonth.ToString("MMMM yyyy") + + +
+ + +
+ MonTueWedThuFriSatSun +
+ + +
+ @foreach (var cell in calendarCells) + { + @if (cell == null) + { + + } + else + { + var day = cell.Value; + var date = new DateTime(DisplayMonth.Year, DisplayMonth.Month, day); + var isAvailable = BookingService.IsDateAvailable(date); + var isSelected = SelectedDate.HasValue && SelectedDate.Value == date; + var isToday = date == DateTime.Today; + + + } + } +
+ + +
+ +
+
diff --git a/Features/Booking/Components/BookingCalendar.razor.cs b/Features/Booking/Components/BookingCalendar.razor.cs new file mode 100644 index 0000000..d5003b9 --- /dev/null +++ b/Features/Booking/Components/BookingCalendar.razor.cs @@ -0,0 +1,43 @@ +using CloudZen.Features.Booking.Services; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Booking.Components; + +/// +/// Code-behind for BookingCalendar.razor — calendar grid with month navigation. +/// +public partial class BookingCalendar +{ + [Parameter, EditorRequired] public DateTime DisplayMonth { get; set; } + [Parameter] public DateTime? SelectedDate { get; set; } + [Parameter] public string TimeZoneLabel { get; set; } = string.Empty; + [Parameter] public EventCallback OnDateSelected { get; set; } + [Parameter] public EventCallback OnDisplayMonthChanged { get; set; } + [Parameter] public EventCallback<(string Id, string Label)> OnTimeZoneChanged { get; set; } + + [Inject] private IBookingService BookingService { get; set; } = default!; + + private int?[] calendarCells => BookingService.BuildCalendarCells(DisplayMonth); + + private void PreviousMonth() + { + if (!BookingService.IsPreviousMonthDisabled(DisplayMonth)) + OnDisplayMonthChanged.InvokeAsync(DisplayMonth.AddMonths(-1)); + } + + private void NextMonth() => OnDisplayMonthChanged.InvokeAsync(DisplayMonth.AddMonths(1)); + + private static string GetDayCss(bool isAvailable, bool isSelected, bool isToday) + { + const string baseClass = "w-9 h-9 mx-auto rounded-full text-sm flex items-center justify-center transition"; + + if (isSelected) + return $"{baseClass} bg-teal-cyan-aqua-600 text-white font-bold"; + if (!isAvailable) + return $"{baseClass} text-gray-300 cursor-default"; + if (isToday) + return $"{baseClass} border-2 border-teal-cyan-aqua-600 text-teal-cyan-aqua-600 font-semibold hover:bg-teal-cyan-aqua-50 cursor-pointer"; + + return $"{baseClass} text-teal-cyan-aqua-600 font-semibold hover:bg-teal-cyan-aqua-50 cursor-pointer"; + } +} diff --git a/Features/Booking/Components/BookingConfirmation.razor b/Features/Booking/Components/BookingConfirmation.razor new file mode 100644 index 0000000..e938665 --- /dev/null +++ b/Features/Booking/Components/BookingConfirmation.razor @@ -0,0 +1,60 @@ + +@* BookingConfirmation.razor — Step 3 success confirmation. *@
+
+
+
+ +
+
+ +

Meeting Scheduled!

+

+ Thank you, @FullName! +

+ + @if (!string.IsNullOrWhiteSpace(BookingId)) + { +
+ + Booking ID: @BookingId +
+ } + +

+ Your 30-minute CloudZen Virtual Meeting is booked for + @TimeSlotRange on + @SelectedDate.ToString("dddd, MMMM dd, yyyy"). + We'll send a confirmation to @Email. +

+ +
+

What happens next?

+
    +
  • + 1 + You'll receive a calendar invite & meeting link +
  • +
  • + 2 + Our team will prepare for your consultation +
  • +
  • + 3 + Join the meeting & explore AI solutions for your business +
  • +
+
+ +
+ + + + Manage Appointment + +
+
diff --git a/Features/Booking/Components/BookingConfirmation.razor.cs b/Features/Booking/Components/BookingConfirmation.razor.cs new file mode 100644 index 0000000..7394001 --- /dev/null +++ b/Features/Booking/Components/BookingConfirmation.razor.cs @@ -0,0 +1,34 @@ +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Booking.Components; + +/// +/// Code-behind for BookingConfirmation.razor — Step 3 success confirmation. +/// Displays the confirmed booking details including the n8n-assigned booking ID. +/// +public partial class BookingConfirmation +{ + /// Full name of the person who booked the appointment. + [Parameter, EditorRequired] public string FullName { get; set; } = string.Empty; + + /// Email address where the calendar invite will be sent. + [Parameter, EditorRequired] public string Email { get; set; } = string.Empty; + + /// Formatted time slot range (e.g. "02:30 PM - 03:00 PM"). + [Parameter, EditorRequired] public string TimeSlotRange { get; set; } = string.Empty; + + /// The confirmed appointment date. + [Parameter, EditorRequired] public DateTime SelectedDate { get; set; } + + /// + /// The booking confirmation ID returned by the n8n workflow (e.g. "APT-MN7O3825-TMVP"). + /// Displayed to the user as a reference for their appointment. + /// + [Parameter] public string? BookingId { get; set; } + + /// + /// Callback invoked when the user clicks "Schedule Another Meeting" + /// to reset the booking flow back to Step 1. + /// + [Parameter] public EventCallback OnReset { get; set; } +} diff --git a/Features/Booking/Components/BookingContact.razor b/Features/Booking/Components/BookingContact.razor new file mode 100644 index 0000000..e4d01ec --- /dev/null +++ b/Features/Booking/Components/BookingContact.razor @@ -0,0 +1,78 @@ + +@* ============================================================================= + BookingContact.razor — Multi-step scheduling & contact orchestrator + Holds state and composes child components via parameters / EventCallbacks. + Flow: + Step 1: Select Date & Time → BookingSidebar, BookingCalendar, BookingTimeSlots + Step 2: Enter Details → BookingSidebar, BookingDetailsForm + Step 3: Confirmation → BookingConfirmation + ============================================================================= *@ + +
+
+ + +
+

Get In Touch

+
+

What Can We Help You With Today?

+
+ + +
+ + @* ── STEP 1: Select Date & Time ──────────────────────────────── *@ + @if (currentStep == Step.SelectDateTime) + { +
+ + + + + @if (selectedDate.HasValue) + { + + } +
+ } + + @* ── STEP 2: Enter Details ───────────────────────────────────── *@ + @if (currentStep == Step.EnterDetails) + { +
+ + + +
+ } + + @* ── STEP 3: Confirmation ────────────────────────────────────── *@ + @if (currentStep == Step.Confirmation) + { + + } +
+
+
diff --git a/Features/Booking/Components/BookingContact.razor.cs b/Features/Booking/Components/BookingContact.razor.cs new file mode 100644 index 0000000..bb25e41 --- /dev/null +++ b/Features/Booking/Components/BookingContact.razor.cs @@ -0,0 +1,207 @@ +using CloudZen.Features.Booking.Models; +using CloudZen.Features.Booking.Services; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Booking.Components; + +/// +/// Code-behind for BookingContact.razor — thin orchestrator holding booking flow state. +/// Composes BookingSidebar, BookingCalendar, BookingTimeSlots, BookingDetailsForm, +/// and BookingConfirmation via parameters and EventCallbacks. +/// +public partial class BookingContact +{ + /// Service for sending appointment bookings to the n8n webhook. + [Inject] private IAppointmentService AppointmentService { get; set; } = default!; + + /// Service for calendar logic, date availability, and time formatting. + [Inject] private IBookingService BookingService { get; set; } = default!; + + // ── State ──────────────────────────────────────────────────────────── + + /// Defines the three steps in the booking wizard flow. + private enum Step { SelectDateTime, EnterDetails, Confirmation } + + /// The currently active wizard step. + private Step currentStep = Step.SelectDateTime; + + /// First day of the month currently shown in the calendar grid. + private DateTime displayMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1); + + /// The date the user selected in the calendar (Step 1). + private DateTime? selectedDate; + + /// The 12-hour time slot the user selected (e.g. "01:00 PM"). + private string? selectedTime; + + /// Form model bound to the details form in Step 2. + private BookingFormModel bookingForm = new(); + + /// Indicates whether an appointment booking request is in flight. + private bool isSubmitting; + + /// User-facing error message displayed when a booking attempt fails. + private string? errorMessage; + + /// Indicates the last failure was a scheduling conflict (slot already booked). + private bool isSlotTaken; + + /// Display label for the selected time zone (e.g. "GMT-05:00 Eastern Standard Time"). + private string timeZoneLabel = string.Empty; + + /// + /// The booking confirmation ID returned by the n8n workflow after a successful booking. + /// Passed to in Step 3. + /// + private string? confirmedBookingId; + + /// + /// Initializes the default time zone label on first render. + /// + protected override void OnInitialized() + { + timeZoneLabel = BookingService.GetLocalTimeZoneLabel(); + } + + // ── Step 1 handlers ────────────────────────────────────────────────── + + /// + /// Handles a date selection from . + /// Resets since a new date invalidates any prior time pick. + /// + /// The newly selected calendar date. + private void SelectDate(DateTime date) + { + selectedDate = date; + selectedTime = null; + } + + /// Stores the time slot selected by the user in . + private void SelectTime(string time) => selectedTime = time; + + /// Updates the calendar grid to display a different month. + private void SetDisplayMonth(DateTime month) => displayMonth = month; + + /// + /// Handles a time zone change from . + /// Updates the display label shown in the sidebar. + /// + /// Tuple of the selected time zone ID and its formatted display label. + private void HandleTimeZoneChanged((string Id, string Label) tz) + { + timeZoneLabel = tz.Label; + } + + /// + /// Advances from Step 1 (date/time selection) to Step 2 (enter details) + /// when both and are set. + /// + private void ConfirmDateTime() + { + if (selectedDate.HasValue && selectedTime is not null) + currentStep = Step.EnterDetails; + } + + /// + /// Returns from Step 2 to Step 1, clearing any prior error message. + /// + private void GoBackToCalendar() + { + errorMessage = null; + isSlotTaken = false; + currentStep = Step.SelectDateTime; + } + + // ── Form submission ────────────────────────────────────────────────── + + /// + /// Builds a from the current form state + /// and sends it to the n8n webhook via . + /// On success, transitions to Step 3 (confirmation). + /// On slot-taken or failure, displays an error and keeps the user on Step 2. + /// + private async Task HandleBookingSubmit() + { + isSubmitting = true; + errorMessage = null; + isSlotTaken = false; + + try + { + var request = new BookAppointmentRequest + { + Name = bookingForm.FullName!, + Email = bookingForm.Email!, + Phone = NormalizePhone(bookingForm.Phone!), + BusinessName = bookingForm.BusinessName!, + Date = selectedDate!.Value.ToString("yyyy-MM-dd"), + Time = BookingService.FormatTimeTo24Hour(selectedTime!), + EndTime = BookingService.FormatEndTimeTo24Hour(selectedTime!), + Reason = string.IsNullOrWhiteSpace(bookingForm.Reason) + ? "CloudZen Virtual Meeting" + : bookingForm.Reason + }; + + var result = await AppointmentService.BookAsync(request); + + if (result.Success) + { + confirmedBookingId = result.BookingId; + currentStep = Step.Confirmation; + } + else + { + errorMessage = result.Error ?? "We couldn't schedule your meeting. Please try again."; + isSlotTaken = result.IsSlotTaken; + } + } + catch + { + errorMessage = "Something went wrong. Please try again later."; + } + finally + { + isSubmitting = false; + } + } + + /// + /// Resets all booking state back to Step 1 defaults, allowing the user + /// to schedule another meeting. + /// + private void ResetBooking() + { + bookingForm = new BookingFormModel(); + selectedDate = null; + selectedTime = null; + displayMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); + currentStep = Step.SelectDateTime; + errorMessage = null; + isSlotTaken = false; + confirmedBookingId = null; + timeZoneLabel = BookingService.GetLocalTimeZoneLabel(); + } + + /// + /// Ensures the phone number is in E.164 format for Twilio compatibility. + /// Prepends "+1" (US) if no country code is present. + /// + /// The raw phone number entered by the user. + /// + /// The phone number in E.164 format (e.g. "+15551234567"). + /// If the input already starts with "+", digits are preserved as-is. + /// For 10-digit US numbers, "+1" is prepended automatically. + /// + private static string NormalizePhone(string phone) + { + var digits = new string(phone.Where(char.IsDigit).ToArray()); + + if (phone.StartsWith('+')) + return $"+{digits}"; + + // Default to US country code if none provided + return digits.Length == 10 + ? $"+1{digits}" + : $"+{digits}"; + } +} diff --git a/Features/Booking/Components/BookingDetailsForm.razor b/Features/Booking/Components/BookingDetailsForm.razor new file mode 100644 index 0000000..44ff0f9 --- /dev/null +++ b/Features/Booking/Components/BookingDetailsForm.razor @@ -0,0 +1,153 @@ +@using System.ComponentModel.DataAnnotations + +
+

Enter Details

+ + + + + +
+ + + +
+ + +
+ +
+ 🇺🇸 + +
+ +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + + +
+ + +
+ + +
+ + + @if (!string.IsNullOrEmpty(ErrorMessage)) + { + @if (IsSlotTaken) + { + + } + else + { + + } + } + + +
+ +
+
+
diff --git a/Features/Booking/Components/BookingDetailsForm.razor.cs b/Features/Booking/Components/BookingDetailsForm.razor.cs new file mode 100644 index 0000000..f16450b --- /dev/null +++ b/Features/Booking/Components/BookingDetailsForm.razor.cs @@ -0,0 +1,22 @@ +using CloudZen.Features.Booking.Models; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Booking.Components; + +/// +/// Code-behind for BookingDetailsForm.razor — Step 2 form for entering booking details. +/// +public partial class BookingDetailsForm +{ + [Parameter, EditorRequired] public BookingFormModel FormModel { get; set; } = default!; + [Parameter] public bool IsSubmitting { get; set; } + [Parameter] public string? ErrorMessage { get; set; } + + /// Indicates the error is a scheduling conflict (slot already booked). + [Parameter] public bool IsSlotTaken { get; set; } + + [Parameter] public EventCallback OnValidSubmit { get; set; } + + /// Callback invoked when the user clicks "Choose a different time" after a slot-taken error. + [Parameter] public EventCallback OnChooseDifferentTime { get; set; } +} diff --git a/Features/Booking/Components/BookingSidebar.razor b/Features/Booking/Components/BookingSidebar.razor new file mode 100644 index 0000000..c8e455c --- /dev/null +++ b/Features/Booking/Components/BookingSidebar.razor @@ -0,0 +1,51 @@ +@* BookingSidebar.razor — Left sidebar with meeting info, shown in Steps 1 & 2. *@ + +
+ + @if (ShowBackButton) + { + + } + else + { + CloudZen Logo + } + + Bookings +

CloudZen Virtual Meeting

+ +
+ + 30 Mins +
+ + @if (SelectedDate.HasValue) + { +
+ + @if (!string.IsNullOrEmpty(TimeSlotRange)) + { + @TimeSlotRange@(", ") + } + @SelectedDate.Value.ToString("ddd, MMM dd, yyyy") +
+ } + + @if (!string.IsNullOrEmpty(TimeZoneLabel) && ShowBackButton) + { +
+ + @TimeZoneLabel +
+ } + + @if (!ShowBackButton) + { +

+ Schedule a 30 minute virtual meeting to speak with one of our team members to see how CloudZen can bring AI Solutions to your business! +

+ } +
+ diff --git a/Features/Booking/Components/BookingSidebar.razor.cs b/Features/Booking/Components/BookingSidebar.razor.cs new file mode 100644 index 0000000..a6d21cf --- /dev/null +++ b/Features/Booking/Components/BookingSidebar.razor.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Booking.Components; + +/// +/// Code-behind for BookingSidebar.razor — left sidebar with meeting info. +/// +public partial class BookingSidebar +{ + [Parameter] public DateTime? SelectedDate { get; set; } + [Parameter] public string? TimeSlotRange { get; set; } + [Parameter] public string? TimeZoneLabel { get; set; } + [Parameter] public bool ShowBackButton { get; set; } + [Parameter] public EventCallback OnBackClicked { get; set; } +} diff --git a/Features/Booking/Components/BookingTimeSlots.razor b/Features/Booking/Components/BookingTimeSlots.razor new file mode 100644 index 0000000..6844880 --- /dev/null +++ b/Features/Booking/Components/BookingTimeSlots.razor @@ -0,0 +1,20 @@ +@* BookingTimeSlots.razor — Time slot selection panel. *@ + +
+ @foreach (var slot in TimeSlots) + { + var isSelectedSlot = SelectedTime == slot; + + @if (isSelectedSlot && OnConfirmed.HasDelegate) + { + + } + } +
+ diff --git a/Features/Booking/Components/BookingTimeSlots.razor.cs b/Features/Booking/Components/BookingTimeSlots.razor.cs new file mode 100644 index 0000000..b99531a --- /dev/null +++ b/Features/Booking/Components/BookingTimeSlots.razor.cs @@ -0,0 +1,23 @@ +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Booking.Components; + +/// +/// Code-behind for BookingTimeSlots.razor — time slot selection panel. +/// +public partial class BookingTimeSlots +{ + [Parameter, EditorRequired] public string[] TimeSlots { get; set; } = []; + [Parameter] public string? SelectedTime { get; set; } + [Parameter] public EventCallback OnTimeSelected { get; set; } + [Parameter] public EventCallback OnConfirmed { get; set; } + + private static string GetTimeSlotCss(bool isSelected) + { + const string baseClass = "px-3 py-2 rounded-lg text-sm font-semibold border transition text-center"; + + return isSelected + ? $"{baseClass} bg-teal-cyan-aqua-600 text-white border-teal-cyan-aqua-600" + : $"{baseClass} border-teal-cyan-aqua-600 text-teal-cyan-aqua-600 hover:bg-teal-cyan-aqua-50"; + } +} diff --git a/Features/Booking/Components/BookingTimeZonePicker.razor b/Features/Booking/Components/BookingTimeZonePicker.razor new file mode 100644 index 0000000..db34524 --- /dev/null +++ b/Features/Booking/Components/BookingTimeZonePicker.razor @@ -0,0 +1,36 @@ +@* BookingTimeZonePicker.razor — Searchable time zone dropdown. *@ + +
+ Time zone + + + @if (isOpen) + { +
+
+
+ +
+
+ @foreach (var tz in FilteredTimeZones) + { + var isSelected = tz.Id == selectedTimeZoneId; + + } +
+
+ } +
+ diff --git a/Features/Booking/Components/BookingTimeZonePicker.razor.cs b/Features/Booking/Components/BookingTimeZonePicker.razor.cs new file mode 100644 index 0000000..020a81f --- /dev/null +++ b/Features/Booking/Components/BookingTimeZonePicker.razor.cs @@ -0,0 +1,48 @@ +using CloudZen.Features.Booking.Services; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Booking.Components; + +/// +/// Code-behind for BookingTimeZonePicker.razor — searchable time zone dropdown. +/// +public partial class BookingTimeZonePicker +{ + [Parameter, EditorRequired] public string TimeZoneLabel { get; set; } = string.Empty; + [Parameter] public EventCallback<(string Id, string Label)> OnTimeZoneChanged { get; set; } + + [Inject] private IBookingService BookingService { get; set; } = default!; + + private bool isOpen; + private string searchText = string.Empty; + private string selectedTimeZoneId = TimeZoneInfo.Local.Id; + + private static readonly TimeZoneInfo[] allTimeZones = TimeZoneInfo.GetSystemTimeZones().ToArray(); + + private IEnumerable FilteredTimeZones => + string.IsNullOrWhiteSpace(searchText) + ? allTimeZones + : allTimeZones.Where(tz => + BookingService.FormatTimeZoneOption(tz).Contains(searchText, StringComparison.OrdinalIgnoreCase)); + + private void ToggleDropdown() + { + isOpen = !isOpen; + if (isOpen) + searchText = string.Empty; + } + + private void SelectTimeZone(TimeZoneInfo tz) + { + selectedTimeZoneId = tz.Id; + isOpen = false; + searchText = string.Empty; + OnTimeZoneChanged.InvokeAsync((tz.Id, BookingService.FormatTimeZoneOption(tz))); + } + + private void CloseDropdown() + { + isOpen = false; + searchText = string.Empty; + } +} diff --git a/Features/Booking/Components/ManageAppointmentCancel.razor b/Features/Booking/Components/ManageAppointmentCancel.razor new file mode 100644 index 0000000..a760dbe --- /dev/null +++ b/Features/Booking/Components/ManageAppointmentCancel.razor @@ -0,0 +1,126 @@ +@using CloudZen.Features.Booking.Models +@using CloudZen.Features.Booking.Services + +
+ @if (isConfirmed) + { + +
+
+
+
+ +
+
+ +

Appointment Cancelled

+

+ Your appointment @cancelForm.BookingId has been successfully cancelled. + A confirmation email has been sent to @cancelForm.Email. +

+ + +
+ } + else + { +
+
+
+ +
+
+

Cancel Appointment

+

Enter your booking details to cancel your appointment

+
+
+ + + + + +
+ + + +

You can find this in your confirmation email

+
+ + +
+ + + +
+ + + @if (!string.IsNullOrEmpty(errorMessage)) + { + + } + + +
+
+ +
+

This action cannot be undone

+

Once cancelled, you will need to book a new appointment if you change your mind.

+
+
+
+ + + +
+
+ } +
diff --git a/Features/Booking/Components/ManageAppointmentCancel.razor.cs b/Features/Booking/Components/ManageAppointmentCancel.razor.cs new file mode 100644 index 0000000..b3abba7 --- /dev/null +++ b/Features/Booking/Components/ManageAppointmentCancel.razor.cs @@ -0,0 +1,89 @@ +using CloudZen.Features.Booking.Models; +using CloudZen.Features.Booking.Services; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Booking.Components; + +/// +/// Code-behind for ManageAppointmentCancel.razor — handles appointment cancellation flow. +/// Manages form state, validation, and communication with the appointment service. +/// +/// +/// Single Responsibility: Manages only the cancellation workflow state and user interactions. +/// Dependency Inversion: Depends on abstraction, not concrete implementation. +/// +public partial class ManageAppointmentCancel +{ + // ── Dependencies ────────────────────────────────────────────────────── + + /// + /// Service for appointment operations (cancel, reschedule, book). + /// Injected via DI; depends on abstraction per Dependency Inversion Principle. + /// + [Inject] private IAppointmentService AppointmentService { get; set; } = default!; + + // ── State ───────────────────────────────────────────────────────────── + + /// Form model bound to the cancellation form inputs. + private CancelFormModel cancelForm = new(); + + /// Indicates whether a cancellation request is currently in flight. + private bool isSubmitting; + + /// Indicates whether the cancellation was successful (shows confirmation UI). + private bool isConfirmed; + + /// User-facing error message displayed when cancellation fails. + private string? errorMessage; + + // ── Event Handlers ──────────────────────────────────────────────────── + + /// + /// Handles the form submission for appointment cancellation. + /// Validates input, calls the appointment service, and updates UI state accordingly. + /// + /// A task representing the asynchronous operation. + private async Task HandleCancel() + { + isSubmitting = true; + errorMessage = null; + + try + { + var request = new CancelAppointmentRequest + { + BookingId = cancelForm.BookingId!, + Email = cancelForm.Email! + }; + + var result = await AppointmentService.CancelAsync(request); + + if (result.Success) + { + isConfirmed = true; + } + else + { + errorMessage = result.Error ?? "We couldn't cancel your appointment. Please try again."; + } + } + catch + { + errorMessage = "Something went wrong. Please try again later."; + } + finally + { + isSubmitting = false; + } + } + + /// + /// Resets the component to its initial state, allowing the user to manage another appointment. + /// + private void Reset() + { + cancelForm = new CancelFormModel(); + isConfirmed = false; + errorMessage = null; + } +} diff --git a/Features/Booking/Components/ManageAppointmentReschedule.razor b/Features/Booking/Components/ManageAppointmentReschedule.razor new file mode 100644 index 0000000..7f44951 --- /dev/null +++ b/Features/Booking/Components/ManageAppointmentReschedule.razor @@ -0,0 +1,226 @@ +@using CloudZen.Features.Booking.Models +@using CloudZen.Features.Booking.Services + +
+ @if (isConfirmed) + { + +
+
+
+
+ +
+
+ +

Appointment Rescheduled!

+

+ Your appointment @rescheduleForm.BookingId has been rescheduled. +

+

+ New time: @FormatSlotRange(selectedTime) on @selectedDate?.ToString("dddd, MMMM dd, yyyy") +

+ + +
+ } + else if (currentStep == Step.EnterDetails) + { + +
+
+
+ +
+
+

Reschedule Appointment

+

Step 1: Enter your booking details

+
+
+ + + + + +
+ + + +

You can find this in your confirmation email

+
+ + +
+ + + +
+ + + @if (!string.IsNullOrEmpty(errorMessage)) + { + + } + + + +
+
+ } + else + { + +
+ +
+
+ + Rescheduling + +

Select New Time

+

Booking: @rescheduleForm.BookingId

+
+ + @if (selectedDate.HasValue) + { +
+
+ + @selectedDate.Value.ToString("dddd, MMMM dd, yyyy") +
+ @if (!string.IsNullOrEmpty(selectedTime)) + { +
+ + @FormatSlotRange(selectedTime) +
+ } +
+ } +
+ + +
+
+ + Step 2 of 2 +
+ + +
+ +
+ +
+ + + @if (selectedDate.HasValue) + { +
+ +
+ } +
+ + + @if (!string.IsNullOrEmpty(errorMessage)) + { + + } + + + @if (selectedDate.HasValue && !string.IsNullOrEmpty(selectedTime)) + { +
+ +
+ } +
+
+ } +
diff --git a/Features/Booking/Components/ManageAppointmentReschedule.razor.cs b/Features/Booking/Components/ManageAppointmentReschedule.razor.cs new file mode 100644 index 0000000..19bd1d0 --- /dev/null +++ b/Features/Booking/Components/ManageAppointmentReschedule.razor.cs @@ -0,0 +1,239 @@ +using CloudZen.Features.Booking.Models; +using CloudZen.Features.Booking.Services; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Booking.Components; + +/// +/// Code-behind for ManageAppointmentReschedule.razor — handles appointment rescheduling flow. +/// Manages a two-step wizard: (1) enter booking details, (2) select new date/time. +/// +/// +/// Single Responsibility: Manages only the rescheduling workflow state and user interactions. +/// Dependency Inversion: Depends on and abstractions. +/// Open/Closed: New steps can be added by extending the enum without modifying existing logic. +/// +public partial class ManageAppointmentReschedule +{ + // ── Dependencies ────────────────────────────────────────────────────── + + /// + /// Service for appointment operations (cancel, reschedule, book). + /// Injected via DI; depends on abstraction per Dependency Inversion Principle. + /// + [Inject] private IAppointmentService AppointmentService { get; set; } = default!; + + /// + /// Service for calendar logic, date availability, time formatting, and time zone handling. + /// + [Inject] private IBookingService BookingService { get; set; } = default!; + + // ── State: Wizard Flow ──────────────────────────────────────────────── + + /// Defines the steps in the rescheduling wizard flow. + private enum Step { EnterDetails, SelectDateTime } + + /// The currently active wizard step. + private Step currentStep = Step.EnterDetails; + + // ── State: Form Data ────────────────────────────────────────────────── + + /// Form model bound to the reschedule form inputs (booking ID and email). + private RescheduleFormModel rescheduleForm = new(); + + /// First day of the month currently shown in the calendar grid. + private DateTime displayMonth = new(DateTime.Today.Year, DateTime.Today.Month, 1); + + /// The date the user selected in the calendar. + private DateTime? selectedDate; + + /// The 12-hour time slot the user selected (e.g. "01:00 PM"). + private string? selectedTime; + + /// Display label for the selected time zone (e.g. "GMT-05:00 America/New_York (EST)"). + private string timeZoneLabel = string.Empty; + + // ── State: UI Feedback ──────────────────────────────────────────────── + + /// Indicates whether a reschedule request is currently in flight. + private bool isSubmitting; + + /// Indicates whether the reschedule was successful (shows confirmation UI). + private bool isConfirmed; + + /// User-facing error message displayed when rescheduling fails. + private string? errorMessage; + + // ── Lifecycle ───────────────────────────────────────────────────────── + + /// + /// Initializes the default time zone label on first render. + /// + protected override void OnInitialized() + { + timeZoneLabel = BookingService.GetLocalTimeZoneLabel(); + } + + // ── Step Navigation ─────────────────────────────────────────────────── + + /// + /// Advances from Step 1 (enter details) to Step 2 (select date/time). + /// Validates that the booking ID exists in the database before proceeding. + /// + private async Task GoToSelectDateTime() + { + errorMessage = null; + isSubmitting = true; + + try + { + //Create request to verify booking exists with provided ID and email first before showing calendar. + // If the booking doesn't exist, we can show an error immediately instead of showing an empty calendar with no available slots. + var request = new VerifyBookingRequest + { + BookingId = rescheduleForm.BookingId!, + Email = rescheduleForm.Email! + }; + + var result = await AppointmentService.VerifyBookingExistsAsync(request); + + if (result.Success) + { + // Booking exists, proceed to step 2 + currentStep = Step.SelectDateTime; + } + else + { + // Booking not found or other error - show error + errorMessage = result.Error + ?? "We couldn't find an appointment with that Booking ID. Please check your confirmation email for the correct Booking ID and try again."; + } + } + catch + { + errorMessage = "Something went wrong. Please try again later."; + } + finally + { + isSubmitting = false; + } + } + + /// + /// Returns from Step 2 (select date/time) back to Step 1 (enter details). + /// + private void GoBackToDetails() + { + errorMessage = null; + currentStep = Step.EnterDetails; + } + + // ── Date/Time Selection Handlers ────────────────────────────────────── + + /// + /// Handles a date selection from . + /// Resets since a new date invalidates any prior time pick. + /// + /// The newly selected calendar date. + private void SelectDate(DateTime date) + { + selectedDate = date; + selectedTime = null; + } + + /// + /// Stores the time slot selected by the user in . + /// + /// The selected time slot (e.g. "10:00 AM"). + private void SelectTime(string time) => selectedTime = time; + + /// + /// Updates the calendar grid to display a different month. + /// + /// The first day of the month to display. + private void SetDisplayMonth(DateTime month) => displayMonth = month; + + /// + /// Handles a time zone change from . + /// Updates the display label shown in the sidebar. + /// + /// Tuple of the selected time zone ID and its formatted display label. + private void HandleTimeZoneChanged((string Id, string Label) tz) + { + timeZoneLabel = tz.Label; + } + + // ── Formatting Helpers ──────────────────────────────────────────────── + + /// + /// Formats a time slot into a display range (e.g. "10:00 AM - 10:30 AM"). + /// Delegates to . + /// + /// The start time of the slot. + /// Formatted time range string. + private string FormatSlotRange(string? time) + { + return BookingService.FormatSlotRange(time); + } + + // ── Form Submission ─────────────────────────────────────────────────── + + /// + /// Handles the final form submission for appointment rescheduling. + /// Validates state, calls the appointment service, and updates UI accordingly. + /// + /// A task representing the asynchronous operation. + private async Task HandleReschedule() + { + if (!selectedDate.HasValue || string.IsNullOrEmpty(selectedTime)) + return; + + isSubmitting = true; + errorMessage = null; + + try + { + var request = new RescheduleAppointmentRequest + { + BookingId = rescheduleForm.BookingId!, + Email = rescheduleForm.Email!, + NewDate = selectedDate.Value.ToString("yyyy-MM-dd"), + NewTime = BookingService.FormatTimeTo24Hour(selectedTime), + NewEndTime = BookingService.FormatEndTimeTo24Hour(selectedTime) + }; + + var result = await AppointmentService.RescheduleAsync(request); + + if (result.Success) + { + isConfirmed = true; + } + else + { + errorMessage = result.Error ?? "We couldn't reschedule your appointment. Please try again."; + } + } + catch + { + errorMessage = "Something went wrong. Please try again later."; + } + finally + { + isSubmitting = false; + } + } + + /// + /// Resets the component to its initial state, allowing the user to manage another appointment. + /// + private void Reset() + { + rescheduleForm = new RescheduleFormModel(); + selectedDate = null; + selectedTime = null; + displayMonth = new DateTime(DateTime.Today.Year, DateTime.Today.Month, 1); + currentStep = Step.EnterDetails; + isConfirmed = false; + errorMessage = null; + } +} diff --git a/Features/Booking/Models/AppointmentRequests.cs b/Features/Booking/Models/AppointmentRequests.cs new file mode 100644 index 0000000..963f366 --- /dev/null +++ b/Features/Booking/Models/AppointmentRequests.cs @@ -0,0 +1,111 @@ +using System.Text.Json.Serialization; + +namespace CloudZen.Features.Booking.Models; + +/// +/// Request to book a new appointment. +/// +public sealed record BookAppointmentRequest +{ + /// Full name of the person booking the appointment. + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// Email address for calendar invites and confirmations. + [JsonPropertyName("email")] + public required string Email { get; init; } + + /// Phone in E.164 format (e.g. "+15551234567"). + [JsonPropertyName("phone")] + public required string Phone { get; init; } + + /// Name of the business or organization. + [JsonPropertyName("businessName")] + public required string BusinessName { get; init; } + + /// Appointment date in YYYY-MM-DD format. + [JsonPropertyName("date")] + public required string Date { get; init; } + + /// Start time in HH:mm 24-hour format. + [JsonPropertyName("time")] + public required string Time { get; init; } + + /// End time in HH:mm 24-hour format. + [JsonPropertyName("endTime")] + public required string EndTime { get; init; } + + /// Reason for the appointment. + [JsonPropertyName("reason")] + public string Reason { get; init; } = "CloudZen Meeting Request"; + + /// Workflow action (always "book" for this request type). + [JsonPropertyName("action")] + public string Action => "book"; +} + +/// +/// Request to cancel an existing appointment. +/// +public sealed record CancelAppointmentRequest +{ + /// The booking ID to cancel (e.g. "APT-MN7O3825-TMVP"). + [JsonPropertyName("bookingId")] + public required string BookingId { get; init; } + + /// Email address associated with the booking. + [JsonPropertyName("email")] + public required string Email { get; init; } + + /// Workflow action (always "cancel" for this request type). + [JsonPropertyName("action")] + public string Action => "cancel"; +} + +/// +/// Request to reschedule an existing appointment. +/// +public sealed record RescheduleAppointmentRequest +{ + /// The booking ID to reschedule (e.g. "APT-MN7O3825-TMVP"). + [JsonPropertyName("bookingId")] + public required string BookingId { get; init; } + + /// Email address associated with the booking. + [JsonPropertyName("email")] + public required string Email { get; init; } + + /// New date in YYYY-MM-DD format. + [JsonPropertyName("newDate")] + public required string NewDate { get; init; } + + /// New start time in HH:mm 24-hour format. + [JsonPropertyName("newTime")] + public required string NewTime { get; init; } + + /// New end time in HH:mm 24-hour format. + [JsonPropertyName("newEndTime")] + public required string NewEndTime { get; init; } + + /// Workflow action (always "reschedule" for this request type). + [JsonPropertyName("action")] + public string Action => "reschedule"; +} + +/// +/// Request to verify if a booking exists. +/// +public sealed record VerifyBookingRequest +{ + /// The booking ID to verify (e.g. "APT-MN7O3825-TMVP"). + [JsonPropertyName("bookingId")] + public required string BookingId { get; init; } + + /// Email address associated with the booking. + [JsonPropertyName("email")] + public required string Email { get; init; } + + /// Workflow action (always "verify" for this request type). + [JsonPropertyName("action")] + public string Action => "verify"; +} diff --git a/Features/Booking/Models/AppointmentResponse.cs b/Features/Booking/Models/AppointmentResponse.cs new file mode 100644 index 0000000..54ae574 --- /dev/null +++ b/Features/Booking/Models/AppointmentResponse.cs @@ -0,0 +1,99 @@ +namespace CloudZen.Features.Booking.Models; + +/// +/// Unified response for all appointment operations (book, cancel, reschedule). +/// Includes HTTP status code from the N8N workflow response. +/// +public sealed class AppointmentResponse +{ + /// HTTP status code from the API/N8N response. + public int StatusCode { get; init; } + + /// Indicates whether the operation was successful. + public bool Success { get; init; } + + /// + /// The unique booking confirmation ID (e.g. "APT-MN7O3825-TMVP"). + /// Populated on successful book operations. + /// + public string? BookingId { get; init; } + + /// Human-readable message from the workflow. + public string? Message { get; init; } + + /// Human-readable error description when is false. + public string? Error { get; init; } + + /// The action that was performed (book, cancel, reschedule). + public string? Action { get; init; } + + /// + /// Failure was caused by a scheduling conflict (time slot already booked). + /// When true, the UI should offer the user a way to pick a different time. + /// + public bool IsSlotTaken { get; init; } + + /// + /// Booking was not found (for cancel/reschedule operations). + /// + public bool IsNotFound { get; init; } + + /// Indicates a network or timeout error occurred. + public bool IsNetworkError { get; init; } + + // ── Factory Methods ────────────────────────────────────────────────── + + /// Creates a successful booking confirmation response. + public static AppointmentResponse Confirmed(int statusCode, string bookingId, string? message = null) => new() + { + StatusCode = statusCode, + Success = true, + BookingId = bookingId, + Message = message, + Action = "book" + }; + + /// Creates a successful cancel/reschedule response. + public static AppointmentResponse Ok(int statusCode, string action, string? message = null) => new() + { + StatusCode = statusCode, + Success = true, + Message = message, + Action = action + }; + + /// Creates a slot-taken failure response. + public static AppointmentResponse SlotTaken(int statusCode, string error) => new() + { + StatusCode = statusCode, + Success = false, + Error = error, + IsSlotTaken = true + }; + + /// Creates a not-found failure response. + public static AppointmentResponse NotFound(int statusCode, string error) => new() + { + StatusCode = statusCode, + Success = false, + Error = error, + IsNotFound = true + }; + + /// Creates a network/timeout error response. + public static AppointmentResponse NetworkError(string error) => new() + { + StatusCode = 0, + Success = false, + Error = error, + IsNetworkError = true + }; + + /// Creates a general failure response. + public static AppointmentResponse Fail(int statusCode, string error) => new() + { + StatusCode = statusCode, + Success = false, + Error = error + }; +} diff --git a/Features/Booking/Models/BookingFormModel.cs b/Features/Booking/Models/BookingFormModel.cs new file mode 100644 index 0000000..3eaf69c --- /dev/null +++ b/Features/Booking/Models/BookingFormModel.cs @@ -0,0 +1,32 @@ +using System.ComponentModel.DataAnnotations; + +namespace CloudZen.Features.Booking.Models; + +/// +/// Represents the data model for the booking/scheduling form submission. +/// Used in the BookingContact component (Step 2: Enter Details). +/// +public class BookingFormModel +{ + [Required(ErrorMessage = "Please enter your full name")] + [StringLength(100, ErrorMessage = "Name is too long (max 100 characters)")] + public string? FullName { get; set; } + + [Required(ErrorMessage = "Please enter your phone number")] + [Phone(ErrorMessage = "Please enter a valid phone number")] + public string? Phone { get; set; } + + [Required(ErrorMessage = "Please enter your email address")] + [EmailAddress(ErrorMessage = "Please enter a valid email address")] + public string? Email { get; set; } + + [Required(ErrorMessage = "Please enter your business name")] + [StringLength(200, ErrorMessage = "Business name is too long (max 200 characters)")] + public string? BusinessName { get; set; } + + [StringLength(500, ErrorMessage = "Reason is too long (max 500 characters)")] + public string? Reason { get; set; } + + [Range(typeof(bool), "true", "true", ErrorMessage = "Please confirm your consent to continue")] + public bool OptInConsent { get; set; } +} diff --git a/Features/Booking/Models/ManageAppointmentFormModels.cs b/Features/Booking/Models/ManageAppointmentFormModels.cs new file mode 100644 index 0000000..7136165 --- /dev/null +++ b/Features/Booking/Models/ManageAppointmentFormModels.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; + +namespace CloudZen.Features.Booking.Models; + +/// +/// Form model for cancelling an appointment. +/// +public class CancelFormModel +{ + [Required(ErrorMessage = "Please enter your booking ID")] + [RegularExpression(@"^APT-[A-Z0-9]{8}-[A-Z0-9]{4}$", + ErrorMessage = "Please enter a valid booking ID (e.g., APT-MN7O3825-TMVP)")] + public string? BookingId { get; set; } + + [Required(ErrorMessage = "Please enter your email address")] + [EmailAddress(ErrorMessage = "Please enter a valid email address")] + public string? Email { get; set; } +} + +/// +/// Form model for rescheduling an appointment. +/// +public class RescheduleFormModel +{ + [Required(ErrorMessage = "Please enter your booking ID")] + [RegularExpression(@"^APT-[A-Z0-9]{8}-[A-Z0-9]{4}$", + ErrorMessage = "Please enter a valid booking ID (e.g., APT-MN7O3825-TMVP)")] + public string? BookingId { get; set; } + + [Required(ErrorMessage = "Please enter your email address")] + [EmailAddress(ErrorMessage = "Please enter a valid email address")] + public string? Email { get; set; } +} diff --git a/Features/Booking/Models/N8nBookingApiResponse.cs b/Features/Booking/Models/N8nBookingApiResponse.cs new file mode 100644 index 0000000..bd94911 --- /dev/null +++ b/Features/Booking/Models/N8nBookingApiResponse.cs @@ -0,0 +1,20 @@ +namespace CloudZen.Features.Booking.Models; + +/// +/// Maps the raw JSON response from the N8N booking workflow. +/// Internal DTO used by for deserialization. +/// +public sealed record N8nBookingApiResponse +{ + /// Whether the N8N workflow operation succeeded. + public bool Success { get; init; } + + /// The action that was performed (book, cancel, reschedule). + public string? Action { get; init; } + + /// The booking confirmation ID (e.g. "APT-MN7O3825-TMVP"). + public string? BookingId { get; init; } + + /// Human-readable message from the N8N workflow. + public string? Message { get; init; } +} diff --git a/Features/Booking/Services/AppointmentService.cs b/Features/Booking/Services/AppointmentService.cs new file mode 100644 index 0000000..9dd1a6b --- /dev/null +++ b/Features/Booking/Services/AppointmentService.cs @@ -0,0 +1,162 @@ +using System.Net.Http.Json; +using System.Text.Json; +using CloudZen.Features.Booking.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace CloudZen.Features.Booking.Services; + +/// +/// Sends appointment requests (book, cancel, reschedule) through the Azure Functions proxy endpoint. +/// +/// +/// The WASM client cannot call the n8n webhook directly due to CORS restrictions. +/// Requests are sent to /api/book-appointment (Azure Functions), +/// which forwards them to n8n server-to-server. +/// +public class AppointmentService : IAppointmentService +{ + private readonly HttpClient _httpClient; + private readonly BookingServiceOptions _options; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + public AppointmentService( + HttpClient httpClient, + IOptions options, + ILogger logger) + { + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _httpClient.Timeout = TimeSpan.FromSeconds(_options.TimeoutSeconds); + } + + /// + public async Task BookAsync(BookAppointmentRequest request) + { + _logger.LogInformation("Booking appointment for {Email} on {Date} at {Time}", + request.Email, request.Date, request.Time); + + return await SendAsync(request, "book"); + } + + /// + public async Task CancelAsync(CancelAppointmentRequest request) + { + _logger.LogInformation("Cancelling appointment {BookingId} for {Email}", + request.BookingId, request.Email); + + return await SendAsync(request, "cancel"); + } + + /// + public async Task RescheduleAsync(RescheduleAppointmentRequest request) + { + _logger.LogInformation("Rescheduling appointment {BookingId} to {NewDate} at {NewTime}", + request.BookingId, request.NewDate, request.NewTime); + + return await SendAsync(request, "reschedule"); + } + + /// + public async Task VerifyBookingExistsAsync(VerifyBookingRequest request) + { + _logger.LogInformation("Verifying booking {BookingId} for {Email}", + request.BookingId, request.Email); + + return await SendAsync(request, "verify"); + } + + /// + /// Sends a request to the API and maps the response. + /// + private async Task SendAsync(TRequest request, string action) + where TRequest : class + { + try + { + var endpoint = _options.BookAppointmentUrl; + var response = await _httpClient.PostAsJsonAsync(endpoint, request); + var statusCode = (int)response.StatusCode; + var body = await response.Content.ReadAsStringAsync(); + + _logger.LogDebug("{Action} API response {StatusCode}: {Body}", action, statusCode, body); + + // Handle empty response body + if (string.IsNullOrWhiteSpace(body)) + { + _logger.LogWarning("{Action} received empty response with status {StatusCode}", action, statusCode); + + if (response.IsSuccessStatusCode && action != "book") + { + return AppointmentResponse.Ok(statusCode, action, "Operation completed successfully."); + } + + return AppointmentResponse.Fail(statusCode, "We received an empty response from the server."); + } + + var apiResponse = JsonSerializer.Deserialize(body, JsonOptions); + + if (apiResponse is null) + { + return AppointmentResponse.Fail(statusCode, "We received an unexpected response format."); + } + + return MapToAppointmentResponse(apiResponse, statusCode, action); + } + catch (HttpRequestException ex) + { + _logger.LogError(ex, "Network error during {Action}: {Message}", action, ex.Message); + return AppointmentResponse.NetworkError( + "Our booking system is temporarily unreachable. Please try again in a moment."); + } + catch (TaskCanceledException ex) + when (ex.InnerException is TimeoutException || !ex.CancellationToken.IsCancellationRequested) + { + _logger.LogError(ex, "Timeout during {Action} after {Seconds}s", action, _options.TimeoutSeconds); + return AppointmentResponse.NetworkError("The request took too long. Please try again."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unexpected error during {Action}: {Message}", action, ex.Message); + return AppointmentResponse.Fail(500, "Something went wrong. Please try again later."); + } + } + + /// + /// Maps the API response to an . + /// + private static AppointmentResponse MapToAppointmentResponse(N8nBookingApiResponse api, int statusCode, string action) + { + if (api.Success) + { + return action == "book" + ? AppointmentResponse.Confirmed(statusCode, api.BookingId ?? "N/A", api.Message) + : AppointmentResponse.Ok(statusCode, action, api.Message); + } + + var error = api.Message ?? "The operation could not be completed."; + + if (error.Contains("not found", StringComparison.OrdinalIgnoreCase) || + error.Contains("couldn't find", StringComparison.OrdinalIgnoreCase) || + error.Contains("could not find", StringComparison.OrdinalIgnoreCase) || + error.Contains("does not exist", StringComparison.OrdinalIgnoreCase)) + { + return AppointmentResponse.NotFound(statusCode, error); + } + + if (error.Contains("already booked", StringComparison.OrdinalIgnoreCase) || + error.Contains("slot", StringComparison.OrdinalIgnoreCase)) + { + return AppointmentResponse.SlotTaken(statusCode, error); + } + + return AppointmentResponse.Fail(statusCode, error); + } +} diff --git a/Features/Booking/Services/BookingService.cs b/Features/Booking/Services/BookingService.cs new file mode 100644 index 0000000..b4c4f29 --- /dev/null +++ b/Features/Booking/Services/BookingService.cs @@ -0,0 +1,107 @@ +using System.Globalization; + +namespace CloudZen.Features.Booking.Services; + +/// +/// Provides calendar logic, date availability checks, and formatting for the booking flow. +/// +public class BookingService : IBookingService +{ + /// + /// + /// Slots are defined in 12-hour format and aligned to the US Eastern time zone business hours. + /// + public string[] AvailableTimeSlots { get; } = + [ + "10:00 AM", "10:30 AM", + "12:00 PM", "12:30 PM", + "01:00 PM", + "02:30 PM", + "03:00 PM", + "05:00 PM" + ]; + + /// + public int?[] BuildCalendarCells(DateTime displayMonth) + { + var firstDay = new DateTime(displayMonth.Year, displayMonth.Month, 1); + int daysInMonth = DateTime.DaysInMonth(displayMonth.Year, displayMonth.Month); + + // Monday = 0 offset + int startOffset = ((int)firstDay.DayOfWeek + 6) % 7; + + var cells = new int?[startOffset + daysInMonth]; + for (int i = 0; i < startOffset; i++) + cells[i] = null; + for (int d = 1; d <= daysInMonth; d++) + cells[startOffset + d - 1] = d; + + return cells; + } + + /// + public bool IsDateAvailable(DateTime date) + { + return date >= DateTime.Today + && date.DayOfWeek != DayOfWeek.Saturday + && date.DayOfWeek != DayOfWeek.Sunday; + } + + /// + public bool IsPreviousMonthDisabled(DateTime displayMonth) + { + return displayMonth.Year == DateTime.Today.Year && displayMonth.Month == DateTime.Today.Month; + } + + /// + public string FormatSlotRange(string? selectedTime) + { + if (selectedTime is null) return string.Empty; + + if (DateTime.TryParseExact(selectedTime, "hh:mm tt", CultureInfo.InvariantCulture, DateTimeStyles.None, out var start)) + { + var end = start.AddMinutes(30); + return $"{start:hh:mm tt} - {end:hh:mm tt}"; + } + + return selectedTime; + } + + /// + public string FormatTimeZoneOption(TimeZoneInfo tz) + { + var utcOffset = tz.BaseUtcOffset; + var sign = utcOffset >= TimeSpan.Zero ? "+" : "-"; + return $"GMT{sign}{Math.Abs(utcOffset.Hours):00}:{Math.Abs(utcOffset.Minutes):00} {tz.Id} ({tz.StandardName})"; + } + + /// + public string GetLocalTimeZoneLabel() + { + return FormatTimeZoneOption(TimeZoneInfo.Local); + } + + /// + public string FormatTimeTo24Hour(string displayTime) + { + if (DateTime.TryParseExact(displayTime, "hh:mm tt", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + { + return parsed.ToString("HH:mm", CultureInfo.InvariantCulture); + } + + return displayTime; + } + + /// + public string FormatEndTimeTo24Hour(string displayTime) + { + if (DateTime.TryParseExact(displayTime, "hh:mm tt", + CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)) + { + return parsed.AddMinutes(30).ToString("HH:mm", CultureInfo.InvariantCulture); + } + + return displayTime; + } +} diff --git a/Services/GoogleCalendarUrlService.cs b/Features/Booking/Services/GoogleCalendarUrlService.cs similarity index 88% rename from Services/GoogleCalendarUrlService.cs rename to Features/Booking/Services/GoogleCalendarUrlService.cs index ea5465d..f151c1d 100644 --- a/Services/GoogleCalendarUrlService.cs +++ b/Features/Booking/Services/GoogleCalendarUrlService.cs @@ -1,8 +1,8 @@ using System; -namespace CloudZen.Services +namespace CloudZen.Features.Booking.Services { - public class GoogleCalendarUrlService + public class GoogleCalendarUrlService : IGoogleCalendarUrlService { public string CreateConsultationUrl(DateTime? startTime = null, int durationHours = 1) { diff --git a/Features/Booking/Services/IAppointmentService.cs b/Features/Booking/Services/IAppointmentService.cs new file mode 100644 index 0000000..dcf9aa7 --- /dev/null +++ b/Features/Booking/Services/IAppointmentService.cs @@ -0,0 +1,37 @@ +using CloudZen.Features.Booking.Models; + +namespace CloudZen.Features.Booking.Services; + +/// +/// Sends appointment requests (book, cancel, reschedule) to the n8n webhook endpoint. +/// +public interface IAppointmentService +{ + /// + /// Books a new appointment via the n8n workflow. + /// + /// The booking details. + /// An with status code and result. + Task BookAsync(BookAppointmentRequest request); + + /// + /// Cancels an existing appointment via the n8n workflow. + /// + /// The cancellation details. + /// An with status code and result. + Task CancelAsync(CancelAppointmentRequest request); + + /// + /// Reschedules an existing appointment to a new date/time via the n8n workflow. + /// + /// The reschedule details. + /// An with status code and result. + Task RescheduleAsync(RescheduleAppointmentRequest request); + + /// + /// Verifies if a booking ID exists in the system. + /// + /// The verification details. + /// An with status code and result. + Task VerifyBookingExistsAsync(VerifyBookingRequest request); +} diff --git a/Features/Booking/Services/IBookingService.cs b/Features/Booking/Services/IBookingService.cs new file mode 100644 index 0000000..79bca3a --- /dev/null +++ b/Features/Booking/Services/IBookingService.cs @@ -0,0 +1,58 @@ +using System.Globalization; + +namespace CloudZen.Features.Booking.Services; + +/// +/// Service for booking calendar logic, date availability, and formatting. +/// +public interface IBookingService +{ + /// Available 30-minute time slots offered each day. + string[] AvailableTimeSlots { get; } + + /// Builds calendar grid cells for a given month. + /// The first day of the month to render. + /// + /// An array where null entries represent empty leading cells (before the 1st) + /// and integer entries represent day numbers. + /// + int?[] BuildCalendarCells(DateTime displayMonth); + + /// Returns true if the given date is bookable (weekday, today or future). + /// The calendar date to check. + /// true when the date is a weekday on or after today; otherwise false. + bool IsDateAvailable(DateTime date); + + /// Returns true if navigating to the previous month should be disabled. + /// The first day of the currently displayed month. + /// true when the displayed month is the current calendar month. + bool IsPreviousMonthDisabled(DateTime displayMonth); + + /// Formats a time slot as a 30-min range, e.g. "12:30 PM - 01:00 PM". + /// A 12-hour slot string (e.g. "12:30 PM"), or null. + /// The formatted range, or when is null. + string FormatSlotRange(string? selectedTime); + + /// Formats a time zone for display, e.g. "GMT+05:30 India Standard Time (IST)". + /// The to format. + /// A human-readable string with GMT offset, time zone ID, and standard name. + string FormatTimeZoneOption(TimeZoneInfo tz); + + /// Returns the display label for the local time zone. + string GetLocalTimeZoneLabel(); + + /// + /// Converts a 12-hour display slot (e.g. "01:00 PM") to 24-hour "HH:mm" format + /// required by the n8n webhook. + /// + /// The 12-hour time string to convert. + /// The time in "HH:mm" format, or the original string if parsing fails. + string FormatTimeTo24Hour(string displayTime); + + /// + /// Returns the 24-hour end time (start + 30 min) for a given 12-hour display slot. + /// + /// The 12-hour time string representing the start of the slot. + /// The end time in "HH:mm" format, or the original string if parsing fails. + string FormatEndTimeTo24Hour(string displayTime); +} diff --git a/Features/Booking/Services/IGoogleCalendarUrlService.cs b/Features/Booking/Services/IGoogleCalendarUrlService.cs new file mode 100644 index 0000000..ca510bf --- /dev/null +++ b/Features/Booking/Services/IGoogleCalendarUrlService.cs @@ -0,0 +1,9 @@ +namespace CloudZen.Features.Booking.Services; + +/// +/// Interface for generating Google Calendar pre-filled URLs for consultations. +/// +public interface IGoogleCalendarUrlService +{ + string CreateConsultationUrl(DateTime? startTime = null, int durationHours = 1); +} diff --git a/Models/Options/ChatbotOptions.cs b/Features/Chat/ChatbotOptions.cs similarity index 98% rename from Models/Options/ChatbotOptions.cs rename to Features/Chat/ChatbotOptions.cs index 31851d7..15d2f67 100644 --- a/Models/Options/ChatbotOptions.cs +++ b/Features/Chat/ChatbotOptions.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models.Options; +namespace CloudZen.Features.Chat; /// /// Configuration options for the chatbot service client. diff --git a/Shared/Chatbot/CloudZenChatbot.razor b/Features/Chat/Components/CloudZenChatbot.razor similarity index 99% rename from Shared/Chatbot/CloudZenChatbot.razor rename to Features/Chat/Components/CloudZenChatbot.razor index b49d401..1ac15f2 100644 --- a/Shared/Chatbot/CloudZenChatbot.razor +++ b/Features/Chat/Components/CloudZenChatbot.razor @@ -1,5 +1,3 @@ -@using CloudZen.Models -@using CloudZen.Services.Abstractions @inject IChatbotService ChatbotService
diff --git a/Shared/Chatbot/CloudZenChatbot.razor.cs b/Features/Chat/Components/CloudZenChatbot.razor.cs similarity index 84% rename from Shared/Chatbot/CloudZenChatbot.razor.cs rename to Features/Chat/Components/CloudZenChatbot.razor.cs index f4e25b4..ac3a523 100644 --- a/Shared/Chatbot/CloudZenChatbot.razor.cs +++ b/Features/Chat/Components/CloudZenChatbot.razor.cs @@ -1,6 +1,6 @@ -using System.Text.RegularExpressions; +using System.Text.RegularExpressions; -namespace CloudZen.Shared.Chatbot; +namespace CloudZen.Features.Chat.Components; /// /// Code-behind partial class for the Blazor component. @@ -57,7 +57,13 @@ private static string HighlightContactInfo(string content) if (match.Groups["url"].Success) { var url = match.Value; - return $"""🔗 {url}"""; + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) + || (uri.Scheme != "http" && uri.Scheme != "https")) + { + return match.Value; + } + var safeHref = new Uri(url).AbsoluteUri; + return $"""🔗 {url}"""; } return match.Value; }); diff --git a/Shared/Chatbot/CloudZenChatbot.razor.css b/Features/Chat/Components/CloudZenChatbot.razor.css similarity index 74% rename from Shared/Chatbot/CloudZenChatbot.razor.css rename to Features/Chat/Components/CloudZenChatbot.razor.css index f07b0b8..05ae9e7 100644 --- a/Shared/Chatbot/CloudZenChatbot.razor.css +++ b/Features/Chat/Components/CloudZenChatbot.razor.css @@ -17,13 +17,13 @@ width: 56px; height: 56px; border-radius: 50%; - background: #7c3aed; + background: #40676B; /* teal-cyan-aqua-600 */ border: none; cursor: pointer; display: flex; align-items: center; justify-content: center; - box-shadow: 0 6px 24px rgba(124, 58, 237, 0.45); + box-shadow: 0 6px 24px rgba(64, 103, 107, 0.45); transition: all 0.3s ease; position: absolute; bottom: 0; @@ -32,12 +32,100 @@ .chatbot-fab:hover { transform: scale(1.08); - box-shadow: 0 8px 32px rgba(124, 58, 237, 0.55); + box-shadow: 0 8px 32px rgba(64, 103, 107, 0.55); } .chatbot-fab.fab-active { - background: #12132a; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); + background: #0F1E1F; /* teal-cyan-aqua-900 */ + box-shadow: 0 4px 16px rgba(15, 30, 31, 0.5); +} + +/* ---------- Greeting Tooltip Bubble ---------- */ +.greeting-bubble { + position: absolute; + bottom: 68px; + right: 0; + display: flex; + align-items: center; + gap: 10px; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 12px 36px 12px 14px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1); + white-space: nowrap; + animation: greetingFadeIn 0.4s ease; + z-index: 1; +} + +.greeting-bubble::after { + content: ''; + position: absolute; + bottom: -7px; + right: 22px; + width: 14px; + height: 14px; + background: #fff; + border-right: 1px solid #e2e8f0; + border-bottom: 1px solid #e2e8f0; + transform: rotate(45deg); +} + +.greeting-logo { + width: 32px; + height: 32px; + border-radius: 8px; + overflow: hidden; + flex-shrink: 0; +} + +.greeting-logo img { + width: 100%; + height: 100%; + object-fit: contain; +} + +.greeting-text { + font-size: 13px; + font-weight: 500; + color: #1e293b; + line-height: 1.4; +} + +.greeting-close { + position: absolute; + top: 6px; + right: 8px; + width: 20px; + height: 20px; + border: none; + background: transparent; + color: #94a3b8; + font-size: 16px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + line-height: 1; + border-radius: 50%; + transition: all 0.15s ease; +} + +.greeting-close:hover { + color: #475569; + background: #f1f5f9; +} + +@keyframes greetingFadeIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } } /* ---------- Greeting Tooltip Bubble ---------- */ @@ -159,7 +247,7 @@ /* ---------- Chat Header ---------- */ .chat-header { - background: #12132a; + background: linear-gradient(135deg, #0F1E1F, #1F3638); /* teal-cyan-aqua-900 → 800 */ border-bottom: none; padding: 15px 22px; display: flex; @@ -199,7 +287,7 @@ .header-text p { font-size: 11.5px; - color: #64748b; + color: #89D6DC; /* teal-cyan-aqua-200 — visible on dark teal */ margin: 1px 0 0 0; font-weight: 400; line-height: 1.3; @@ -302,7 +390,7 @@ } .avatar.user-av { - background: #12132a; + background: #0F1E1F; /* teal-cyan-aqua-900 */ border: none; color: #fff; } @@ -332,31 +420,31 @@ display: inline-flex; align-items: center; gap: 3px; - background: linear-gradient(135deg, #ede9fe, #f0fdfa); - color: #7c3aed; + background: linear-gradient(135deg, #DAF6F9, #B8EFF4); /* teal-50 → teal-100 */ + color: #40676B; /* teal-cyan-aqua-600 */ padding: 2px 8px; border-radius: 6px; font-weight: 600; font-size: 12.5px; text-decoration: none; - border: 1px solid #ddd6fe; + border: 1px solid #89D6DC; /* teal-cyan-aqua-200 */ transition: all 0.2s ease; word-break: break-all; } ::deep .contact-highlight:hover { - background: linear-gradient(135deg, #ddd6fe, #ccfbf1); - border-color: #7c3aed; - box-shadow: 0 2px 8px rgba(124, 58, 237, 0.2); + background: linear-gradient(135deg, #B8EFF4, #89D6DC); /* teal-100 → teal-200 */ + border-color: #40676B; /* teal-cyan-aqua-600 */ + box-shadow: 0 2px 8px rgba(64, 103, 107, 0.2); transform: translateY(-1px); - color: #6d28d9; + color: #2F4E51; /* teal-cyan-aqua-700 */ } .bubble.user { - background: #7c3aed; + background: #40676B; /* teal-cyan-aqua-600 */ color: #fff; border-radius: 12px 12px 3px 12px; - box-shadow: 0 2px 10px rgba(124, 58, 237, 0.25); + box-shadow: 0 2px 10px rgba(64, 103, 107, 0.25); } /* ---------- Suggestions ---------- */ @@ -386,11 +474,11 @@ } .suggestion-chip:hover { - background: #faf5ff; - border-color: #7c3aed; - color: #7c3aed; + background: #DAF6F9; /* teal-cyan-aqua-50 */ + border-color: #40676B; /* teal-cyan-aqua-600 */ + color: #40676B; transform: translateY(-1px); - box-shadow: 0 3px 8px rgba(124, 58, 237, 0.12); + box-shadow: 0 3px 8px rgba(64, 103, 107, 0.12); } /* ---------- Input Area ---------- */ @@ -413,8 +501,8 @@ } .input-row:focus-within { - border-color: #7c3aed; - box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.08); + border-color: #40676B; /* teal-cyan-aqua-600 */ + box-shadow: 0 0 0 3px rgba(64, 103, 107, 0.08); background: #fff; } @@ -442,7 +530,7 @@ width: 33px; height: 33px; border-radius: 8px; - background: #7c3aed; + background: #fb923c; /* orange-400 — primary CTA per design system */ border: none; cursor: pointer; display: flex; @@ -450,11 +538,11 @@ justify-content: center; transition: background 0.15s, transform 0.15s; flex-shrink: 0; - box-shadow: 0 2px 8px rgba(124, 58, 237, 0.3); + box-shadow: 0 2px 8px rgba(251, 146, 60, 0.3); } .send-btn:hover:not(:disabled) { - background: #6d28d9; + background: #f97316; /* orange-500 */ transform: scale(1.04); } @@ -482,7 +570,7 @@ .typing-dot { width: 6px; height: 6px; - background: #c4b5fd; + background: #89D6DC; /* teal-cyan-aqua-200 */ border-radius: 50%; animation: typingBounce 1.3s infinite; } @@ -522,7 +610,7 @@ .cta-button { display: inline-block; padding: 10px 24px; - background: #7c3aed; + background: #fb923c; /* orange-400 — primary CTA */ color: #fff; border-radius: 24px; text-decoration: none; @@ -530,12 +618,13 @@ font-weight: 600; font-family: inherit; transition: all 0.2s ease; - box-shadow: 0 4px 16px rgba(124, 58, 237, 0.35); + box-shadow: 0 4px 16px rgba(251, 146, 60, 0.35); } .cta-button:hover { transform: translateY(-1px); - box-shadow: 0 6px 24px rgba(124, 58, 237, 0.5); + box-shadow: 0 6px 24px rgba(251, 146, 60, 0.5); + background: #f97316; /* orange-500 */ color: #fff; } diff --git a/Models/ChatMessage.cs b/Features/Chat/Models/ChatMessage.cs similarity index 94% rename from Models/ChatMessage.cs rename to Features/Chat/Models/ChatMessage.cs index be1dc3b..da120a4 100644 --- a/Models/ChatMessage.cs +++ b/Features/Chat/Models/ChatMessage.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models; +namespace CloudZen.Features.Chat.Models; /// /// Represents a single message in the chatbot conversation. diff --git a/Services/ChatbotService.cs b/Features/Chat/Services/ChatbotService.cs similarity index 97% rename from Services/ChatbotService.cs rename to Features/Chat/Services/ChatbotService.cs index 3ab0a6d..aa498f1 100644 --- a/Services/ChatbotService.cs +++ b/Features/Chat/Services/ChatbotService.cs @@ -1,12 +1,11 @@ -using CloudZen.Models; -using CloudZen.Models.Options; -using CloudZen.Services.Abstractions; +using CloudZen.Features.Chat.Models; +using CloudZen.Features.Chat; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Net.Http.Json; using System.Text.Json; -namespace CloudZen.Services; +namespace CloudZen.Features.Chat.Services; /// /// Chatbot service implementation that sends messages through the Azure Functions API backend. diff --git a/Services/Abstractions/IChatbotService.cs b/Features/Chat/Services/IChatbotService.cs similarity index 93% rename from Services/Abstractions/IChatbotService.cs rename to Features/Chat/Services/IChatbotService.cs index 5347976..8f57b5a 100644 --- a/Services/Abstractions/IChatbotService.cs +++ b/Features/Chat/Services/IChatbotService.cs @@ -1,6 +1,6 @@ -using CloudZen.Models; +using CloudZen.Features.Chat.Models; -namespace CloudZen.Services.Abstractions; +namespace CloudZen.Features.Chat.Services; /// /// Interface for the chatbot service that sends messages through the Azure Functions API backend. diff --git a/Shared/Landing/ContactForm.razor b/Features/Contact/Components/ContactForm.razor similarity index 76% rename from Shared/Landing/ContactForm.razor rename to Features/Contact/Components/ContactForm.razor index ade7e94..94aa8da 100644 --- a/Shared/Landing/ContactForm.razor +++ b/Features/Contact/Components/ContactForm.razor @@ -1,19 +1,16 @@ -@using System.ComponentModel.DataAnnotations -@using CloudZen.Models -@using CloudZen.Services.Abstractions -@inject IEmailService EmailService +@using System.ComponentModel.DataAnnotations -
+
- + Get In Touch

Let's Start a - + Conversation

@@ -25,10 +22,10 @@
-
-
+
+
-
+
@if (!submitted) { @@ -41,52 +38,52 @@
- +
- +
- +
- + @(formModel.Message?.Length ?? 0)/500
@@ -94,13 +91,18 @@ @if (!string.IsNullOrEmpty(errorMessage)) { -
+
-@code { - private ContactFormModel formModel = new(); - private bool submitted = false; - private bool isSubmitting = false; - private string? errorMessage; - - private async Task HandleValidSubmit() - { - isSubmitting = true; - errorMessage = null; - - try - { - var result = await EmailService.SendEmailAsync( - formModel.Subject!, - formModel.Message!, - formModel.Name!, - formModel.Email! - ); - - if (result.Success) - { - submitted = true; - } - else - { - errorMessage = result.Error ?? "Failed to send message. Please try again."; - } - } - catch (Exception) - { - errorMessage = "An unexpected error occurred. Please try again later."; - } - finally - { - isSubmitting = false; - } - } - - private void ResetForm() - { - formModel = new ContactFormModel(); - submitted = false; - errorMessage = null; - } -} diff --git a/Features/Contact/Components/ContactForm.razor.cs b/Features/Contact/Components/ContactForm.razor.cs new file mode 100644 index 0000000..70cc81d --- /dev/null +++ b/Features/Contact/Components/ContactForm.razor.cs @@ -0,0 +1,58 @@ +using CloudZen.Features.Contact.Models; +using CloudZen.Features.Contact.Services; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Contact.Components; + +/// +/// Code-behind for ContactForm.razor — handles form state and email submission. +/// +public partial class ContactForm +{ + [Inject] private IEmailService EmailService { get; set; } = default!; + + private ContactFormModel formModel = new(); + private bool submitted; + private bool isSubmitting; + private string? errorMessage; + + private async Task HandleValidSubmit() + { + isSubmitting = true; + errorMessage = null; + + try + { + var result = await EmailService.SendEmailAsync( + formModel.Subject!, + formModel.Message!, + formModel.Name!, + formModel.Email! + ); + + if (result.Success) + { + submitted = true; + } + else + { + errorMessage = result.Error ?? "Failed to send message. Please try again."; + } + } + catch (Exception) + { + errorMessage = "An unexpected error occurred. Please try again later."; + } + finally + { + isSubmitting = false; + } + } + + private void ResetForm() + { + formModel = new ContactFormModel(); + submitted = false; + errorMessage = null; + } +} diff --git a/Models/Options/EmailServiceOptions.cs b/Features/Contact/EmailServiceOptions.cs similarity index 98% rename from Models/Options/EmailServiceOptions.cs rename to Features/Contact/EmailServiceOptions.cs index ef4cbbb..ebbee68 100644 --- a/Models/Options/EmailServiceOptions.cs +++ b/Features/Contact/EmailServiceOptions.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models.Options; +namespace CloudZen.Features.Contact; /// /// Configuration options for the email service client. diff --git a/Models/ContactFormModel.cs b/Features/Contact/Models/ContactFormModel.cs similarity index 95% rename from Models/ContactFormModel.cs rename to Features/Contact/Models/ContactFormModel.cs index 5aaae91..f2e659a 100644 --- a/Models/ContactFormModel.cs +++ b/Features/Contact/Models/ContactFormModel.cs @@ -1,6 +1,6 @@ using System.ComponentModel.DataAnnotations; -namespace CloudZen.Models; +namespace CloudZen.Features.Contact.Models; /// /// Represents the data model for the contact form submission. diff --git a/Models/EmailApiErrorResponse.cs b/Features/Contact/Models/EmailApiErrorResponse.cs similarity index 93% rename from Models/EmailApiErrorResponse.cs rename to Features/Contact/Models/EmailApiErrorResponse.cs index 2a871bc..7c926d9 100644 --- a/Models/EmailApiErrorResponse.cs +++ b/Features/Contact/Models/EmailApiErrorResponse.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models; +namespace CloudZen.Features.Contact.Models; /// /// Response model for email API error responses. diff --git a/Models/EmailApiRequest.cs b/Features/Contact/Models/EmailApiRequest.cs similarity index 96% rename from Models/EmailApiRequest.cs rename to Features/Contact/Models/EmailApiRequest.cs index d29ef44..6023f9f 100644 --- a/Models/EmailApiRequest.cs +++ b/Features/Contact/Models/EmailApiRequest.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models; +namespace CloudZen.Features.Contact.Models; /// /// Request model for sending emails through the API backend. diff --git a/Models/EmailApiResponse.cs b/Features/Contact/Models/EmailApiResponse.cs similarity index 95% rename from Models/EmailApiResponse.cs rename to Features/Contact/Models/EmailApiResponse.cs index c0230af..13d2e17 100644 --- a/Models/EmailApiResponse.cs +++ b/Features/Contact/Models/EmailApiResponse.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models; +namespace CloudZen.Features.Contact.Models; /// /// Response model for successful email API responses. diff --git a/Services/ApiEmailService.cs b/Features/Contact/Services/ApiEmailService.cs similarity index 98% rename from Services/ApiEmailService.cs rename to Features/Contact/Services/ApiEmailService.cs index adc1d0c..16bdc8e 100644 --- a/Services/ApiEmailService.cs +++ b/Features/Contact/Services/ApiEmailService.cs @@ -1,12 +1,11 @@ -using CloudZen.Models; -using CloudZen.Models.Options; -using CloudZen.Services.Abstractions; +using CloudZen.Features.Contact.Models; +using CloudZen.Features.Contact; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using System.Net.Http.Json; using System.Text.Json; -namespace CloudZen.Services; +namespace CloudZen.Features.Contact.Services; /// /// Email service implementation that sends emails through the Azure Functions API backend. diff --git a/Services/Abstractions/IEmailService.cs b/Features/Contact/Services/IEmailService.cs similarity index 96% rename from Services/Abstractions/IEmailService.cs rename to Features/Contact/Services/IEmailService.cs index bc8c5d0..7c8505b 100644 --- a/Services/Abstractions/IEmailService.cs +++ b/Features/Contact/Services/IEmailService.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Services.Abstractions; +namespace CloudZen.Features.Contact.Services; /// /// Interface for email service that sends emails via API backend. diff --git a/Features/Landing/Components/CTA.razor b/Features/Landing/Components/CTA.razor new file mode 100644 index 0000000..8913d1e --- /dev/null +++ b/Features/Landing/Components/CTA.razor @@ -0,0 +1,8 @@ +
+

Let's Modernize Your Business

+

Schedule a free consultation to discover how CloudZen can help.

+ +
diff --git a/Features/Landing/Components/CTA.razor.cs b/Features/Landing/Components/CTA.razor.cs new file mode 100644 index 0000000..00bf186 --- /dev/null +++ b/Features/Landing/Components/CTA.razor.cs @@ -0,0 +1,20 @@ +using CloudZen.Features.Booking.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace CloudZen.Features.Landing.Components; + +/// +/// Code-behind for CTA.razor — opens a pre-filled Google Calendar event. +/// +public partial class CTA +{ + [Inject] private IJSRuntime JS { get; set; } = default!; + [Inject] private IGoogleCalendarUrlService CalendarUrlService { get; set; } = default!; + + private async Task BookConsultation() + { + var url = CalendarUrlService.CreateConsultationUrl(); + await JS.InvokeVoidAsync("open", url, "_blank"); + } +} diff --git a/Features/Landing/Components/CaseStudies.razor b/Features/Landing/Components/CaseStudies.razor new file mode 100644 index 0000000..d25021d --- /dev/null +++ b/Features/Landing/Components/CaseStudies.razor @@ -0,0 +1,122 @@ + +@* + Case Studies Component + + PURPOSE: + Showcases real-world project case studies with measurable results and business impact. + Dynamically loads featured projects from IProjectService and presents them in an + attractive, customer-friendly format. Text transformations delegated to ICaseStudyService. +*@ + +
+
+ +
+

+ Real Results, Real Impact +

+
+

+ See how some businesses have ditched slow systems, automated what used to take hours, and delivered real, measurable growth. +

+
+ + +
+ @{ var caseIndex = 0; } + @foreach (var project in _caseStudyProjects) + { + +
+ + + + + +
+

+ @CaseStudyService.GetShortTitle(project.Name) +

+ + @CaseStudyService.GetProjectCategory(project.Category) + +
+ + +
+

+ @CaseStudyService.GetCustomerFriendlyDescription(project.Description) +

+ + @if (project.Results != null && project.Results.Any()) + { +
+

Key results

+
    + @foreach (var result in project.Results.Take(2)) + { +
  • + + @CaseStudyService.GetSimplifiedResult(result) +
  • + } +
+
+ } + + +
+ + + @project.Status + + @if (!string.IsNullOrEmpty(project.GithubUrl)) + { + + + View code + + } +
+
+
+ caseIndex++; + } +
+ + +
+
+ + + Explore all projects + + + +

+ Want to see similar results? Let's talk +

+
+
+
+
+ diff --git a/Features/Landing/Components/CaseStudies.razor.cs b/Features/Landing/Components/CaseStudies.razor.cs new file mode 100644 index 0000000..3c472e6 --- /dev/null +++ b/Features/Landing/Components/CaseStudies.razor.cs @@ -0,0 +1,27 @@ +using CloudZen.Features.Landing.Models; +using CloudZen.Features.Landing.Services; +using CloudZen.Features.Projects.Services; +using CloudZen.Features.Projects.Models; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Landing.Components; + +/// +/// Code-behind for CaseStudies.razor — loads featured projects and delegates +/// text transformation to ICaseStudyService. +/// +public partial class CaseStudies +{ + [Inject] private IProjectService ProjectService { get; set; } = default!; + [Inject] private ICaseStudyService CaseStudyService { get; set; } = default!; + + private List _caseStudyProjects = new(); + + protected override void OnInitialized() + { + _caseStudyProjects = ProjectService + .GetProjectsByCategory(ProjectCategory.AiAutomation) + .Take(3) + .ToList(); + } +} diff --git a/Features/Landing/Components/FeatureHighlightCard.razor b/Features/Landing/Components/FeatureHighlightCard.razor new file mode 100644 index 0000000..9551394 --- /dev/null +++ b/Features/Landing/Components/FeatureHighlightCard.razor @@ -0,0 +1,33 @@ + +@* A single feature highlight row: text on one side, illustration on the other. + The layout alternates direction based on the IsReversed parameter. *@ + +
+ +
+ @if (!string.IsNullOrEmpty(Feature.Subtitle)) + { +

@Feature.Subtitle

+ } +

+ @Feature.TitlePrefix@Feature.TitleBold@Feature.TitleSuffix +

+
+

@Feature.Description

+
+ +
+ @Feature.TitleBold @Feature.TitleSuffix +
+
+ +@code { + [Parameter, EditorRequired] + public FeatureHighlight Feature { get; set; } = default!; + + /// + /// When true, the image appears on the left and text on the right. + /// + [Parameter] + public bool IsReversed { get; set; } +} diff --git a/Features/Landing/Components/FeaturesShowcase.razor b/Features/Landing/Components/FeaturesShowcase.razor new file mode 100644 index 0000000..d87f837 --- /dev/null +++ b/Features/Landing/Components/FeaturesShowcase.razor @@ -0,0 +1,11 @@ + +@* Section: Feature highlights with alternating text/image layout. *@ + +
+ @for (int i = 0; i < _features.Count; i++) + { +
+ +
+ } +
diff --git a/Features/Landing/Components/FeaturesShowcase.razor.cs b/Features/Landing/Components/FeaturesShowcase.razor.cs new file mode 100644 index 0000000..4338aec --- /dev/null +++ b/Features/Landing/Components/FeaturesShowcase.razor.cs @@ -0,0 +1,20 @@ +using CloudZen.Features.Landing.Models; +using CloudZen.Features.Landing.Services; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Landing.Components; + +/// +/// Code-behind for FeaturesShowcase.razor — loads feature highlights from service. +/// +public partial class FeaturesShowcase +{ + [Inject] private IFeatureHighlightService FeatureHighlightService { get; set; } = default!; + + private List _features = new(); + + protected override void OnInitialized() + { + _features = FeatureHighlightService.GetAllFeatures(); + } +} diff --git a/Features/Landing/Components/HeroWarmTealWash.razor b/Features/Landing/Components/HeroWarmTealWash.razor new file mode 100644 index 0000000..7ea69d1 --- /dev/null +++ b/Features/Landing/Components/HeroWarmTealWash.razor @@ -0,0 +1,71 @@ +@* ══════════════════════════════════════════════════════════════ + WarmTealWash Component — Hero section with white left side, + AI automation illustration on warm cream/beige right side. + Premium pass: staggered entrance choreography, gradient accent + headlialsone, CTA glow + shimmer, floating illustration with radial + glow, responsive stacking. Respects prefers-reduced-motion. + ══════════════════════════════════════════════════════════════ *@ + +
+ @* ── Left Side: White/Light Background ── *@ +
+
+ @* Badge *@ + + + Automation workflows + + + @* Headline — single gradient accent line, no italic, tight rhythm *@ +

+ Smart systems. + Less busywork. + More time for growing. +

+ + @* Subtext *@ +

+ We build custom automation tools designed around your daily routine—whether you run a clinic, agency, hospitality venue, retail store, or any local small business. One partner, clear communication, and outcomes you can actually measure. +

+ + @* CTA Buttons *@ + + + @* Proof block — replaces hero-metric anti-pattern *@ +
+

+ One flat project fee. No retainers, no surprises. +

+ +
+ +
+
+ + @* ── Right Side: AI illustration (md and up) ── *@ + +
diff --git a/Features/Landing/Components/HeroWarmTealWash.razor.cs b/Features/Landing/Components/HeroWarmTealWash.razor.cs new file mode 100644 index 0000000..98346c3 --- /dev/null +++ b/Features/Landing/Components/HeroWarmTealWash.razor.cs @@ -0,0 +1,6 @@ +namespace CloudZen.Features.Landing.Components; + +public sealed partial class HeroWarmTealWash +{ + // Component is primarily visual/CSS-driven. No complex logic needed. +} diff --git a/Features/Landing/Components/HeroWarmTealWash.razor.css b/Features/Landing/Components/HeroWarmTealWash.razor.css new file mode 100644 index 0000000..a582594 --- /dev/null +++ b/Features/Landing/Components/HeroWarmTealWash.razor.css @@ -0,0 +1,757 @@ +/* ══════════════════════════════════════════════════════════════ + WarmTealWash Component Styles — Warm cream/beige variant + with light workflow cards, dashed connectors, testimonials. + ══════════════════════════════════════════════════════════════ */ + +/* ── Section-wide gradient background ── */ +.warm-teal-wash { + background: #fcf9f5; +} + +/* ── Right Panel — transparent, lets section gradient show through ── */ +.warm-panel { + background: #fcf9f5; + padding: 0; +} + +/* ── Left-to-right fade overlay on top of the hero image ── */ +.hero-image-fade-overlay { + position: absolute; + inset: 0; + background: transparent; + pointer-events: none; +} + +/* ── Subtle radial teal glow behind the illustration (toned down) ── */ +.hero-radial-glow { + position: absolute; + top: 50%; + left: 50%; + width: 55%; + height: 55%; + transform: translate(-50%, -50%); + background: radial-gradient(circle, rgba(97, 194, 200, 0.12) 0%, rgba(97, 194, 200, 0) 72%); + pointer-events: none; + z-index: 0; +} + +/* ── Structured dot grid pattern — replaces decorative blobs ── + Intentional, geometric, fades to nothing at edges via radial mask. + Reads as "engineering / systems" not "AI hero blur orb". */ +.hero-grid-pattern { + position: absolute; + inset: 0; + background-image: radial-gradient(circle, rgba(64, 103, 107, 0.14) 1px, transparent 1.2px); + background-size: 22px 22px; + -webkit-mask-image: radial-gradient(ellipse 62% 62% at center, #000 28%, transparent 78%); + mask-image: radial-gradient(ellipse 62% 62% at center, #000 28%, transparent 78%); + pointer-events: none; + z-index: 0; +} + +/* ══════════════════════════════════════════════════════════════ + LIGHT WORKFLOW NODE CARDS + ══════════════════════════════════════════════════════════════ */ + +.dark-workflow-node { + z-index: 10; +} + +.dark-node-card { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + background: #ffffff; + padding: 0.875rem 1.25rem; + border-radius: 0.75rem; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.04); + border: 1px solid rgba(0, 0, 0, 0.06); + position: relative; + min-width: 90px; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.dark-node-card:hover { + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(0, 0, 0, 0.12); +} + +/* Teal border variant */ +.dark-node-card-teal { + border: 1.5px solid rgba(97, 194, 200, 0.4); +} + +/* Orange highlight variant (for AI Filter) */ +.dark-node-card-orange-highlight { + border: 2px solid #f97316; + box-shadow: 0 4px 16px rgba(249, 115, 22, 0.15); +} + +.dark-node-icon-wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.dark-node-icon { + width: 2.25rem; + height: 2.25rem; + border-radius: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + font-size: 1rem; +} + +.dark-node-title { + font-weight: 600; + font-size: 0.75rem; + color: #374151; + text-align: center; + white-space: nowrap; +} + +/* Node connection dots */ +.dark-node-dot { + position: absolute; + width: 10px; + height: 10px; + border-radius: 50%; + top: -5px; + right: -5px; + border: 2px solid #ffffff; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15); + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.dark-dot-teal { + background: #61C2C8; +} + +.dark-dot-orange { + background: #f97316; +} + +.dark-dot-green { + background: #22c55e; +} + +/* ══════════════════════════════════════════════════════════════ + NODE HIGHLIGHT ANIMATIONS (when flow arrives) + ══════════════════════════════════════════════════════════════ */ + +/* Pulse glow effect for nodes */ +@keyframes node-pulse-teal { + 0%, 100% { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + border-color: rgba(97, 194, 200, 0.4); + } + 50% { + box-shadow: 0 0 20px rgba(97, 194, 200, 0.5), 0 0 40px rgba(97, 194, 200, 0.25); + border-color: #61C2C8; + } +} + +@keyframes node-pulse-orange { + 0%, 100% { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + border-color: rgba(249, 115, 22, 0.4); + } + 50% { + box-shadow: 0 0 20px rgba(249, 115, 22, 0.5), 0 0 40px rgba(249, 115, 22, 0.25); + border-color: #f97316; + } +} + +@keyframes dot-pulse { + 0%, 100% { + transform: scale(1); + } + 50% { + transform: scale(1.4); + } +} + +/* Staggered highlight animations for each node */ +.node-highlight-1 .dark-node-card { + animation: node-pulse-teal 4s ease-in-out infinite; + animation-delay: 0s; +} + +.node-highlight-2 .dark-node-card { + animation: node-pulse-teal 4s ease-in-out infinite; + animation-delay: 0.5s; +} + +.node-highlight-3 .dark-node-card { + animation: node-pulse-orange 4s ease-in-out infinite; + animation-delay: 1s; +} + +.node-highlight-4 .dark-node-card { + animation: node-pulse-teal 4s ease-in-out infinite; + animation-delay: 1.5s; +} + +.node-highlight-5 .dark-node-card { + animation: node-pulse-teal 4s ease-in-out infinite; + animation-delay: 2s; +} + +.node-highlight-6 .dark-node-card { + animation: node-pulse-teal 4s ease-in-out infinite; + animation-delay: 2.5s; +} + +.node-highlight-7 .dark-node-card { + animation: node-pulse-teal 4s ease-in-out infinite; + animation-delay: 3s; +} + +/* Dot pulse animations synced with node highlights */ +.node-highlight-1 .dark-node-dot { + animation: dot-pulse 4s ease-in-out infinite; + animation-delay: 0s; +} + +.node-highlight-2 .dark-node-dot { + animation: dot-pulse 4s ease-in-out infinite; + animation-delay: 0.5s; +} + +.node-highlight-3 .dark-node-dot { + animation: dot-pulse 4s ease-in-out infinite; + animation-delay: 1s; +} + +.node-highlight-4 .dark-node-dot { + animation: dot-pulse 4s ease-in-out infinite; + animation-delay: 1.5s; +} + +.node-highlight-5 .dark-node-dot { + animation: dot-pulse 4s ease-in-out infinite; + animation-delay: 2s; +} + +.node-highlight-6 .dark-node-dot { + animation: dot-pulse 4s ease-in-out infinite; + animation-delay: 2.5s; +} + +.node-highlight-7 .dark-node-dot { + animation: dot-pulse 4s ease-in-out infinite; + animation-delay: 3s; +} + +/* ══════════════════════════════════════════════════════════════ + TESTIMONIAL CARD + ══════════════════════════════════════════════════════════════ */ + +.testimonial-card { + background: rgba(15, 25, 25, 0.82); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border-radius: 0.875rem; + padding: 0.875rem 1.375rem 1rem; + min-height: 140px; + min-width: 260px; + max-width: 300px; + box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22), 0 2px 8px rgba(0, 0, 0, 0.14); + border: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + overflow: hidden; +} + +/* Subtle top-left accent glow */ +.testimonial-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 60px; + height: 2px; + background: linear-gradient(90deg, #f59e0b 0%, transparent 100%); + border-radius: 0 0 2px 0; +} + +.testimonial-card .stars { + display: flex; + align-items: center; + gap: 0.2rem; + margin-bottom: 0.5rem; + font-size: 0.75rem; + line-height: 1; +} + +.testimonial-name { + font-weight: 700; + font-size: 0.875rem; + color: #ffffff; + margin-bottom: 0.15rem; + letter-spacing: -0.01em; +} + +.testimonial-role { + font-size: 0.6875rem; + color: #6b7a90; + margin-bottom: 0.375rem; +} + +.testimonial-quote { + font-size: 0.725rem; + color: #c8d3e0; + font-style: italic; + line-height: 1.55; + margin: 0; + padding-top: 0.5rem; + border-top: 1px solid rgba(255, 255, 255, 0.07); +} + +/* ══════════════════════════════════════════════════════════════ + 100% TESTED BADGE + ══════════════════════════════════════════════════════════════ */ + +.tested-badge { + background: rgba(245, 252, 252, 0.85); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(97, 194, 200, 0.25); + border-radius: 0.625rem; + padding: 0.625rem 1.25rem; + text-align: center; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); + min-width: 130px; +} + +.tested-badge-value { + font-size: 1.375rem; + font-weight: 800; + color: #61C2C8; + line-height: 1.2; + letter-spacing: -0.02em; +} + +.tested-badge-label { + font-size: 0.5625rem; + font-weight: 700; + color: #1a3a3c; + text-transform: uppercase; + letter-spacing: 0.1em; + line-height: 1.3; +} + +/* ══════════════════════════════════════════════════════════════ + DAY 1 READY BADGE + ══════════════════════════════════════════════════════════════ */ + +.day1-badge { + background: rgba(245, 252, 252, 0.85); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid rgba(97, 194, 200, 0.25); + border-radius: 0.625rem; + padding: 0.625rem 1.25rem; + text-align: center; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06); + min-width: 140px; +} + +.day1-badge-title { + font-family: 'IBM Plex Sans', var(--font-ibm-plex), sans-serif; + font-size: 1.125rem; + font-weight: 500; + color: #f97316; + line-height: 1.2; + letter-spacing: -0.01em; +} + +.day1-badge-subtitle { + font-size: 0.5625rem; + font-weight: 700; + color: #1a3a3c; + text-transform: uppercase; + letter-spacing: 0.1em; + line-height: 1.3; +} + +/* ══════════════════════════════════════════════════════════════ + ALERT BADGE + ══════════════════════════════════════════════════════════════ */ + +.testimonial-alert-anchor { + margin-top: -1px; + display: flex; + justify-content: center; +} + +.alert-badge { + display: inline-block; + padding: 0.375rem 0.875rem; + background: linear-gradient(135deg, #f97316 0%, #fb923c 100%); + color: white; + font-size: 0.6875rem; + font-weight: 600; + border-radius: 9999px; + text-transform: capitalize; + box-shadow: 0 2px 8px rgba(249, 115, 22, 0.3); +} + +/* ══════════════════════════════════════════════════════════════ + HAPPY CLIENT BADGE (Double border - solid inner, segmented outer) + ══════════════════════════════════════════════════════════════ */ + +.happy-client-badge { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.happy-client-photo-wrapper { + position: relative; + width: 86px; + height: 86px; + display: flex; + align-items: center; + justify-content: center; +} + +/* Outer segmented/dashed orange border ring - thin with rotation */ +.happy-client-border-ring { + position: absolute; + top: 0; + left: 0; + width: 86px; + height: 86px; + border-radius: 50%; + background: conic-gradient( + #f97316 0deg 6deg, + transparent 6deg 18deg, + #f97316 18deg 24deg, + transparent 24deg 36deg, + #f97316 36deg 42deg, + transparent 42deg 54deg, + #f97316 54deg 60deg, + transparent 60deg 72deg, + #f97316 72deg 78deg, + transparent 78deg 90deg, + #f97316 90deg 96deg, + transparent 96deg 108deg, + #f97316 108deg 114deg, + transparent 114deg 126deg, + #f97316 126deg 132deg, + transparent 132deg 144deg, + #f97316 144deg 150deg, + transparent 150deg 162deg, + #f97316 162deg 168deg, + transparent 168deg 180deg, + #f97316 180deg 186deg, + transparent 186deg 198deg, + #f97316 198deg 204deg, + transparent 204deg 216deg, + #f97316 216deg 222deg, + transparent 222deg 234deg, + #f97316 234deg 240deg, + transparent 240deg 252deg, + #f97316 252deg 258deg, + transparent 258deg 270deg, + #f97316 270deg 276deg, + transparent 276deg 288deg, + #f97316 288deg 294deg, + transparent 294deg 306deg, + #f97316 306deg 312deg, + transparent 312deg 324deg, + #f97316 324deg 330deg, + transparent 330deg 342deg, + #f97316 342deg 348deg, + transparent 348deg 360deg + ); + -webkit-mask: radial-gradient(transparent 95%, black 95.5%, black 98%, transparent 98.5%); + mask: radial-gradient(transparent 95%, black 95.5%, black 98%, transparent 98.5%); + animation: ring-rotate 8s linear infinite; +} + +@keyframes ring-rotate { + 0% { + transform: rotate(0deg); + } + 100% { + transform: rotate(360deg); + } +} + +/* Inner solid orange border */ +.happy-client-inner-border { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 72px; + height: 72px; + border-radius: 50%; + border: 3px solid #f97316; + background: transparent; + z-index: 1; +} + +.happy-client-photo { + position: relative; + width: 66px; + height: 66px; + border-radius: 50%; + overflow: hidden; + background: #374151; + z-index: 2; +} + +.happy-client-photo img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.happy-client-placeholder { + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, #4b5563 0%, #374151 100%); +} + +.happy-client-placeholder img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.happy-client-check { + position: absolute; + bottom: 4px; + right: 4px; + width: 24px; + height: 24px; + background: #f97316; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid white; + z-index: 3; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2); +} + +.happy-client-label { + font-size: 0.6875rem; + font-weight: 700; + color: #f97316; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.happy-client-stars { + display: flex; + align-items: center; + gap: 0.125rem; +} + +/* ══════════════════════════════════════════════════════════════ + DASHED CONNECTOR PATHS + ══════════════════════════════════════════════════════════════ */ + +.dashed-connector { + fill: none; + stroke: #9ca3af; + stroke-width: 1.5; + stroke-dasharray: 8, 6; + stroke-linecap: round; + opacity: 0.6; +} + +.dashed-connector-orange { + fill: none; + stroke: #f97316; + stroke-width: 1.5; + stroke-dasharray: 8, 6; + stroke-linecap: round; + opacity: 0.7; +} + +.dashed-subtle { + stroke: #d1d5db; + stroke-width: 1; + stroke-dasharray: 6, 5; + opacity: 0.4; +} + +.dashed-connector-animated { + fill: none; + stroke: #61C2C8; + stroke-width: 2; + stroke-dasharray: 12, 200; + stroke-linecap: round; + animation: dash-flow 4s ease-in-out infinite; +} + +.dashed-connector-animated-orange { + fill: none; + stroke: #f97316; + stroke-width: 2; + stroke-dasharray: 12, 200; + stroke-linecap: round; + animation: dash-flow 4s ease-in-out infinite; +} + +@keyframes dash-flow { + 0% { + stroke-dashoffset: 0; + } + 50% { + stroke-dashoffset: -120; + } + 100% { + stroke-dashoffset: -240; + } +} + +/* ══════════════════════════════════════════════════════════════ + FLOAT ANIMATION + ══════════════════════════════════════════════════════════════ */ + +@keyframes float-warm { + 0%, 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-4px); + } +} + +.animate-float-warm { + animation: float-warm 7s cubic-bezier(0.4, 0, 0.2, 1) infinite; +} + +/* ══════════════════════════════════════════════════════════════ + ANIMATION DELAYS + ══════════════════════════════════════════════════════════════ */ + +.delay-1 { + animation-delay: 0.3s; +} + +.delay-2 { + animation-delay: 0.6s; +} + +.delay-3 { + animation-delay: 0.9s; +} + +.delay-4 { + animation-delay: 1.2s; +} + +.delay-5 { + animation-delay: 1.5s; +} + +.delay-6 { + animation-delay: 1.8s; +} + +.delay-7 { + animation-delay: 2.1s; +} + +.delay-8 { + animation-delay: 2.4s; +} + +/* ══════════════════════════════════════════════════════════════ + RESPONSIVE ADJUSTMENTS + ══════════════════════════════════════════════════════════════ */ + +@media (max-width: 1280px) { + .dark-node-card { + min-width: 80px; + padding: 0.75rem 1rem; + } + + .dark-node-icon { + width: 2rem; + height: 2rem; + font-size: 0.875rem; + } + + .dark-node-title { + font-size: 0.6875rem; + } + + .testimonial-card { + min-width: 215px; + max-width: 240px; + min-height: 120px; + padding: 0.75rem 1.125rem 0.875rem; + } + + .testimonial-name { + font-size: 0.9375rem; + } + + .testimonial-card .stars i { + font-size: 0.875rem; + } + + .happy-client-photo-wrapper { + width: 76px; + height: 76px; + } + + .happy-client-border-ring { + width: 76px; + height: 76px; + } + + .happy-client-inner-border { + width: 64px; + height: 64px; + } + + .happy-client-photo { + width: 58px; + height: 58px; + } +} + +/* ══════════════════════════════════════════════════════════════ + REDUCED MOTION — disable all continuous animations + ══════════════════════════════════════════════════════════════ */ + +@media (prefers-reduced-motion: reduce) { + .animate-float-warm, + .node-highlight-1 .dark-node-card, + .node-highlight-2 .dark-node-card, + .node-highlight-3 .dark-node-card, + .node-highlight-4 .dark-node-card, + .node-highlight-5 .dark-node-card, + .node-highlight-6 .dark-node-card, + .node-highlight-7 .dark-node-card, + .node-highlight-1 .dark-node-dot, + .node-highlight-2 .dark-node-dot, + .node-highlight-3 .dark-node-dot, + .node-highlight-4 .dark-node-dot, + .node-highlight-5 .dark-node-dot, + .node-highlight-6 .dark-node-dot, + .node-highlight-7 .dark-node-dot, + .dashed-connector-animated, + .dashed-connector-animated-orange, + .happy-client-border-ring { + animation: none; + } + + .dark-node-card:hover { + transform: none; + } +} diff --git a/Features/Landing/Components/Mission.razor b/Features/Landing/Components/Mission.razor new file mode 100644 index 0000000..c5c059b --- /dev/null +++ b/Features/Landing/Components/Mission.razor @@ -0,0 +1,111 @@ +@page "/mission" + +About Us — CloudZen | Smart Technology for Growing Businesses + + + + + +
+ + @* ── Section 1: "About Us" hero row ── *@ +
+

About Us

+
+
+ + @* Row: Text left, Icon right *@ +
+
+

+ We Specialize in Bringing
+ Smart Technology to Growing Businesses +

+
+

+ CloudZen's goal is to help businesses break free from slow, outdated systems and missed opportunities — making day-to-day operations smoother and helping everyone grow. +

+ + GET STARTED → + +
+
+
+ +
+
+
+ + @* ── Section 2: "Our Mission" row (reversed) ── *@ +
+
+

+ Our mission is to
+ empower businesses to achieve their goals +

+

+ We believe the right tools and automation can improve everyone's work. Whether you are a business owner or the very customer engaging with that business, CloudZen builds solutions to help with: +

+
    + @{ var pointIdx = 0; } + @foreach (var point in _missionPoints) + { +
  • + + @point +
  • + pointIdx++; + } +
+
+
+
+ +
+
+
+ + @* ── Section 3: "Our Standards" grid ── *@ +
+

Our Standards

+
+
+ +
+ @{ var stdIdx = 0; } + @foreach (var standard in _standards) + { +
+ +
+ stdIdx++; + } +
+ + @* ── Section 4: "Empower" banner ── *@ +
+

+ We want to empower your business along the way! +

+

+ Visit our contact page +

+
+ + @* ── Section 5: Dark CTA footer ── *@ +
+

+ Start growing with CloudZen SmartSystems today! +

+
+ +
+ +
diff --git a/Features/Landing/Components/Mission.razor.cs b/Features/Landing/Components/Mission.razor.cs new file mode 100644 index 0000000..212bb10 --- /dev/null +++ b/Features/Landing/Components/Mission.razor.cs @@ -0,0 +1,32 @@ +using CloudZen.Features.Landing.Models; +using CloudZen.Features.Landing.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace CloudZen.Features.Landing.Components; + +/// +/// Code-behind for Mission.razor — loads mission points and standards data. +/// +public partial class Mission +{ + [Inject] private IMissionService MissionService { get; set; } = default!; + [Inject] private IJSRuntime JS { get; set; } = default!; + + private List _missionPoints = new(); + private List _standards = new(); + + protected override void OnInitialized() + { + _missionPoints = MissionService.GetMissionPoints(); + _standards = MissionService.GetStandards(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("initScrollReveal"); + } + } +} diff --git a/Features/Landing/Components/Mission.razor.css b/Features/Landing/Components/Mission.razor.css new file mode 100644 index 0000000..40766d5 --- /dev/null +++ b/Features/Landing/Components/Mission.razor.css @@ -0,0 +1,405 @@ +/* ── Mission Section Base ── */ +.mission-section { + background: #ffffff; +} + +.mission-header { + text-align: center; + margin-bottom: 4rem; +} + +.mission-title { + font-size: 2.25rem; + font-weight: 700; + color: #1f2937; + letter-spacing: -0.025em; +} + +.mission-title-accent { + width: 4rem; + height: 0.25rem; + background: linear-gradient(90deg, #61C2C8 0%, #5dd3d9 100%); + border-radius: 9999px; + margin: 0.75rem auto 0; +} + +/* ── Content Rows ── */ +.mission-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 2.5rem; + max-width: 72rem; + margin: 0 auto; + padding: 0 1.5rem; + margin-bottom: 6rem; +} + +@media (min-width: 768px) { + .mission-row { + flex-direction: row; + } + + .mission-row--reverse { + flex-direction: row-reverse; + } +} + +.mission-row--reverse { + /* Desktop: reversed layout */ +} + +.mission-text-content { + flex: 1; + text-align: center; +} + +@media (min-width: 768px) { + .mission-text-content { + text-align: left; + } +} + +.mission-heading { + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + line-height: 1.25; + margin-bottom: 1rem; +} + +@media (min-width: 768px) { + .mission-heading { + font-size: 1.875rem; + } +} + +.mission-highlight { + color: #0891b2; +} + +.mission-divider { + width: 4rem; + height: 0.25rem; + background: linear-gradient(90deg, #61C2C8 0%, #5dd3d9 100%); + border-radius: 9999px; + margin-bottom: 1rem; +} + +@media (min-width: 768px) { + .mission-divider { + margin-left: 0; + margin-right: auto; + } +} + +.mission-description { + color: #6b7280; + line-height: 1.75; + margin-bottom: 1.5rem; + max-width: 36rem; +} + +@media (min-width: 768px) { + .mission-description { + margin-left: auto; + margin-right: 0; + } +} + +.mission-cta { + display: inline-block; + padding: 0.875rem 2rem; + background: linear-gradient(135deg, #fb923c 0%, #f97316 100%); + color: #ffffff; + font-weight: 600; + border-radius: 9999px; + text-decoration: none; + transition: all 0.2s ease; + box-shadow: 0 2px 8px rgba(249, 115, 22, 0.2); +} + +.mission-cta:hover { + background: linear-gradient(135deg, #fdba74 0%, #fb923c 100%); + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(249, 115, 22, 0.3); +} + +.mission-cta:focus-visible { + outline: 2px solid #61C2C8; + outline-offset: 2px; +} + +.mission-cta:active { + transform: translateY(0); +} + +@media (prefers-reduced-motion: reduce) { + .mission-cta { + transition: none; + transform: none; + } + .mission-cta:hover { + transform: none; + } +} + +/* Icon Circle */ +.mission-icon-wrapper { + flex: 1; + display: flex; + align-items: center; + justify-content: center; +} + +.mission-icon-circle { + width: 12rem; + height: 12rem; + background: linear-gradient(135deg, #f0fdfa 0%, #ccfbf1 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #0891b2; + box-shadow: 0 8px 32px rgba(8, 145, 178, 0.12); + transition: transform 0.3s ease; +} + +.mission-icon-circle:hover { + transform: scale(1.05); +} + +.mission-icon { + font-size: 3.5rem; +} + +@media (prefers-reduced-motion: reduce) { + .mission-icon-circle { + transition: none; + } + .mission-icon-circle:hover { + transform: none; + } +} + +/* ── Mission Points List ── */ +.mission-points { + list-style: none; + padding: 0; + margin: 0; + text-align: left; + max-width: 28rem; +} + +.mission-point { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin-bottom: 0.5rem; + color: #4b5563; +} + +.mission-point-icon { + color: #0891b2; + flex-shrink: 0; + margin-top: 0.125rem; +} + +/* ── Standards Section ── */ +.standards-header { + text-align: center; + margin-bottom: 3rem; +} + +.standards-title { + font-size: 1.875rem; + font-weight: 700; + color: #1f2937; + margin-bottom: 0.75rem; + letter-spacing: -0.025em; +} + +.standards-title-accent { + width: 4rem; + height: 0.25rem; + background: linear-gradient(90deg, #61C2C8 0%, #5dd3d9 100%); + border-radius: 9999px; + margin: 0 auto; +} + +/* StandardCard styles - handled via StandardCard.razor.css */ + +/* ── Empower Banner ── */ +.empower-banner { + text-align: center; + padding: 4rem 1.5rem; + margin-top: 4rem; +} + +.empower-heading { + font-size: 1.5rem; + font-weight: 700; + color: #1f2937; + font-style: italic; + margin-bottom: 0.5rem; +} + +.empower-subheading { + font-size: 1.25rem; + font-weight: 700; + color: #1f2937; +} + +.empower-link { + color: #0891b2; + text-decoration: underline; + text-underline-offset: 4px; + transition: color 0.2s ease; +} + +.empower-link:hover { + color: #06b6d4; +} + +.empower-link:focus-visible { + outline: 2px solid #61C2C8; + outline-offset: 2px; + border-radius: 2px; +} + +/* ── CTA Footer ── */ +.cta-footer { + background: linear-gradient(135deg, #374151 0%, #1f2937 100%); + color: #ffffff; + text-align: center; + padding: 4rem 1.5rem; +} + +.cta-footer-title { + font-size: 1.5rem; + font-weight: 700; + margin-bottom: 1rem; +} + +@media (min-width: 768px) { + .cta-footer-title { + font-size: 1.875rem; + } +} + +.cta-footer-brand { + font-weight: 700; + color: #61C2C8; +} + +.cta-footer-accent { + width: 4rem; + height: 0.25rem; + background: linear-gradient(90deg, #61C2C8 0%, #5dd3d9 100%); + border-radius: 9999px; + margin: 0 auto 2rem; +} + +.cta-footer-buttons { + display: flex; + flex-direction: column; + align-items: center; + gap: 1rem; + justify-content: center; +} + +@media (min-width: 640px) { + .cta-footer-buttons { + flex-direction: row; + } +} + +.cta-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.875rem 2rem; + font-size: 0.9375rem; + font-weight: 600; + border-radius: 9999px; + text-decoration: none; + transition: all 0.2s ease; + min-height: 48px; + letter-spacing: 0.025em; + text-transform: uppercase; +} + +.cta-button--primary { + background: linear-gradient(135deg, #fb923c 0%, #f97316 100%); + color: #ffffff; + box-shadow: 0 2px 8px rgba(249, 115, 22, 0.3); +} + +.cta-button--primary:hover { + background: linear-gradient(135deg, #fdba74 0%, #fb923c 100%); + box-shadow: 0 4px 16px rgba(249, 115, 22, 0.4); + transform: translateY(-2px); +} + +.cta-button--primary:focus-visible { + outline: 2px solid #61C2C8; + outline-offset: 2px; +} + +.cta-button--primary:active { + transform: translateY(0); +} + +.cta-button--secondary { + background: #ffffff; + color: #1f2937; + border: 2px solid #ffffff; +} + +.cta-button--secondary:hover { + background: #f3f4f6; + border-color: #f3f4f6; + transform: translateY(-2px); +} + +.cta-button--secondary:focus-visible { + outline: 2px solid #61C2C8; + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + .cta-button { + transition: none; + } + .cta-button:hover { + transform: none; + } +} + +/* ── Responsive Adjustments ── */ +@media (max-width: 767px) { + .mission-header { + margin-bottom: 3rem; + } + + .mission-title { + font-size: 1.875rem; + } + + .mission-row { + margin-bottom: 4rem; + } + + .mission-icon-circle { + width: 10rem; + height: 10rem; + } + + .mission-icon { + font-size: 2.75rem; + } + + .cta-footer { + padding: 3rem 1.5rem; + } +} \ No newline at end of file diff --git a/Features/Landing/Components/ServiceCard.razor b/Features/Landing/Components/ServiceCard.razor new file mode 100644 index 0000000..968b6b8 --- /dev/null +++ b/Features/Landing/Components/ServiceCard.razor @@ -0,0 +1,22 @@ + +@* A single service card with Bootstrap Icon, title, and HTML description. *@ + +
+ +
+ +
+

@Service.Title

+

@((MarkupString)Service.Description)

+
+ +@code { + [Parameter, EditorRequired] + public ServiceInfo Service { get; set; } = default!; +} diff --git a/Features/Landing/Components/Services.razor b/Features/Landing/Components/Services.razor new file mode 100644 index 0000000..9962c5f --- /dev/null +++ b/Features/Landing/Components/Services.razor @@ -0,0 +1,140 @@ +@page "/services" + +Services — CloudZen | Technology Solutions, Automation & System Modernization + + + + + +@* ══════════════════════════════════════════════════════════════ + Services Page — Standalone page showcasing CloudZen's offerings. + Layout: Hero → Featured services (top 3) → Full grid → How We Work → CTA + ══════════════════════════════════════════════════════════════ *@ + +@* ── Hero Banner ── *@ +
+
+ + Your Technology Partner + +

+ We Handle the Technology. + You Focus on Growing and Making Money. +

+

+ One partner who builds, modernizes, and automates — so you don't have to hire an entire tech department. +

+
+
+
+ +@* ── Value Proposition ── *@ +
+
+

+ Whether you're stuck with slow systems, drowning in manual tasks, or just need technology that works — + CloudZen is here to help. + We modernize legacy systems, automate repetitive tasks, and deploy tools that streamline operations. + By handling the technology behind the scenes, we let you focus entirely on your customers. +

+
+
+ +@* ── Featured Services (Top 3) ── *@ +
+
+
+

Core Solutions

+
+

The foundation of every transformation we deliver.

+
+
+ @{ var featIdx = 0; } + @foreach (var service in _featured) + { +
+ +
+ +
+

@service.Title

+

@((MarkupString)service.Description)

+
+ featIdx++; + } +
+
+
+ +@* ── All Services Grid ── *@ +
+
+
+

Everything We Offer

+
+
+
+ @{ var remIdx = 0; } + @foreach (var service in _remaining) + { +
+ +
+ remIdx++; + } +
+
+
+ +@* ── How We Work (3 Steps) ── *@ +
+
+
+

How We Work

+
+

A simple, transparent process from start to finish.

+
+
+ @* Step 1 *@ +
+
1
+

Discover

+

We listen to your challenges, understand your workflow, and identify the bottlenecks holding your business back.

+
+ @* Step 2 *@ +
+
2
+

Build

+

We design and develop a tailored solution in short stages — keeping you in the loop with visible progress every week.

+
+ @* Step 3 *@ +
+
3
+

Launch & Grow

+

After rigorous testing, we launch with confidence. Your systems are ready to scale as your business grows.

+
+
+
+
+ +@* ── Bottom CTA ── *@ +
+
+

Ready to modernize your business?

+

Let's talk about what's slowing you down — and how we can fix it.

+ +
+
diff --git a/Features/Landing/Components/Services.razor.cs b/Features/Landing/Components/Services.razor.cs new file mode 100644 index 0000000..881ae96 --- /dev/null +++ b/Features/Landing/Components/Services.razor.cs @@ -0,0 +1,33 @@ +using CloudZen.Features.Landing.Models; +using CloudZen.Features.Landing.Services; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace CloudZen.Features.Landing.Components; + +/// +/// Code-behind for Services.razor — loads service offerings split into featured and remaining. +/// +public partial class Services +{ + [Inject] private IServiceOfferingsService ServiceOfferings { get; set; } = default!; + [Inject] private IJSRuntime JS { get; set; } = default!; + + private List _featured = new(); + private List _remaining = new(); + + protected override void OnInitialized() + { + var all = ServiceOfferings.GetAllServices(); + _featured = all.Take(3).ToList(); + _remaining = all.Skip(3).ToList(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("initScrollReveal"); + } + } +} diff --git a/Features/Landing/Components/StandardCard.razor b/Features/Landing/Components/StandardCard.razor new file mode 100644 index 0000000..51709fd --- /dev/null +++ b/Features/Landing/Components/StandardCard.razor @@ -0,0 +1,15 @@ + +@* A single standard card: icon, title, and description. *@ + +
+
+ +
+

@Standard.Title

+

@Standard.Description

+
+ +@code { + [Parameter, EditorRequired] + public StandardInfo Standard { get; set; } = default!; +} diff --git a/Shared/Landing/Testimonials.razor b/Features/Landing/Components/Testimonials.razor similarity index 92% rename from Shared/Landing/Testimonials.razor rename to Features/Landing/Components/Testimonials.razor index 097a05d..5dd76ec 100644 --- a/Shared/Landing/Testimonials.razor +++ b/Features/Landing/Components/Testimonials.razor @@ -1,4 +1,4 @@ -
+

What Our Clients Say

diff --git a/Features/Landing/Components/ToolCardItem.razor b/Features/Landing/Components/ToolCardItem.razor new file mode 100644 index 0000000..912a3df --- /dev/null +++ b/Features/Landing/Components/ToolCardItem.razor @@ -0,0 +1,15 @@ + +@* A single tool card: icon, title, and description. Reusable via the Tool parameter. *@ + +
+
+ @((MarkupString)Tool.IconMarkup) +
+

@Tool.Title

+

@Tool.Description

+
+ +@code { + [Parameter, EditorRequired] + public ToolInfo Tool { get; set; } = default!; +} diff --git a/Features/Landing/Components/ToolsOverview.razor b/Features/Landing/Components/ToolsOverview.razor new file mode 100644 index 0000000..9acc307 --- /dev/null +++ b/Features/Landing/Components/ToolsOverview.razor @@ -0,0 +1,29 @@ + +@* Section: "All the tools you need to grow in one place." + Displays a grid of tool cards with SVG icons. + Includes the "What You Get" value propositions (previously in ValueProposition.razor). *@ + +
+

+ All the tools you need to grow in one place. +

+
+ +
+ @{ var toolIndex = 0; } + @foreach (var tool in _tools) + { +
+ +
+ toolIndex++; + } +
+ + +
diff --git a/Features/Landing/Components/ToolsOverview.razor.cs b/Features/Landing/Components/ToolsOverview.razor.cs new file mode 100644 index 0000000..642152a --- /dev/null +++ b/Features/Landing/Components/ToolsOverview.razor.cs @@ -0,0 +1,20 @@ +using CloudZen.Features.Landing.Models; +using CloudZen.Features.Landing.Services; +using Microsoft.AspNetCore.Components; + +namespace CloudZen.Features.Landing.Components; + +/// +/// Code-behind for ToolsOverview.razor — loads tool items from service. +/// +public partial class ToolsOverview +{ + [Inject] private IToolService ToolService { get; set; } = default!; + + private List _tools = new(); + + protected override void OnInitialized() + { + _tools = ToolService.GetAllTools(); + } +} diff --git a/Features/Landing/Models/FeatureHighlight.cs b/Features/Landing/Models/FeatureHighlight.cs new file mode 100644 index 0000000..ca4e11e --- /dev/null +++ b/Features/Landing/Models/FeatureHighlight.cs @@ -0,0 +1,18 @@ +namespace CloudZen.Features.Landing.Models; + +/// +/// Represents a single feature highlight with alternating text/image layout. +/// +/// Optional small text above the title. +/// The regular-weight portion of the title. +/// The bold/italic keyword in the title. +/// The text after the bold keyword. +/// A brief description of the feature. +/// Path to the illustration image. +public record FeatureHighlight( + string? Subtitle, + string TitlePrefix, + string TitleBold, + string TitleSuffix, + string Description, + string ImagePath); diff --git a/Models/ServiceInfo.cs b/Features/Landing/Models/ServiceInfo.cs similarity index 89% rename from Models/ServiceInfo.cs rename to Features/Landing/Models/ServiceInfo.cs index 8008ea5..4396ba9 100644 --- a/Models/ServiceInfo.cs +++ b/Features/Landing/Models/ServiceInfo.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models; +namespace CloudZen.Features.Landing.Models; /// /// Represents a professional service offering. diff --git a/Features/Landing/Models/StandardInfo.cs b/Features/Landing/Models/StandardInfo.cs new file mode 100644 index 0000000..e1cb478 --- /dev/null +++ b/Features/Landing/Models/StandardInfo.cs @@ -0,0 +1,9 @@ +namespace CloudZen.Features.Landing.Models; + +/// +/// Represents a single standard/value displayed in the "Our Standards" grid. +/// +/// Bootstrap Icon class (e.g. "bi-lightning-charge"). +/// The standard's display title. +/// A brief description of the standard. +public record StandardInfo(string IconClass, string Title, string Description); diff --git a/Features/Landing/Models/ToolInfo.cs b/Features/Landing/Models/ToolInfo.cs new file mode 100644 index 0000000..0916eab --- /dev/null +++ b/Features/Landing/Models/ToolInfo.cs @@ -0,0 +1,9 @@ +namespace CloudZen.Features.Landing.Models; + +/// +/// Represents a single tool/feature displayed in the Tools Overview section. +/// +/// The HTML markup for the tool icon (e.g. a Bootstrap Icon). +/// The tool's display title. +/// A brief description of what the tool does. +public record ToolInfo(string IconMarkup, string Title, string Description); diff --git a/Features/Landing/Services/CaseStudyService.cs b/Features/Landing/Services/CaseStudyService.cs new file mode 100644 index 0000000..c87089e --- /dev/null +++ b/Features/Landing/Services/CaseStudyService.cs @@ -0,0 +1,88 @@ + +using CloudZen.Features.Projects.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Converts technical project data into business-friendly presentation text +/// for the case studies section. +/// +public class CaseStudyService : ICaseStudyService +{ + /// + /// Determines the display category badge for a project based on its type string. + /// + public string GetProjectCategory(string projectType) + { + if (projectType.Contains("Customer")) + return "Customer Success"; + if (projectType.Contains("AI Automation")) + return "AI Automation"; + return "Innovation Project"; + } + + /// + /// Determines the display category badge from a enum value. + /// + public string GetProjectCategory(ProjectCategory category) => category switch + { + ProjectCategory.CustomerWork => "Customer Success", + ProjectCategory.AiAutomation => "AI Automation", + ProjectCategory.SideProject => "Innovation Project", + _ => "Project" + }; + + /// + /// Converts long project titles into shorter, more display-friendly versions. + /// + public string GetShortTitle(string title) + { + if (title.Contains("WPBT")) + return "Assessment Platform Modernization"; + if (title.Contains("ETL Optimization")) + return "Data Pipeline Optimization"; + if (title.Contains("VPKFILEPROCESSOR")) + return "File Processing Automation"; + if (title.Contains("Smart Menu")) + return "AI Menu Optimization"; + if (title.Contains("AI Chatbot")) + return "Personalized AI Chatbot Assistance"; + if (title.Contains("Booking Appointments")) + return "Smart Appointment Booking System"; + if (title.Contains("Customer-Facing Web")) + return "Custom Web Application"; + + return title.Length > 50 ? title.Substring(0, 47) + "..." : title; + } + + /// + /// Translates technical descriptions into business-friendly language. + /// + public string GetCustomerFriendlyDescription(string description) + { + var simplified = description + .Replace("ASP.NET Web Forms to modular ASP.NET Core architecture", "outdated systems to modern technology") + .Replace("SSIS ETL pipeline", "data processing pipeline") + .Replace("ABAP-driven delta extraction", "smart data extraction") + .Replace("cloud-native solution", "modern online solution") + .Replace("Blazor Server interface", "user-friendly web interface") + .Replace("Azure Event Grid", "automated notifications"); + + return simplified.Length > 150 ? simplified.Substring(0, 147) + "..." : simplified; + } + + /// + /// Simplifies technical result statements for non-technical audiences. + /// + public string GetSimplifiedResult(string result) + { + var simplified = result + .Replace("turnaround times by roughly", "delivery speed by") + .Replace("Runtime Reduction through Delta Processing", "faster processing") + .Replace("Scales-Out efficiently with large datasets", "Handles growing data smoothly") + .Replace("CI/CD pipelines", "automated deployments") + .Replace("Azure Event Grid", "automated notifications"); + + return simplified.Length > 80 ? simplified.Substring(0, 77) + "..." : simplified; + } +} diff --git a/Features/Landing/Services/FeatureHighlightService.cs b/Features/Landing/Services/FeatureHighlightService.cs new file mode 100644 index 0000000..36d242b --- /dev/null +++ b/Features/Landing/Services/FeatureHighlightService.cs @@ -0,0 +1,53 @@ +using CloudZen.Features.Landing.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Provides the list of feature highlights displayed in the Features Showcase section. +/// +public class FeatureHighlightService : IFeatureHighlightService +{ + public List GetAllFeatures() => new() + { + new FeatureHighlight( + Subtitle: "Reclaim Your Calendar", + TitlePrefix: "Put the Busy Work on ", + TitleBold: "Autopilot", + TitleSuffix: "", + Description: "Stop wasting hours on repetitive tasks. CloudZen sets up smart systems that handle the boring stuff — scheduling, data entry, follow-ups — so you can focus on the work that actually makes you money.", + ImagePath: "/images/features/autopilot.webp" + ), + new FeatureHighlight( + Subtitle: "Built Around Your Workflow", + TitlePrefix: "", + TitleBold: "Custom Systems", + TitleSuffix: " That Work the Way You Do", + Description: "Stop trying to fit your business into a box. CloudZen builds tools designed around your specific daily routine and goals, so your technology finally supports you instead of getting in your way.", + ImagePath: "/images/features/custom-systems.webp" + ), + new FeatureHighlight( + Subtitle: "See What Matters at a Glance", + TitlePrefix: "Clear, Simple ", + TitleBold: "Insights", + TitleSuffix: " From Your Data", + Description: "Stop digging through messy spreadsheets. We create simple, clear dashboards that show you exactly how your business is performing, so you can make decisions with total confidence.", + ImagePath: "/images/features/data-insights.webp" + ), + new FeatureHighlight( + Subtitle: "Out With the Old", + TitlePrefix: "", + TitleBold: "Modernize", + TitleSuffix: " Your Systems, Keep Your Momentum", + Description: "Tired of outdated systems holding you back? We help you transition smoothly to modern solutions that keep your business running while setting you up for the future — no downtime, no disruption.", + ImagePath: "/images/features/modernize-systems.webp" + ), + new FeatureHighlight( + Subtitle: "Speed Wins Customers", + TitlePrefix: "", + TitleBold: "Faster Results", + TitleSuffix: " Mean Happier Customers", + Description: "CloudZen helps you clear the bottlenecks so you can respond to clients and deliver your services quicker. When you move faster, your customers stay happier — and keep coming back.", + ImagePath: "/images/features/faster-result.webp" + ) + }; +} diff --git a/Features/Landing/Services/ICaseStudyService.cs b/Features/Landing/Services/ICaseStudyService.cs new file mode 100644 index 0000000..db17e07 --- /dev/null +++ b/Features/Landing/Services/ICaseStudyService.cs @@ -0,0 +1,16 @@ +using CloudZen.Features.Projects.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Interface for case study text-transformation and display helpers. +/// Converts technical project data into business-friendly presentation text. +/// +public interface ICaseStudyService +{ + string GetProjectCategory(string projectType); + string GetProjectCategory(ProjectCategory category); + string GetShortTitle(string title); + string GetCustomerFriendlyDescription(string description); + string GetSimplifiedResult(string result); +} diff --git a/Features/Landing/Services/IFeatureHighlightService.cs b/Features/Landing/Services/IFeatureHighlightService.cs new file mode 100644 index 0000000..f10b3dc --- /dev/null +++ b/Features/Landing/Services/IFeatureHighlightService.cs @@ -0,0 +1,11 @@ +using CloudZen.Features.Landing.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Interface for retrieving feature highlights for the Features Showcase section. +/// +public interface IFeatureHighlightService +{ + List GetAllFeatures(); +} diff --git a/Features/Landing/Services/IMissionService.cs b/Features/Landing/Services/IMissionService.cs new file mode 100644 index 0000000..000ff31 --- /dev/null +++ b/Features/Landing/Services/IMissionService.cs @@ -0,0 +1,12 @@ +using CloudZen.Features.Landing.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Interface for retrieving CloudZen's mission data and company standards/values. +/// +public interface IMissionService +{ + List GetMissionPoints(); + List GetStandards(); +} diff --git a/Features/Landing/Services/IServiceOfferingsService.cs b/Features/Landing/Services/IServiceOfferingsService.cs new file mode 100644 index 0000000..9c71545 --- /dev/null +++ b/Features/Landing/Services/IServiceOfferingsService.cs @@ -0,0 +1,11 @@ +using CloudZen.Features.Landing.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Interface for retrieving professional service offerings. +/// +public interface IServiceOfferingsService +{ + List GetAllServices(); +} diff --git a/Features/Landing/Services/IToolService.cs b/Features/Landing/Services/IToolService.cs new file mode 100644 index 0000000..6f12fbe --- /dev/null +++ b/Features/Landing/Services/IToolService.cs @@ -0,0 +1,11 @@ +using CloudZen.Features.Landing.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Interface for retrieving tool/feature items for the Tools Overview section. +/// +public interface IToolService +{ + List GetAllTools(); +} diff --git a/Features/Landing/Services/MissionService.cs b/Features/Landing/Services/MissionService.cs new file mode 100644 index 0000000..caed384 --- /dev/null +++ b/Features/Landing/Services/MissionService.cs @@ -0,0 +1,60 @@ +using CloudZen.Features.Landing.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Provides CloudZen's mission data and company standards/values. +/// +public class MissionService : IMissionService +{ + /// + /// Returns the list of capabilities CloudZen helps businesses with. + /// Displayed as a checklist in the mission section. + /// + public List GetMissionPoints() => new() + { + "System Modernization", + "Smart Automation", + "Cloud Migration", + "Data-Driven Insights", + "And So Much More!" + }; + + /// + /// Returns CloudZen's core standards and values. + /// Displayed as a 3-column icon grid. + /// + public List GetStandards() => new() + { + new StandardInfo( + IconClass: "bi-arrow-repeat", + Title: "Staying Relevant", + Description: "We keep your technology current so you can serve your customers at a higher level." + ), + new StandardInfo( + IconClass: "bi-graph-up-arrow", + Title: "Maximum Growth", + Description: "Our goal is to give you the tools and resources to maximize your business growth." + ), + new StandardInfo( + IconClass: "bi-heart", + Title: "Positive Impact", + Description: "We want to help you amplify the positive impact you have in your community." + ), + new StandardInfo( + IconClass: "bi-grid-3x3-gap", + Title: "Cross-Functional", + Description: "Our solutions give you the ability to perform at your best across all platforms." + ), + new StandardInfo( + IconClass: "bi-people", + Title: "Multidisciplinary Team", + Description: "We bring in a highly diverse team in skills and culture to serve you better." + ), + new StandardInfo( + IconClass: "bi-cpu", + Title: "Cutting-Edge Technology", + Description: "We strive to bring you the best tools the market has to offer." + ) + }; +} diff --git a/Features/Landing/Services/ServiceOfferingsService.cs b/Features/Landing/Services/ServiceOfferingsService.cs new file mode 100644 index 0000000..75be1dc --- /dev/null +++ b/Features/Landing/Services/ServiceOfferingsService.cs @@ -0,0 +1,57 @@ +using CloudZen.Features.Landing.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Service for managing and retrieving professional service offerings. +/// This service centralizes service data management and can be extended to load from external sources (API, database, JSON files, etc.). +/// +public class ServiceOfferingsService : IServiceOfferingsService +{ + /// + /// Retrieves all professional services offered. + /// + /// A list of ServiceInfo objects representing the service portfolio. + public List GetAllServices() + { + return GetServicesData(); + } + + /// + /// Central method containing all service data. + /// Icon values are Bootstrap Icon class names (without the "bi-" prefix is added in the component). + /// + /// Complete list of services. + private List GetServicesData() + { + return + [ + new ServiceInfo("bi-laptop", "Systems That Work the Way You Do", + "Stop trying to fit your business into a box. CloudZen builds custom tools designed around your specific daily routine and goals, so your technology finally supports you instead of getting in your way."), + + new ServiceInfo("bi-cloud-arrow-up", "Your Business, Everywhere You Need It", + "Move your operations online securely so you can access your work from anywhere. It's all about giving you more flexibility and lower costs without the tech headache or surprise bills."), + + new ServiceInfo("bi-rocket-takeoff", "Out With the Old, In With the New", + "Tired of outdated systems holding you back? We help you transition smoothly to modern solutions that keep your business running while setting you up for the future."), + + new ServiceInfo("bi-speedometer2", "Faster Results Mean Happier Customers", + "CloudZen helps you clear the bottlenecks so you can respond to clients and deliver your services quicker. When you move faster, your customers stay happier and keep coming back."), + + new ServiceInfo("bi-bar-chart-line", "Clear, Simple Insights From Your Data", + "Stop digging through messy spreadsheets. We create simple, clear dashboards that show you exactly how your business is performing at a glance, so you can make decisions with total confidence."), + + new ServiceInfo("bi-robot", "Put the Busy Work on Autopilot", + "Reclaim your calendar. CloudZen sets up smart systems to handle those boring, repetitive tasks that eat up your day, leaving you free to focus on the work that actually makes you money."), + + new ServiceInfo("bi-people", "A Big Team's Brains, a Solo Partner's Care", + "You get the best of both worlds. CloudZen leads your project personally, and when we need a specific niche expert, we bring in a trusted specialist so you get top-tier results without the corporate runaround."), + + new ServiceInfo("bi-shield-check", "Total Peace of Mind on Day One", + "Your solution undergoes rigorous testing behind the scenes before your customers ever see it. You can launch your new tools with zero stress, knowing everything will work perfectly the moment you hit go."), + + new ServiceInfo("bi-arrow-repeat", "You're in the Loop Every Step of the Way", + "No big reveals or expensive surprises at the end. We work together in short stages so you can see the progress every week and make sure the final result is exactly what your business needs.") + ]; + } +} diff --git a/Features/Landing/Services/ToolService.cs b/Features/Landing/Services/ToolService.cs new file mode 100644 index 0000000..efa8295 --- /dev/null +++ b/Features/Landing/Services/ToolService.cs @@ -0,0 +1,106 @@ +using CloudZen.Features.Landing.Models; + +namespace CloudZen.Features.Landing.Services; + +/// +/// Provides the list of tool/feature items displayed in the Tools Overview section. +/// +public class ToolService : IToolService +{ + public List GetAllTools() => new() + { + new ToolInfo( + IconMarkup: "", + Title: "One Point of Contact", + Description: "Work directly with the person building your solution — no phone trees, no runaround, just clear answers when you need them." + ), + new ToolInfo( + IconMarkup: "", + Title: "The Right Help, When You Need It", + Description: "When your project calls for specialized skills, we bring in trusted experts, so every detail is covered and nothing slips through the cracks." + ), + new ToolInfo( + IconMarkup: "", + Title: "Real-World Results", + Description: "Proven results replacing outdated systems, saving teams hours every week, and helping businesses win more customers through streamlined operations." + ), + new ToolInfo( + IconMarkup: "", + Title: "Solutions That Fit Your Business", + Description: "Every business is different. We tailor solutions to what actually moves the needle for you. Less hassle, more impact." + ), + //new ToolInfo( + // SvgMarkup: "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "", + // Title: "Online Reviews", + // Description: "Automate your online reviews with a few simple clicks & respond to reviews in one place." + //), + //new ToolInfo( + // SvgMarkup: "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "", + // Title: "Messaging", + // Description: "Manage your messages with a single inbox for text, Facebook messages, Google messages, and more." + //), + //new ToolInfo( + // SvgMarkup: "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "", + // Title: "Webchat", + // Description: "Convert more website visitors into leads & sales conversations with Webchat." + //), + //new ToolInfo( + // SvgMarkup: "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "" + // + "", + // Title: "Missed Call Text Back", + // Description: "Never lose a lead again — automatically text back customers when you miss their call." + //), + //new ToolInfo( + // SvgMarkup: "" + // + "" + // + "" + // + "" + // + "" + // + "CRM" + // + "", + // Title: "CRM", + // Description: "Track every lead, manage your pipeline, and close more deals with a powerful built-in CRM." + //), + new ToolInfo( + IconMarkup: "", + Title: "Speed & Reliability", + Description: "Fast, scalable systems that grow with you - so you can reach customers and get to market quickly and confidently." + ), + new ToolInfo( + IconMarkup: "", + Title: "Smart Automation", + Description: "Automate repetitive tasks and free up your team to focus on what actually matters - growing your business." + ) + }; +} diff --git a/Features/Legal/Components/Faq.razor b/Features/Legal/Components/Faq.razor new file mode 100644 index 0000000..6398f2c --- /dev/null +++ b/Features/Legal/Components/Faq.razor @@ -0,0 +1,74 @@ +@page "/faq" + +FAQ — CloudZen | Frequently Asked Questions + + + + +
+
+ + @* ── Header ── *@ +
+

Frequently Asked Questions

+
+

+ Everything you need to know about working with CloudZen. Can't find what you're looking for? + Get in touch. +

+
+ + @* ── Accordion ── *@ +
+ @for (var i = 0; i < _faqItems.Count; i++) + { + var index = i; + var item = _faqItems[index]; + var isOpen = _openIndex == index; + var panelId = $"faq-panel-{index}"; + var headingId = $"faq-heading-{index}"; + var revealDelay = (index % 6) + 1; + +
+
+

+ +

+
+
+ @item.Answer +
+
+
+
+ } +
+ + @* ── CTA ── *@ +
+

Still have questions?

+

+ We'd love to hear from you. Book a free consultation or drop us a message. +

+ +
+ +
+
diff --git a/Features/Legal/Components/Faq.razor.cs b/Features/Legal/Components/Faq.razor.cs new file mode 100644 index 0000000..cc6d1ff --- /dev/null +++ b/Features/Legal/Components/Faq.razor.cs @@ -0,0 +1,116 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace CloudZen.Features.Legal.Components; + +public sealed partial class Faq : ComponentBase +{ + [Inject] private IJSRuntime JS { get; set; } = default!; + + private int? _openIndex; + + private void Toggle(int index) => + _openIndex = _openIndex == index ? null : index; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("initScrollReveal"); + } + } + + private sealed record FaqItem(string Question, RenderFragment Answer); + + private readonly List _faqItems = + [ + new("What does CloudZen do?", builder => + { + builder.AddMarkupContent(0, + "

CloudZen helps small and medium-sized businesses modernize their technology. " + + "We build custom systems, migrate to the cloud, automate workflows, and create data dashboards — " + + "so you can focus on growing your business instead of wrestling with outdated tools.

"); + }), + + new("How does the Build & Grow model work?", builder => + { + builder.AddMarkupContent(0, + "

Our process has three simple stages:

" + + "
    " + + "
  1. Discover — We learn about your business, goals, and pain points in a free consultation.
  2. " + + "
  3. Build — We design and develop your solution in short stages, with weekly check-ins so you see progress every step of the way.
  4. " + + "
  5. Launch & Grow — We deploy, train your team, and provide ongoing support as your business evolves.
  6. " + + "
"); + }), + + new("How much do your services cost?", builder => + { + builder.AddMarkupContent(0, + "

We use a flat-fee project model — no hourly surprises. After your free consultation, " + + "we provide a clear proposal with a fixed price based on your project scope. " + + "The initial consultation is always free with no obligation.

"); + }), + + new("How do I get started?", builder => + { + builder.AddMarkupContent(0, + "

Easy — book a free 30-minute consultation. " + + "We'll discuss your needs, answer your questions, and outline how we can help. " + + "No commitment required. You can also email us at " + + "cloudzen.inc@gmail.com " + + "and we'll respond within 24 hours.

"); + }), + + new("What technologies do you use?", builder => + { + builder.AddMarkupContent(0, + "

We work with modern, proven technologies including .NET, Blazor, Azure cloud services, " + + "PostgreSQL, and AI-powered tools. But we're technology-agnostic — we choose the best tools " + + "for your specific needs, not the other way around.

"); + }), + + new("How long do projects typically take?", builder => + { + builder.AddMarkupContent(0, + "

Timelines depend on scope, but most projects follow our staged approach:

" + + "
    " + + "
  • Small automation or dashboard — 2 to 4 weeks
  • " + + "
  • System modernization — 1 to 3 months
  • " + + "
  • Full custom platform — 3 to 6 months
  • " + + "
" + + "

We break every project into short stages so you see progress weekly — no disappearing for months.

"); + }), + + new("Do you offer ongoing support after launch?", builder => + { + builder.AddMarkupContent(0, + "

Yes. We don't build and vanish. After launch, we offer ongoing support and maintenance to keep " + + "your system running smoothly. We can also iterate on your solution as your business grows and needs evolve.

"); + }), + + new("What industries do you work with?", builder => + { + builder.AddMarkupContent(0, + "

We work across industries — our focus is on small and medium businesses that need " + + "better technology without the overhead of a full IT department. We've helped companies in professional services, " + + "logistics, healthcare administration, retail operations, and more.

"); + }), + + new("Is my data secure?", builder => + { + builder.AddMarkupContent(0, + "

We take security seriously. Our website uses HTTPS encryption, strict security headers, and " + + "all secrets are managed through Azure Key Vault. We follow industry best practices for input validation, " + + "rate limiting, and data protection. For full details, see our " + + "Privacy Policy.

"); + }), + + new("What if I'm not sure what I need?", builder => + { + builder.AddMarkupContent(0, + "

That's exactly what the free consultation is for. Many of our clients start with a vague feeling that " + + "\"things could be better\" — and we help them identify specific improvements that will have the biggest " + + "impact. No jargon, no pressure. Just a conversation about your goals.

"); + }), + ]; +} diff --git a/Features/Legal/Components/Faq.razor.css b/Features/Legal/Components/Faq.razor.css new file mode 100644 index 0000000..134b697 --- /dev/null +++ b/Features/Legal/Components/Faq.razor.css @@ -0,0 +1,274 @@ +/* ── FAQ Accordion ── */ + +.faq-list { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.faq-item { + background: #ffffff; + border: 1px solid rgba(97, 194, 200, 0.25); + border-radius: 0.75rem; + overflow: hidden; + transition: border-color 0.2s ease, box-shadow 0.2s ease; +} + +.faq-item:hover { + border-color: #d1d5db; +} + +.faq-item--open { + border-color: rgba(97, 194, 200, 0.5); + box-shadow: 0 4px 16px rgba(97, 194, 200, 0.1); +} + +/* Trigger button */ +.faq-trigger { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 1.125rem 1.25rem; + background: none; + border: none; + cursor: pointer; + text-align: left; + gap: 1rem; + transition: background-color 0.15s ease; +} + +.faq-trigger:hover { + background-color: #fafbfc; +} + +.faq-trigger:focus-visible { + outline: 2px solid #61C2C8; + outline-offset: 2px; + border-radius: 0.625rem; +} + +.faq-question { + font-size: 0.9375rem; + font-weight: 600; + color: #1f2937; + line-height: 1.4; +} + +/* Chevron */ +.faq-chevron { + flex-shrink: 0; + color: #9ca3af; + transition: transform 0.25s cubic-bezier(0.4, 0, 0.2, 1), color 0.2s ease; + width: 20px; + height: 20px; +} + +.faq-chevron--open { + transform: rotate(180deg); + color: #61C2C8; +} + +/* Answer panel */ +.faq-panel { + max-height: 0; + overflow: hidden; + transition: max-height 0.2s ease-out, opacity 0.2s ease-out; + opacity: 0; +} + +.faq-panel--open { + max-height: 800px; + opacity: 1; + animation: faq-slide-down 0.2s ease-out forwards; +} + +@media (prefers-reduced-motion: reduce) { + .faq-panel { + animation: none; + } + .faq-chevron { + transition: none; + } + .faq-item, + .faq-trigger { + transition: none; + } +} + +.faq-answer { + padding: 0 1.25rem 1.25rem; + color: #4b5563; + font-size: 0.9375rem; + line-height: 1.7; +} + +.faq-answer p { + margin-bottom: 0.75rem; +} + +.faq-answer p:last-child { + margin-bottom: 0; +} + +/* Links inside answers */ +::deep .faq-answer-link { + color: #0891b2; + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.2s ease; + font-weight: 500; +} + +::deep .faq-answer-link:hover { + color: #06b6d4; +} + +::deep .faq-answer-link:focus-visible { + outline: 2px solid #61C2C8; + outline-offset: 2px; + border-radius: 2px; +} + +/* Lists inside answers */ +::deep .faq-ordered-list, +::deep .faq-unordered-list { + padding-left: 1.5rem; + margin: 0.5rem 0 0.75rem; +} + +::deep .faq-ordered-list { + list-style: decimal; +} + +::deep .faq-unordered-list { + list-style: disc; +} + +::deep .faq-ordered-list li, +::deep .faq-unordered-list li { + margin-bottom: 0.375rem; +} + +::deep .faq-ordered-list li strong, +::deep .faq-unordered-list li strong { + color: #374151; +} + +/* Inline link in header */ +.faq-inline-link { + color: #0891b2; + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.2s ease; + font-weight: 500; +} + +.faq-inline-link:hover { + color: #06b6d4; +} + +.faq-inline-link:focus-visible { + outline: 2px solid #61C2C8; + outline-offset: 2px; + border-radius: 2px; +} + +/* Slide-down animation */ +@keyframes faq-slide-down { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ── CTA Section ── */ +.faq-cta { + text-align: center; + margin-top: 3.5rem; + padding: 2.5rem 1.5rem; + background: linear-gradient(135deg, #f8fafc 0%, #f1f5f9 100%); + border: 1px solid #e2e8f0; + border-radius: 1rem; +} + +.faq-cta h2 { + font-size: 1.125rem; + font-weight: 600; + color: #1e293b; + margin-bottom: 0.5rem; +} + +.faq-cta p { + color: #64748b; + font-size: 0.9375rem; + margin-bottom: 1.5rem; + line-height: 1.6; +} + +.faq-cta-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.75rem 1.75rem; + font-size: 0.9375rem; + font-weight: 600; + border-radius: 9999px; + text-decoration: none; + transition: all 0.2s ease; + min-height: 44px; +} + +.faq-cta-button--primary { + background: #fb923c; /* orange-400 — brand primary */ + color: #0F1E1F; /* teal-cyan-aqua-900 — high contrast on orange */ + box-shadow: 0 8px 20px -10px rgba(251, 146, 60, 0.45); +} + +.faq-cta-button--primary:hover { + background: #f97316; /* orange-500 */ + box-shadow: 0 12px 28px -10px rgba(249, 115, 22, 0.55); +} + +.faq-cta-button--primary:active { + transform: scale(0.97); + box-shadow: 0 4px 12px -8px rgba(249, 115, 22, 0.4); +} + +.faq-cta-button--primary:focus-visible { + outline: 2px solid #61C2C8; + outline-offset: 2px; +} + +.faq-cta-button--secondary { + background-color: transparent; + color: #475569; + border: 1.5px solid #cbd5e1; +} + +.faq-cta-button--secondary:hover { + background-color: #f8fafc; + border-color: #94a3b8; + color: #334155; +} + +.faq-cta-button--secondary:focus-visible { + outline: 2px solid #61C2C8; + outline-offset: 2px; +} + +@media (max-width: 640px) { + .faq-cta { + padding: 2rem 1.25rem; + margin-top: 2.5rem; + } + + .faq-cta-button { + width: 100%; + max-width: 280px; + } +} diff --git a/Features/Legal/Components/PrivacyPolicy.razor b/Features/Legal/Components/PrivacyPolicy.razor new file mode 100644 index 0000000..8999870 --- /dev/null +++ b/Features/Legal/Components/PrivacyPolicy.razor @@ -0,0 +1,264 @@ +@page "/privacy" + +Privacy Policy — CloudZen + + + + +
+
+ + @* ── Header ── *@ +
+

Privacy Policy

+
+

+ Last updated: April 6, 2026 +

+
+ + @* ── Table of Contents ── *@ + + + @* ── Introduction ── *@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+ We encourage you to review the privacy policies of these services. We select providers that align with + reasonable data protection standards. +

+ + @* ── 4. Cookies & Local Storage ── *@ + +

+ Our website uses minimal cookies and browser storage, limited to what is technically necessary: +

+ +

+ We do not use advertising cookies, social media tracking pixels, or third-party analytics + cookies. +

+ + @* ── 5. Data Retention ── *@ + +

We retain your information only as long as necessary for the purposes described:

+ + + @* ── 6. Your Rights ── *@ + +

+ Depending on your jurisdiction, you may have the following rights regarding your personal information: +

+ +

+ To exercise any of these rights, contact us at + cloudzen.inc@gmail.com. + We will respond to verified requests within 30 days. +

+ + @* ── 7. Children's Privacy ── *@ + +

+ Our website and services are not directed at individuals under the age of 16. We do not knowingly collect + personal information from children. If you believe a child has provided us with personal data, please contact + us and we will promptly delete it. +

+ + @* ── 8. Security ── *@ + +

+ We implement reasonable administrative, technical, and physical safeguards to protect your information, + including: +

+ +

+ No method of transmission or storage is 100% secure. While we strive to protect your data, we cannot + guarantee absolute security. +

+ + @* ── 9. Changes to This Policy ── *@ + +

+ We may update this Privacy Policy from time to time. When we do, we will revise the "Last updated" date at the + top of this page. We encourage you to review this policy periodically. Continued use of the website after + changes constitutes acceptance of the updated policy. +

+ + @* ── 10. Contact Us ── *@ + +

+ If you have questions or concerns about this Privacy Policy or your personal data, contact us at: +

+ + + + diff --git a/Features/Legal/Components/PrivacyPolicy.razor.cs b/Features/Legal/Components/PrivacyPolicy.razor.cs new file mode 100644 index 0000000..ffbc883 --- /dev/null +++ b/Features/Legal/Components/PrivacyPolicy.razor.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace CloudZen.Features.Legal.Components; + +public sealed partial class PrivacyPolicy : ComponentBase +{ + [Inject] private IJSRuntime JS { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("initScrollReveal"); + } + } +} diff --git a/Features/Legal/Components/PrivacyPolicy.razor.css b/Features/Legal/Components/PrivacyPolicy.razor.css new file mode 100644 index 0000000..d78c46d --- /dev/null +++ b/Features/Legal/Components/PrivacyPolicy.razor.css @@ -0,0 +1,112 @@ +/* ── Legal page shared styles ── */ + +/* Section headings — offset for fixed header */ +.legal-heading { + font-size: 1.25rem; + font-weight: 700; + color: #111827; + margin-top: 2.5rem; + margin-bottom: 1rem; + padding-top: 0.5rem; + scroll-margin-top: 7rem; +} + +.legal-subheading { + font-size: 1rem; + font-weight: 600; + color: #374151; + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} + +/* Body prose */ +.legal-body { + color: #374151; + font-size: 0.9375rem; + line-height: 1.75; +} + +.legal-body p { + margin-bottom: 1rem; +} + +/* Lists */ +.legal-list { + list-style: disc; + padding-left: 1.5rem; + margin-bottom: 1rem; +} + +.legal-list li { + margin-bottom: 0.5rem; +} + +/* Links */ +.legal-link { + color: #61C2C8; + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.2s ease; +} + +.legal-link:hover { + color: #89D6DC; +} + +/* TOC */ +.legal-toc { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 0.75rem; + padding: 1.25rem 1.5rem; + margin-bottom: 2.5rem; +} + +.legal-toc-link { + color: #6b7280; + text-decoration: none; + transition: color 0.2s ease; +} + +.legal-toc-link:hover { + color: #61C2C8; +} + +/* Table */ +.legal-table { + width: 100%; + border-collapse: collapse; + font-size: 0.875rem; +} + +.legal-table th { + text-align: left; + font-weight: 600; + color: #374151; + padding: 0.75rem 1rem; + border-bottom: 2px solid #e5e7eb; + background: #f9fafb; +} + +.legal-table td { + padding: 0.75rem 1rem; + border-bottom: 1px solid #f3f4f6; + color: #4b5563; + vertical-align: top; +} + +.legal-table tr:last-child td { + border-bottom: none; +} + +/* Address block */ +.legal-address { + font-style: normal; + background: #f9fafb; + border-left: 3px solid #61C2C8; + padding: 1rem 1.25rem; + border-radius: 0 0.5rem 0.5rem 0; + margin-top: 1rem; + font-size: 0.875rem; + line-height: 1.75; +} diff --git a/Features/Legal/Components/TermsOfService.razor b/Features/Legal/Components/TermsOfService.razor new file mode 100644 index 0000000..a7f8b2f --- /dev/null +++ b/Features/Legal/Components/TermsOfService.razor @@ -0,0 +1,223 @@ +@page "/terms" + +Terms of Service — CloudZen + + + + +
+
+ + @* ── Header ── *@ +
+

Terms of Service

+
+

+ Last updated: April 6, 2026 +

+
+ + @* ── Table of Contents ── *@ + + + @* ── Body ── *@ + +
+
diff --git a/Features/Legal/Components/TermsOfService.razor.cs b/Features/Legal/Components/TermsOfService.razor.cs new file mode 100644 index 0000000..a05c851 --- /dev/null +++ b/Features/Legal/Components/TermsOfService.razor.cs @@ -0,0 +1,17 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace CloudZen.Features.Legal.Components; + +public sealed partial class TermsOfService : ComponentBase +{ + [Inject] private IJSRuntime JS { get; set; } = default!; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("initScrollReveal"); + } + } +} diff --git a/Features/Legal/Components/TermsOfService.razor.css b/Features/Legal/Components/TermsOfService.razor.css new file mode 100644 index 0000000..bf420b1 --- /dev/null +++ b/Features/Legal/Components/TermsOfService.razor.css @@ -0,0 +1,79 @@ +/* ── Legal page shared styles ── */ + +.legal-heading { + font-size: 1.25rem; + font-weight: 700; + color: #111827; + margin-top: 2.5rem; + margin-bottom: 1rem; + padding-top: 0.5rem; + scroll-margin-top: 7rem; +} + +.legal-subheading { + font-size: 1rem; + font-weight: 600; + color: #374151; + margin-top: 1.5rem; + margin-bottom: 0.5rem; +} + +.legal-body { + color: #374151; + font-size: 0.9375rem; + line-height: 1.75; +} + +.legal-body p { + margin-bottom: 1rem; +} + +.legal-list { + list-style: disc; + padding-left: 1.5rem; + margin-bottom: 1rem; +} + +.legal-list li { + margin-bottom: 0.5rem; +} + +.legal-link { + color: #61C2C8; + text-decoration: underline; + text-underline-offset: 2px; + transition: color 0.2s ease; +} + +.legal-link:hover { + color: #89D6DC; +} + +.legal-toc { + background: #f9fafb; + border: 1px solid #e5e7eb; + border-radius: 0.75rem; + padding: 1.25rem 1.5rem; + margin-bottom: 2.5rem; +} + +.legal-toc-link { + color: #6b7280; + text-decoration: none; + transition: color 0.2s ease; +} + +.legal-toc-link:hover { + color: #61C2C8; +} + +.legal-address { + font-style: normal; + background: #f9fafb; + border-left: 3px solid #61C2C8; + padding: 1rem 1.25rem; + border-radius: 0 0.5rem 0.5rem 0; + margin-top: 1rem; + font-size: 0.875rem; + line-height: 1.75; +} diff --git a/Features/Profile/Components/ProfileApproach.razor b/Features/Profile/Components/ProfileApproach.razor new file mode 100644 index 0000000..1612b74 --- /dev/null +++ b/Features/Profile/Components/ProfileApproach.razor @@ -0,0 +1,26 @@ +@* Profile Approach Component - Displays professional approach and methodology *@ + +
+

My Approach

+
+ +

+ I specialize in delivering measurable results — not just code. + My focus is on modernizing legacy systems, implementing DevOps practices, and unlocking insights through reporting automation and AI-driven solutions. +

+ +

+ Clients value working with me because I combine deep technical expertise with + a business-first mindset: I look for outcomes that reduce costs, improve efficiency, + and create long-term scalability. +

+ +

+ While I operate independently to deliver focused results, I strategically expand CloudZen's capabilities by assembling trusted professionals when projects require broader expertise. This ensures clients benefit from both agility and comprehensive solutions tailored to complex business needs. +

+
+ +@code { + // This component currently displays static content. + // In the future, this could accept parameters for dynamic content if needed. +} diff --git a/Shared/Profile/ProfileHeader.razor b/Features/Profile/Components/ProfileHeader.razor similarity index 62% rename from Shared/Profile/ProfileHeader.razor rename to Features/Profile/Components/ProfileHeader.razor index 68c108d..fdb2bea 100644 --- a/Shared/Profile/ProfileHeader.razor +++ b/Features/Profile/Components/ProfileHeader.razor @@ -1,32 +1,38 @@ @* Profile Header Component - Displays avatar, name, intro, and social links *@ -
- +
+
@AltText + loading="lazy" + width="160" + height="160" + class="w-36 h-36 md:w-40 md:h-40 rounded-full object-cover ring-1 ring-gray-200 outline outline-[6px] outline-white shadow-[0_8px_24px_-12px_rgba(64,103,107,0.25)]" />
-

@Title

+

@Title

+
- -

- @NameHighlight - @RoleDescription -

-

- @DetailedDescription -

+ +
+

+ @NameHighlight — @RoleDescription +

+

+ @DetailedDescription +

+
-
+
@if (!string.IsNullOrWhiteSpace(LinkedInUrl)) { @@ -36,7 +42,7 @@ diff --git a/Features/Profile/Components/ProfileHighlights.razor b/Features/Profile/Components/ProfileHighlights.razor new file mode 100644 index 0000000..1678cc6 --- /dev/null +++ b/Features/Profile/Components/ProfileHighlights.razor @@ -0,0 +1,75 @@ +@* Profile Highlights Component - Displays key results, expertise, and resume download *@ + +
+

Key Results Delivered

+
+ +
    +
  • + + Modernized WPBT Assessment Services for MDCPS by migrating from legacy ASP.NET Web Forms to .NET Core, accelerating assessment cycles by 50%. +
  • +
  • + + Engineered delta-based SSIS ETL pipeline for SAP Maintenance Data, reducing job runtimes by 70%. +
  • +
  • + + Architected cloud-native ETL solution migrating SSIS pipelines to Azure with event-driven microservices. +
  • +
  • + + Built production-ready microservices with Clean Architecture, JWT auth, CQRS, and RabbitMQ Pub/Sub. +
  • +
  • + + Developed AI-powered Smart Menu Optimizer increasing restaurant profit margins by 18% and reducing food waste by 22%. +
  • +
  • + + Established CI/CD pipelines with GitHub Actions and Docker across multiple projects. +
  • +
+ +

Core Expertise

+ +
+ .NET / C# + Azure OpenAI / Cognitive Services + Web APIs / REST + Azure Cloud Services + DevOps / CI/CD + Blazor + T-SQL & Databases + ETL / Data Integration + SSIS Pipeline Engineering + Power BI & Reporting + Software Architecture +
+ +
+ +
+
+ +@code { + /// + /// Event callback triggered when the download resume button is clicked. + /// Envet callback is specially used to allow parent component to handle the event. In this case, parent component will handle the actual download logic. + /// Parent component should handle the actual download logic. + /// + [Parameter] + public EventCallback OnResumeDownload { get; set; } + + private async Task HandleResumeDownload() + { + await OnResumeDownload.InvokeAsync(); + } +} diff --git a/Features/Profile/Components/SDLCProcess.razor b/Features/Profile/Components/SDLCProcess.razor new file mode 100644 index 0000000..4226609 --- /dev/null +++ b/Features/Profile/Components/SDLCProcess.razor @@ -0,0 +1,68 @@ +@* SDLCProcess.razor — Vertical timeline with scroll-triggered animations *@ +
+
+
+

How I Work

+
+

A transparent, proven process from first conversation to launch day.

+
+ + +
+ +
+ + +
+
+ 1 +
+
+
+
+ +

Planning & Strategy

+
+

We start by understanding your business goals and challenges. Together, we define what success looks like, identify potential risks, and create a clear roadmap that fits your timeline and budget.

+
+
+
+ + +
+
+ 2 +
+
+
+
+ +

Building & Testing

+
+

I build your solution using proven methods that catch issues early and deliver updates quickly. Everything is tested automatically, so you get reliable results without the wait — and you stay in the loop with regular progress updates.

+
+
+
+ + +
+
+ 3 +
+
+
+
+ +

Launch & Support

+
+

When it's time to go live, I ensure a smooth launch with no interruptions to your business. Your solution is monitored around the clock, and if anything needs adjusting, I can respond quickly to keep everything running smoothly.

+
+
+
+
+ +
+ From your vision to real results — every step is transparent and aligned with your business. +
+
+
diff --git a/Features/Profile/Components/SDLCProcess.razor.cs b/Features/Profile/Components/SDLCProcess.razor.cs new file mode 100644 index 0000000..fe96310 --- /dev/null +++ b/Features/Profile/Components/SDLCProcess.razor.cs @@ -0,0 +1,37 @@ +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace CloudZen.Features.Profile.Components; + +public sealed partial class SDLCProcess : ComponentBase, IAsyncDisposable +{ + [Inject] private IJSRuntime JS { get; set; } = default!; + + private IJSObjectReference? _module; + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + _module = await JS.InvokeAsync( + "import", "./js/timeline-observer.js"); + await _module.InvokeVoidAsync("initTimelineObserver"); + } + } + + public async ValueTask DisposeAsync() + { + if (_module is not null) + { + try + { + await _module.InvokeVoidAsync("destroyTimelineObserver"); + await _module.DisposeAsync(); + } + catch (JSDisconnectedException) + { + // Circuit already closed — safe to ignore + } + } + } +} diff --git a/Features/Profile/Components/SDLCProcess.razor.css b/Features/Profile/Components/SDLCProcess.razor.css new file mode 100644 index 0000000..3ae47e1 --- /dev/null +++ b/Features/Profile/Components/SDLCProcess.razor.css @@ -0,0 +1,106 @@ +/* ── Heading fade-up ── */ +.timeline-heading { + opacity: 0; + transform: translateY(20px); + transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1); +} + +.timeline-heading.animate-in { + opacity: 1; + transform: translateY(0); +} + +/* ── Stage entry ── */ +.timeline-stage { + opacity: 0; + transition: opacity 0.5s ease; + transition-delay: calc(var(--stage-delay, 0) * 250ms + 0.3s); +} + +.timeline-stage.animate-in { + opacity: 1; +} + +/* ── Card slide-up ── */ +.timeline-card { + opacity: 0; + transform: translateY(24px); + transition: opacity 0.6s ease, transform 0.6s cubic-bezier(0.22, 1, 0.36, 1); + transition-delay: calc(var(--stage-delay, 0) * 250ms + 0.4s); +} + +.timeline-stage.animate-in .timeline-card { + opacity: 1; + transform: translateY(0); +} + +/* ── Card hover micro-interaction ── */ +.timeline-card > div { + transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease; +} + +.timeline-card > div:hover { + transform: translateY(-3px); + box-shadow: 0 12px 32px -8px rgba(97, 194, 200, 0.18); + border-color: rgba(97, 194, 200, 0.3); +} + +/* + * Circle ring-burst — NO position override here! + * The circle already has Tailwind's `absolute` for layout; + * `absolute` also creates a containing block for ::after. + */ +.timeline-circle::after { + content: ''; + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid rgba(97, 194, 200, 0.5); + opacity: 0; + pointer-events: none; +} + +.timeline-stage.animate-in .timeline-circle::after { + animation: ring-burst 0.8s cubic-bezier(0.22, 1, 0.36, 1) forwards; + animation-delay: calc(var(--stage-delay, 0) * 250ms + 0.2s); +} + +@keyframes ring-burst { + 0% { + opacity: 0.8; + transform: scale(0.8); + } + 100% { + opacity: 0; + transform: scale(2); + } +} + +/* ── Bottom badge fade-up ── */ +.timeline-badge { + opacity: 0; + transform: translateY(12px); + transition: opacity 0.5s ease, transform 0.5s ease; + transition-delay: 1.2s; +} + +.timeline-badge.animate-in { + opacity: 1; + transform: translateY(0); +} + +/* ── Accessibility: respect reduced-motion preference ── */ +@media (prefers-reduced-motion: reduce) { + .timeline-heading, + .timeline-stage, + .timeline-card, + .timeline-badge { + opacity: 1 !important; + transform: none !important; + transition: none !important; + } + + .timeline-circle::after { + display: none; + } +} diff --git a/Features/Profile/Components/WhoIAm.razor b/Features/Profile/Components/WhoIAm.razor new file mode 100644 index 0000000..cbaac28 --- /dev/null +++ b/Features/Profile/Components/WhoIAm.razor @@ -0,0 +1,95 @@ +@page "/whoiam" + +Who I Am — Dariem C. Macias | CloudZen Software Engineer & Consultant + + + + + + +
+
+ +
+ + + + +
+
+ +
+ +
+ +
+ +
+
+ + +
+
+

Highlighted Projects

+
+

+ Real-world projects showcasing software engineering, cloud-native design, and applied AI — built from scratch, iterated fast, and delivered production-ready. +

+
+ + +
+ +
+ + + @if (FilteredProjects.Any()) + { +
+ @{ var projIdx = 0; } + @foreach (var project in PagedProjects) + { +
+ +
+ projIdx++; + } +
+ + + + } + else + { +
+ +

No projects match the selected filters.

+

Try adjusting or clearing your filters.

+
+ } +
+
+ + +
+
+

Like what you see?

+

Let's talk about how I can help your business grow.

+ + Get In Touch → + +
+
+
+ diff --git a/Features/Profile/Components/WhoIAm.razor.cs b/Features/Profile/Components/WhoIAm.razor.cs new file mode 100644 index 0000000..6624e07 --- /dev/null +++ b/Features/Profile/Components/WhoIAm.razor.cs @@ -0,0 +1,104 @@ +using CloudZen.Features.Profile.Models; +using CloudZen.Features.Profile.Services; +using CloudZen.Features.Projects.Services; +using CloudZen.Features.Projects.Models; +using Microsoft.AspNetCore.Components; +using Microsoft.JSInterop; + +namespace CloudZen.Features.Profile.Components; + +/// +/// Code-behind for WhoIAm.razor — orchestrates project data, filtering, +/// resume download, and scroll-to-section JS interop. +/// +public partial class WhoIAm +{ + [Inject] private ResumeService ResumeService { get; set; } = default!; + [Inject] private IProjectService ProjectService { get; set; } = default!; + [Inject] private IJSRuntime JS { get; set; } = default!; + [Inject] private NavigationManager NavigationManager { get; set; } = default!; + + private List Projects = new(); + private List FilteredProjects = new(); + + // ── Pagination State ───────────────────────────────────────────────── + private const int PageSize = 5; + private int _currentPage = 1; + + /// Current page slice of filtered projects. + private List PagedProjects => FilteredProjects + .Skip((_currentPage - 1) * PageSize) + .Take(PageSize) + .ToList(); + + protected override void OnInitialized() + { + Projects = ProjectService.GetAllProjects(); + FilteredProjects = Projects; + } + + /// + /// Handles filter changes from the ProjectFilter component. + /// Resets to page 1 whenever filters change. + /// + private void HandleFilterChange((string Status, string ProjectType) filters) + { + FilteredProjects = Projects + .Where(p => string.IsNullOrEmpty(filters.Status) || p.Status == filters.Status) + .Where(p => string.IsNullOrEmpty(filters.ProjectType) || MatchesProjectTypeFilter(p, filters.ProjectType)) + .ToList(); + + _currentPage = 1; + } + + private static bool MatchesProjectTypeFilter(ProjectInfo project, string filterValue) => filterValue switch + { + "Customer" => project.Category == ProjectCategory.CustomerWork, + "AI Automation" => project.Category == ProjectCategory.AiAutomation, + "Side Project" => project.Category == ProjectCategory.SideProject, + _ => project.ProjectType == filterValue + }; + + /// + /// Handles page navigation from the Pagination component. + /// Scrolls to the projects section for smooth UX. + /// + private async Task HandlePageChanged(int page) + { + _currentPage = page; + await JS.InvokeVoidAsync("scrollToElementById", "highlighted-projects"); + } + + /// + /// Downloads the resume using ResumeService and JS interop. + /// + private async Task DownloadResume() + { + // Validate that ResumeUrl is configured + if (string.IsNullOrWhiteSpace(ResumeService.ResumeBlobUrl)) + { + throw new InvalidOperationException("Resume URL is not configured. Please ensure 'BlobStorage:ResumeUrl' is set in appsettings.json"); + } + + var resumeBytes = await ResumeService.DownloadResumeAsync(); + var uri = new Uri(ResumeService.ResumeBlobUrl); + var fileName = System.IO.Path.GetFileName(uri.LocalPath); + await JS.InvokeVoidAsync("saveAsFile", fileName, resumeBytes); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("initScrollReveal"); + + var uri = new Uri(NavigationManager.Uri); + var query = System.Web.HttpUtility.ParseQueryString(uri.Query); + var scrollTarget = query["scroll"]; + if (scrollTarget == "highlighted-projects") + { + await JS.InvokeVoidAsync("scrollToElementById", "highlighted-projects"); + } + } + } +} diff --git a/Models/SDLCStage.cs b/Features/Profile/Models/SDLCStage.cs similarity index 80% rename from Models/SDLCStage.cs rename to Features/Profile/Models/SDLCStage.cs index 1e5baa7..5a84d75 100644 --- a/Models/SDLCStage.cs +++ b/Features/Profile/Models/SDLCStage.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models; +namespace CloudZen.Features.Profile.Models; /// /// Represents the stages of the Software Development Life Cycle (SDLC) process. diff --git a/Services/ResumeService.cs b/Features/Profile/Services/ResumeService.cs similarity index 91% rename from Services/ResumeService.cs rename to Features/Profile/Services/ResumeService.cs index 7f1b426..654a1a2 100644 --- a/Services/ResumeService.cs +++ b/Features/Profile/Services/ResumeService.cs @@ -1,8 +1,8 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; -using CloudZen.Models.Options; +using CloudZen.Common.Options; -namespace CloudZen.Services +namespace CloudZen.Features.Profile.Services { public class ResumeService { diff --git a/Features/Projects/Components/ProjectCard.razor b/Features/Projects/Components/ProjectCard.razor new file mode 100644 index 0000000..5a13837 --- /dev/null +++ b/Features/Projects/Components/ProjectCard.razor @@ -0,0 +1,167 @@ + +
+ + + + +
+

@Project.Name

+ + @Project.Status + +
+ + + @if (!string.IsNullOrWhiteSpace(Project.Role) || !string.IsNullOrWhiteSpace(Project.ProjectType)) + { +
+ @if (!string.IsNullOrWhiteSpace(Project.Role)) + { + + + @Project.Role + + } + @if (!string.IsNullOrWhiteSpace(Project.ProjectType)) + { + + + @Project.ProjectType + + } +
+ } + + + @if (Project.Participants != null && Project.Participants.Any()) + { +
+
+ @foreach (var participant in Project.Participants) + { + @participant.Name + } +
+ + @string.Join(", ", Project.Participants.Select(p => p.Name)) + +
+ } + + +

@Project.Description

+ + + @if (Project.TechStack != null && Project.TechStack.Any()) + { +
+ @foreach (var tech in Project.TechStack) + { + @tech + } +
+ } + + + @if ((Project.Challenges != null && Project.Challenges.Count > 0) || (Project.Results != null && Project.Results.Count > 0)) + { +
+ @if (Project.Challenges != null && Project.Challenges.Count > 0) + { +
+

Challenges

+
    + @foreach (var challenge in Project.Challenges) + { +
  • + + @challenge +
  • + } +
+
+ } + @if (Project.Results != null && Project.Results.Count > 0) + { +
+

Outcomes

+
    + @foreach (var result in Project.Results) + { +
  • + + @result +
  • + } +
+
+ } +
+ } + + +
+
+
+ Progress + @Project.Progress% +
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(Project.GithubUrl)) + { + + + View on GitHub + + } +
+
+ +@code { + /// + /// The project information to display in the card. EditorRequired ensures this parameter must be set. + /// + [Parameter, EditorRequired] + public ProjectInfo Project { get; set; } = default!; + + private string GetStatusColor(string status) => status switch + { + "Completed" => "bg-teal-cyan-aqua-50 text-teal-cyan-aqua-700 border-teal-cyan-aqua-200", + "In Progress" => "bg-orange-50 text-orange-700 border-orange-200", + "Planning" => "bg-gray-50 text-gray-600 border-gray-200", + _ => "bg-gray-50 text-gray-600 border-gray-200" + }; + + private string GetProgressColor(int progress) => progress switch + { + >= 100 => "bg-teal-cyan-aqua-600", + >= 70 => "bg-teal-cyan-aqua-500", + >= 40 => "bg-orange-400", + _ => "bg-gray-400" + }; + + private string GetProjectTypeIcon(string projectType) => projectType switch + { + "Side Project" => "bi-rocket-takeoff", + var t when t.StartsWith("Customer:") => "bi-briefcase", + _ => "bi-folder" + }; +} diff --git a/Features/Projects/Components/ProjectFilter.razor b/Features/Projects/Components/ProjectFilter.razor new file mode 100644 index 0000000..dcd501d --- /dev/null +++ b/Features/Projects/Components/ProjectFilter.razor @@ -0,0 +1,148 @@ +@* Project Filter Component - Modern UI/UX with enhanced visual design *@ + +@* Restrained brand-aligned project filter: teal palette, system controls, no gradients/double shadows. *@ + +
+ +
+
+ +

Filter projects

+
+ @if (!string.IsNullOrEmpty(SelectedStatus) || !string.IsNullOrEmpty(SelectedProjectType)) + { + + } +
+ +
+ +
+ +
+ + +
+
+ + +
+ +
+ + +
+
+
+ + @* Active filter chips — minimal, dismissible *@ + @if (!string.IsNullOrEmpty(SelectedStatus) || !string.IsNullOrEmpty(SelectedProjectType)) + { +
+ Active + @if (!string.IsNullOrEmpty(SelectedStatus)) + { + + Status: @SelectedStatus + + + } + @if (!string.IsNullOrEmpty(SelectedProjectType)) + { + + Type: @SelectedProjectType + + + } +
+ } +
+ +@code { + /// + /// Currently selected status filter value. + /// + private string SelectedStatus { get; set; } = string.Empty; + + /// + /// Currently selected project type filter value. + /// + private string SelectedProjectType { get; set; } = string.Empty; + + /// + /// Event callback that fires when filter values change. + /// Passes the selected status and project type back to the parent component. + /// + [Parameter] + public EventCallback<(string Status, string ProjectType)> OnFilterChange { get; set; } + + /// + /// Invokes the filter change callback with current filter values. + /// + private async Task OnFilterChanged() + { + await OnFilterChange.InvokeAsync((SelectedStatus, SelectedProjectType)); + } + + /// + /// Clears all active filters and triggers filter change event. + /// + private async Task ClearFilters() + { + SelectedStatus = string.Empty; + SelectedProjectType = string.Empty; + await OnFilterChanged(); + } + + /// + /// Clears the status filter and triggers filter change event. + /// + private async Task ClearStatusFilter() + { + SelectedStatus = string.Empty; + await OnFilterChanged(); + } + + /// + /// Clears the project type filter and triggers filter change event. + /// + private async Task ClearProjectTypeFilter() + { + SelectedProjectType = string.Empty; + await OnFilterChanged(); + } +} diff --git a/Features/Projects/Models/AiAutomationDetails.cs b/Features/Projects/Models/AiAutomationDetails.cs new file mode 100644 index 0000000..6052262 --- /dev/null +++ b/Features/Projects/Models/AiAutomationDetails.cs @@ -0,0 +1,17 @@ +namespace CloudZen.Features.Projects.Models; + +/// +/// Holds AI-automation-specific metadata for projects in the category. +/// Composed into as an optional property. +/// +public record AiAutomationDetails +{ + /// Who is this workflow or feature designed for? + public required string TargetAudience { get; init; } + + /// What problem does this workflow or feature solve? + public required string ProblemSolved { get; init; } + + /// Key benefits the customer gains from adopting this solution. + public required List CustomerBenefits { get; init; } +} diff --git a/Features/Projects/Models/ProjectCategory.cs b/Features/Projects/Models/ProjectCategory.cs new file mode 100644 index 0000000..ad596be --- /dev/null +++ b/Features/Projects/Models/ProjectCategory.cs @@ -0,0 +1,12 @@ +namespace CloudZen.Features.Projects.Models; + +/// +/// Categorizes projects by their business context. +/// Used for filtering and display grouping across the portfolio. +/// +public enum ProjectCategory +{ + SideProject, + CustomerWork, + AiAutomation +} diff --git a/Models/ProjectInfo.cs b/Features/Projects/Models/ProjectInfo.cs similarity index 79% rename from Models/ProjectInfo.cs rename to Features/Projects/Models/ProjectInfo.cs index 024c187..937413d 100644 --- a/Models/ProjectInfo.cs +++ b/Features/Projects/Models/ProjectInfo.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models; +namespace CloudZen.Features.Projects.Models; /// /// Represents a project showcased in the portfolio. @@ -59,4 +59,14 @@ public class ProjectInfo /// Type of project: "Side Project", "Client Work", or "Customer: {Name}". /// public string ProjectType { get; set; } = string.Empty; + + /// + /// The business category this project belongs to (type-safe replacement for filtering). + /// + public ProjectCategory Category { get; set; } = ProjectCategory.SideProject; + + /// + /// AI-automation-specific metadata. Populated only for projects. + /// + public AiAutomationDetails? AutomationDetails { get; set; } } diff --git a/Models/ProjectParticipant.cs b/Features/Projects/Models/ProjectParticipant.cs similarity index 89% rename from Models/ProjectParticipant.cs rename to Features/Projects/Models/ProjectParticipant.cs index 5806ddf..492d02a 100644 --- a/Models/ProjectParticipant.cs +++ b/Features/Projects/Models/ProjectParticipant.cs @@ -1,4 +1,4 @@ -namespace CloudZen.Models; +namespace CloudZen.Features.Projects.Models; /// /// Represents a participant/contributor in a project. diff --git a/Features/Projects/Services/IProjectService.cs b/Features/Projects/Services/IProjectService.cs new file mode 100644 index 0000000..42827df --- /dev/null +++ b/Features/Projects/Services/IProjectService.cs @@ -0,0 +1,14 @@ +using CloudZen.Features.Projects.Models; + +namespace CloudZen.Features.Projects.Services; + +/// +/// Interface for retrieving project portfolio data. +/// +public interface IProjectService +{ + List GetAllProjects(); + List GetProjectsByStatus(string status); + List GetProjectsByType(string projectType); + List GetProjectsByCategory(ProjectCategory category); +} diff --git a/Services/ProjectService.cs b/Features/Projects/Services/ProjectService.cs similarity index 71% rename from Services/ProjectService.cs rename to Features/Projects/Services/ProjectService.cs index 48511c6..edd9f52 100644 --- a/Services/ProjectService.cs +++ b/Features/Projects/Services/ProjectService.cs @@ -1,12 +1,12 @@ -using CloudZen.Models; +using CloudZen.Features.Projects.Models; -namespace CloudZen.Services; +namespace CloudZen.Features.Projects.Services; /// /// Service for managing and retrieving project portfolio data. /// This service centralizes project data management and can be extended to load from external sources (API, database, JSON files, etc.). /// -public class ProjectService +public class ProjectService : IProjectService { /// /// Retrieves all projects in the portfolio, sorted by status (Completed, In Progress, Planning). @@ -41,6 +41,20 @@ public List GetProjectsByType(string projectType) return GetProjectsData().Where(p => p.ProjectType == projectType).ToList(); } + /// + /// Retrieves projects filtered by . + /// + /// The category to filter by. + /// A list of projects in the specified category, sorted by status. + public List GetProjectsByCategory(ProjectCategory category) + { + var statusOrder = new List { "Completed", "In Progress", "Planning" }; + return GetProjectsData() + .Where(p => p.Category == category) + .OrderBy(p => statusOrder.IndexOf(p.Status)) + .ToList(); + } + /// /// Gets featured/highlighted projects (typically completed projects with high impact). /// @@ -198,7 +212,8 @@ private List GetProjectsData() "Architecting for deployment on Azure App Services." }, GithubUrl = "https://github.com/dariemcarlosdev/CleanArchitecture.ApiTemplate", - ProjectType = "Side Project" + ProjectType = "Side Project", + Category = ProjectCategory.SideProject }, new ProjectInfo { @@ -234,7 +249,8 @@ private List GetProjectsData() "Applying Clean Architecture principles for maintainability." }, GithubUrl = "https://github.com/dariemcarlosdev/OrderProcessing-RabbitMQ-Microservices", - ProjectType = "Side Project" + ProjectType = "Side Project", + Category = ProjectCategory.SideProject }, new ProjectInfo { @@ -263,7 +279,8 @@ private List GetProjectsData() "Ensuring data integrity and traceability during platform transition.", "Redesigning UI/UX for modern accessibility and scalability." }, - ProjectType = "Customer: MDCPS" + ProjectType = "Customer: MDCPS", + Category = ProjectCategory.CustomerWork }, new ProjectInfo { @@ -291,7 +308,8 @@ private List GetProjectsData() "Optimizing ETL for large-scale, high-volume data loads.", "Ensuring audit compliance and traceability in ETL workflows." }, - ProjectType = "Customer: MDCPS" + ProjectType = "Customer: MDCPS", + Category = ProjectCategory.CustomerWork }, new ProjectInfo { @@ -336,7 +354,8 @@ private List GetProjectsData() "Automating data ingestion and dashboard reporting." }, GithubUrl = "https://github.com/dariemcarlosdev/SmartMenuOptim", - ProjectType = "Side Project" + ProjectType = "Side Project", + Category = ProjectCategory.SideProject }, new ProjectInfo { @@ -383,7 +402,8 @@ private List GetProjectsData() "Ensuring security and scalability for sensitive data processing." }, GithubUrl = "https://github.com/dariemcarlosdev/VPKFILEPROCESSORAPP", - ProjectType = "Customer: MDCPS" + ProjectType = "Customer: MDCPS", + Category = ProjectCategory.CustomerWork }, new ProjectInfo { @@ -424,7 +444,8 @@ private List GetProjectsData() "Automating campaign management and analytics reporting." }, GithubUrl = "https://github.com/dariemcarlosdev/DineJoyApp", - ProjectType = "Side Project" + ProjectType = "Side Project", + Category = ProjectCategory.SideProject }, new ProjectInfo { @@ -455,8 +476,169 @@ private List GetProjectsData() "Designing a responsive and user-friendly UI." }, GithubUrl = "https://github.com/dariemcarlosdev/BlazorTicketmasterApiIntegration", - ProjectType = "Side Project" + ProjectType = "Side Project", + Category = ProjectCategory.SideProject + }, + new ProjectInfo + { + Name = "AI Chatbot Assistance - Intelligent Customer Support", + Status = "Completed", + Description = "A smart chatbot that lives on your website and answers customer questions instantly — day or night. It handles the repetitive stuff so your team can focus on what matters, and hands off tricky conversations to a real person when needed.", + TechStack = new[] { + "Blazor WebAssembly", + "Azure Functions (Isolated Worker)", + "Anthropic Claude API", + "MailKit / Brevo SMTP", + ".NET 8.0 SDK", + "C#", + "Azure Static Web Apps", + "Azure Key Vault", + "Polly Rate Limiting", + "Tailwind CSS v4" + }, + Progress = 100, + Results = new List + { + "Instant AI-generated responses to customer inquiries, eliminating wait times.", + "Reduced support ticket volume by handling common questions automatically.", + "24/7 availability without scaling headcount or support shifts.", + "Secure backend architecture with API keys stored in Azure Key Vault.", + "Per-client rate limiting to prevent abuse and ensure fair usage." + }, + Participants = new[] + { + new ProjectParticipant { Name = "Dariem C. Macias", ImageUrl = "/images/dariem-avatar.png" } + }, + Role = "Principal Consultant / AI Solution Architect", + Challenges = new List + { + "Integrating Anthropic Claude API with server-side proxy to protect secrets.", + "Designing conversational UX for non-technical end users.", + "Implementing rate limiting and input validation to prevent misuse." + }, + ProjectType = "AI Automation", + Category = ProjectCategory.AiAutomation, + AutomationDetails = new AiAutomationDetails + { + TargetAudience = "Small-to-medium businesses needing 24/7 customer support without scaling headcount.", + ProblemSolved = "Customers wait too long for answers and support teams are overwhelmed with repetitive questions, causing churn and lost revenue.", + CustomerBenefits = new List + { + "Instant response times that keep customers engaged.", + "Reduced support costs by automating repetitive inquiries.", + "Consistent brand voice across every interaction.", + "Available around the clock without overtime or extra hires.", + "Seamless escalation to human agents for complex issues." + } + } + }, + new ProjectInfo + { + Name = "Booking Appointments - Automated Scheduling System", + Status = "Completed", + Description = "An online booking system that lets your clients pick a time, confirm their appointment, and get it added to your calendar — all without a single phone call or email. Reschedules and cancellations are handled automatically too.", + TechStack = new[] { + "Blazor WebAssembly", + "Azure Functions (Isolated Worker)", + "n8n Workflows", + "Google Calendar API", + ".NET 8.0 SDK", + "C#", + "Azure Static Web Apps", + "Azure Key Vault", + "Tailwind CSS v4" + }, + Progress = 100, + Results = new List + { + "Zero manual scheduling effort — clients book directly from the website.", + "Automated email confirmations and calendar invites on every booking.", + "Real-time availability prevents double-bookings entirely.", + "Self-service rescheduling and cancellation reduce admin overhead.", + "Seamless Google Calendar sync keeps schedules up to date." + }, + Participants = new[] + { + new ProjectParticipant { Name = "Dariem C. Macias", ImageUrl = "/images/dariem-avatar.png" } + }, + Role = "Principal Consultant / Automation Architect", + Challenges = new List + { + "Orchestrating n8n workflows with Azure Functions for reliable appointment processing.", + "Building a responsive calendar UI with timezone-aware slot selection.", + "Ensuring conflict-free scheduling with real-time availability checks." + }, + ProjectType = "AI Automation", + Category = ProjectCategory.AiAutomation, + AutomationDetails = new AiAutomationDetails + { + TargetAudience = "Service-based businesses (consultants, agencies, freelancers) that lose leads to slow or manual booking processes.", + ProblemSolved = "Manual back-and-forth scheduling wastes time, creates friction for potential clients, and leads to missed appointments and lost revenue.", + CustomerBenefits = new List + { + "Self-service scheduling that converts visitors into booked appointments.", + "Automated confirmations and reminders reduce no-shows.", + "Zero double-bookings with real-time calendar sync.", + "Clients can reschedule or cancel without calling or emailing.", + "Professional booking experience that builds trust and credibility." + } + } + }, + new ProjectInfo + { + Name = "Custom Customer-Facing Web Application", + Status = "Completed", + Description = "A fully custom website designed to make your business look great and work hard for you. It greets visitors with an AI chatbot, lets them book appointments on the spot, and captures every lead through a secure contact form — all running on its own with no maintenance needed from you.", + TechStack = new[] { + "Blazor WebAssembly", + "Azure Static Web Apps", + "Azure Functions (Isolated Worker)", + "Anthropic Claude API", + "n8n Workflows", + "Google Calendar API", + "MailKit / Brevo SMTP", + ".NET 8.0 SDK", + "C#", + "Tailwind CSS v4", + "Azure Key Vault", + "Polly Rate Limiting" + }, + Progress = 100, + Results = new List + { + "Delivered a professional, mobile-responsive web presence with modern UI.", + "Integrated AI chatbot for instant visitor engagement and lead qualification.", + "Automated appointment booking eliminates manual scheduling overhead.", + "Secure contact form with SMTP delivery and input validation.", + "Fully serverless architecture with zero infrastructure management." + }, + Participants = new[] + { + new ProjectParticipant { Name = "Dariem C. Macias", ImageUrl = "/images/dariem-avatar.png" } + }, + Role = "Principal Consultant / Full-Stack Architect", + Challenges = new List + { + "Unifying chatbot, booking, and contact features into a cohesive single-page experience.", + "Ensuring fast load times with Blazor WASM while keeping rich interactivity.", + "Securing API keys and secrets with Azure Key Vault across multiple integrations." + }, + ProjectType = "AI Automation", + Category = ProjectCategory.AiAutomation, + AutomationDetails = new AiAutomationDetails + { + TargetAudience = "Small businesses and professionals who need a polished online presence with built-in automation to convert visitors into clients.", + ProblemSolved = "Generic website templates lack intelligent engagement — visitors leave without taking action because there is no instant support, easy booking, or personalized experience.", + CustomerBenefits = new List + { + "A branded, professional web app that makes a strong first impression.", + "AI-powered chatbot engages visitors instantly and answers questions 24/7.", + "Built-in appointment scheduling turns interest into booked meetings.", + "Secure contact form ensures no lead is lost.", + "Fully managed cloud hosting with no servers to maintain." + } + } } }; } -} +} \ No newline at end of file diff --git a/Features/Tickets/Components/Tickets.razor b/Features/Tickets/Components/Tickets.razor new file mode 100644 index 0000000..2b841e0 --- /dev/null +++ b/Features/Tickets/Components/Tickets.razor @@ -0,0 +1,49 @@ +@page "/tickets" + +@inject ITicketService TicketService +@inject IJSRuntime JS + +
+

Ticket incidents overview

+
+
+
+ +
+
+ +
+
+ +
+
+
+ +@code { + private int totalCount; + private int openCount; + private int closedCount; + private bool isLoading; + + protected override async Task OnInitializedAsync() + { + isLoading = true; + var tickets = await TicketService.GetAllTicketsAsync() ?? new List(); + // totalCount = tickets.Count; + totalCount = 30; + // openCount = tickets.Count(t => t.IsOpen); + openCount = 14; + closedCount = totalCount - openCount; + isLoading = false; + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await JS.InvokeVoidAsync("initScrollReveal"); + } + } +} diff --git a/Services/Abstractions/TicketDto.cs b/Features/Tickets/Models/TicketDto.cs similarity index 78% rename from Services/Abstractions/TicketDto.cs rename to Features/Tickets/Models/TicketDto.cs index 3338b8c..d238df1 100644 --- a/Services/Abstractions/TicketDto.cs +++ b/Features/Tickets/Models/TicketDto.cs @@ -1,6 +1,6 @@ -using Microsoft.VisualBasic; +using Microsoft.VisualBasic; -namespace CloudZen.Services.Abstractions +namespace CloudZen.Features.Tickets.Models { public class TicketDto { diff --git a/Services/Abstractions/ITicketService.cs b/Features/Tickets/Services/ITicketService.cs similarity index 52% rename from Services/Abstractions/ITicketService.cs rename to Features/Tickets/Services/ITicketService.cs index d5c06d5..63a9e32 100644 --- a/Services/Abstractions/ITicketService.cs +++ b/Features/Tickets/Services/ITicketService.cs @@ -1,7 +1,9 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; -namespace CloudZen.Services.Abstractions +using CloudZen.Features.Tickets.Models; + +namespace CloudZen.Features.Tickets.Services { public interface ITicketService { diff --git a/Services/TicketService.cs b/Features/Tickets/Services/TicketService.cs similarity index 93% rename from Services/TicketService.cs rename to Features/Tickets/Services/TicketService.cs index 09d972b..89d6608 100644 --- a/Services/TicketService.cs +++ b/Features/Tickets/Services/TicketService.cs @@ -1,10 +1,11 @@ -using System; +using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; -using CloudZen.Services.Abstractions; -namespace CloudZen.Services +using CloudZen.Features.Tickets.Models; + +namespace CloudZen.Features.Tickets.Services { public class TicketService : ITicketService { diff --git a/GEMINI.md.archived b/GEMINI.md.archived new file mode 100644 index 0000000..524ed3d --- /dev/null +++ b/GEMINI.md.archived @@ -0,0 +1,211 @@ +# GEMINI.md — Gemini-Specific AI Instructions + +> These instructions extend AGENTS.md with guidance optimized for Gemini's analysis and code generation capabilities. + +## Project Context + +Read **AGENTS.md** first for full project context. This file adds Gemini-specific guidance for: + +- Dependency graph analysis before changes +- Pattern matching against existing codebase conventions +- Efficient code search and cross-referencing +- Database and EF Core query generation + +--- + +## Exploration Strategy + +### Before Making Changes — Map Dependencies First + +When asked to modify any code, analyze the dependency graph before writing: + +1. **Trace inbound references.** What calls/imports the file being changed? +2. **Trace outbound references.** What does the file depend on? +3. **Identify the layer.** Presentation → Application → Domain ← Infrastructure. +4. **Check for pattern consistency.** How do similar files in the same directory handle this? +5. **Verify interface contracts.** If changing an interface, identify all implementations and consumers. + +**Example:** Before modifying `IOrderRepository`: +- Find all classes implementing it (e.g., `OrderRepository`) +- Find all consumers (e.g., `CreateOrderHandler`, `CompleteOrderHandler`, etc.) +- Verify the change doesn't break the Repository Pattern boundary +- Check if `AppDbContext` needs a corresponding migration + +### Cross-Referencing Checklist + +When exploring the codebase: + +| Question | Where to Look | +|---|---| +| How is DI wired? | `Program.cs` — service registration section | +| What strategies exist? | `Services/Strategies/` — `IPaymentProcessor` implementations | +| What MediatR slices exist? | `Features/{Domain}/` — each subdirectory is a vertical slice | +| What domain events exist? | `Events/` — `DomainEvent` subclasses | +| What's the DB schema? | `Models/` entities, `Data/AppDbContext.cs` | +| What API endpoints exist? | `Features/{Domain}/Api/` or `Api/` — controller classes | +| What localization keys exist? | `Resources/SharedResource.resx` and locale-specific `.resx` files | + +--- + +## Code Generation Guidelines + +### Match Existing Patterns + +Before generating code, find and match the project's established patterns: + +**MediatR Handler Pattern** (reference: any `Features/{Domain}/{Slice}/` directory): +``` +Command record → Handler class → injects repository + strategy factory + IEventBus +``` + +**Strategy Pattern** (reference: `Services/Strategies/`): +``` +IPaymentProcessor (marker) + capability interfaces (IChargeable, IRefundable, ICancellable) +``` + +**Blazor Component Pattern** (reference: any `Components/Pages/{Component}.*`): +``` +.razor — markup with @inject IStringLocalizer L +.razor.cs — sealed partial class with [Inject] properties +.razor.css — scoped styles using Bootstrap 5 +``` + +**Repository Pattern** (reference: `Data/Repositories/`): +``` +Interface in Data/Repositories/ → Implementation uses DbContext internally +``` + +### Code Style Rules + +- File-scoped namespaces: `namespace ProjectName.Features.Orders;` +- Nullable reference types enabled throughout +- `sealed` on concrete classes not designed for inheritance +- `record` types for commands, queries, and DTOs +- Async/await with `CancellationToken` propagation +- Guard clauses at method entry — fail fast +- No `var` for domain types — use explicit types for clarity + +--- + +## Database & EF Core Guidance + +### Before Writing Queries + +1. **Check existing repository methods.** Repository interfaces define available data operations (e.g., `GetByIdAsync`, `AddAsync`, `UpdateAsync`). +2. **Examine `AppDbContext`** for configured relationships, indexes, and conventions. +3. **Match existing query patterns.** Use `AsNoTracking()` for read-only queries. Use projections to avoid loading full entities. +4. **Check for existing migrations** in `Migrations/` before creating new ones. + +### Query Rules + +- Always use EF Core parameterized queries — never raw SQL string concatenation. +- Read queries: `AsNoTracking()` for performance. +- Writes: load entity → modify → `SaveChangesAsync()` inside the repository. +- New columns or tables: create a migration with `dotnet ef migrations add MigrationName`. +- Database-specific: check `Program.cs` for the configured provider (e.g., `UseNpgsql()`, `UseSqlServer()`). + +--- + +## Feature Modification Workflow + +When adding or modifying a feature: + +``` +1. Identify the vertical slice in Features/{Domain}/ +2. Check the corresponding doc in docs/ +3. Map dependencies (repository, strategy, events) +4. Make changes following existing patterns +5. Update the docs/ entry +6. Verify DI registration in Program.cs if new services are added +7. Add/update localization keys in Resources/ if UI text changes +``` + +--- + +## UI Component Analysis + +When working with Blazor components: + +1. **Inspect the component triad.** Always check all three files (`.razor`, `.razor.cs`, `.razor.css`). +2. **Check parent-child relationships.** Look at `[Parameter]` and `EventCallback` usage. +3. **Verify localization.** All user-facing strings should use `@L["Key"]` in markup or `L["Key"]` in code-behind. +4. **Check scoped CSS.** Styles must be in the `.razor.css` file — no global overrides for component-specific elements. +5. **Bootstrap 5 consistency.** Match existing component patterns for layout (containers, rows, cols) and utilities. + +### Component Inventory + +When onboarding to a project, catalog existing components: + +| What to Find | Where | +|---|---| +| Page components | `Components/Pages/` — routable components with `@page` | +| Layout components | `Layout/` — `MainLayout`, `NavMenu`, etc. | +| Shared components | `Components/Shared/` — reusable building blocks | +| Feature components | `Components/Features/` — domain-specific UI | + +--- + +## Business Rules — Quick Reference + +> **Define your domain-specific business rules per project.** These are non-negotiable invariants +> that every code change must respect. Verify compliance on every change. + +Example rules to verify: + +| Rule | Rationale | +|---|---| +| Domain events after persistence | Events must reflect committed state, not intent | +| Validate all input at boundaries | Prevents invalid state from entering the domain | +| Never log PII/tokens/secrets | Regulatory compliance (GDPR, etc.) | +| Idempotency on external calls | Prevents duplicate operations on retry | +| State machine transitions enforced | Aggregates reject invalid state changes | +| Authorization on every endpoint | Default deny — no anonymous business operations | + +--- + +## Documentation Maintenance + +When features change, update the corresponding doc in `docs/`: + +``` +docs/ +├── 00-Architecture-Overview ← cross-cutting changes +├── 01-Feature-Name ← feature-specific changes +├── 02-Feature-Name ← one doc per feature area +└── ... ← follow numbering convention +``` + +New features that don't fit existing docs: create the next numbered doc (e.g., `NN-Feature-Name`). + +--- + +## Program.cs Service Registration Reference + +Key DI registrations (keep in sync when adding services): + +```csharp +// Data Layer +services.AddDbContext(/* database provider */); +services.AddScoped(); + +// Event Bus +services.AddScoped(); + +// Strategies +services.AddScoped(); +services.AddScoped(); + +// MediatR (auto-discovers handlers) +services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblyContaining()); +``` + +When adding a new service or strategy, register it in `Program.cs` following this pattern. + +--- + +## Skills Catalog + +See **AGENTS.md → Skills Catalog** for the complete skill loading instructions, categories, +and usage examples. Skills are universal across all models. + +**Quick start:** `cat .github/skills/CATALOG.md` to browse all available skills. diff --git a/Layout/Footer.razor b/Layout/Footer.razor index a0542ae..8897fe8 100644 --- a/Layout/Footer.razor +++ b/Layout/Footer.razor @@ -1,17 +1,80 @@ -