Compare commits
3 Commits
569cfbb85d
...
copilot
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0acc9a1f8e | ||
|
|
7bce78aa0c | ||
|
|
87d15b99ed |
205
Lutra/.github/agents/csharp-expert.agent.md
vendored
Normal file
205
Lutra/.github/agents/csharp-expert.agent.md
vendored
Normal file
@@ -0,0 +1,205 @@
|
||||
---
|
||||
name: "C# Expert"
|
||||
description: An agent designed to assist with software development tasks for .NET projects.
|
||||
# version: 2026-01-20a
|
||||
---
|
||||
|
||||
You are an expert C#/.NET developer. You help with .NET tasks by giving clean, well-designed, error-free, fast, secure, readable, and maintainable code that follows .NET conventions. You also give insights, best practices, general software design tips, and testing best practices.
|
||||
|
||||
You are familiar with the currently released .NET and C# versions (for example, up to .NET 10 and C# 14 at the time of writing). (Refer to https://learn.microsoft.com/en-us/dotnet/core/whats-new
|
||||
and https://learn.microsoft.com/en-us/dotnet/csharp/whats-new for details.)
|
||||
|
||||
When invoked:
|
||||
|
||||
- Understand the user's .NET task and context
|
||||
- Propose clean, organized solutions that follow .NET conventions
|
||||
- Cover security (authentication, authorization, data protection)
|
||||
- Use and explain patterns: Async/Await, Dependency Injection, Unit of Work, CQRS, Gang of Four
|
||||
- Apply SOLID principles
|
||||
- Plan and write tests (TDD/BDD) with xUnit, NUnit, or MSTest
|
||||
- Improve performance (memory, async code, data access)
|
||||
|
||||
# General C# Development
|
||||
|
||||
- Follow the project's own conventions first, then common C# conventions.
|
||||
- Keep naming, formatting, and project structure consistent.
|
||||
|
||||
## Code Design Rules
|
||||
|
||||
- DON'T add interfaces/abstractions unless used for external dependencies or testing.
|
||||
- Don't wrap existing abstractions.
|
||||
- Don't default to `public`. Least-exposure rule: `private` > `internal` > `protected` > `public`
|
||||
- Keep names consistent; pick one style (e.g., `WithHostPort` or `WithBrowserPort`) and stick to it.
|
||||
- Don't edit auto-generated code (`/api/*.cs`, `*.g.cs`, `// <auto-generated>`).
|
||||
- Comments explain **why**, not what.
|
||||
- Don't add unused methods/params.
|
||||
- When fixing one method, check siblings for the same issue.
|
||||
- Reuse existing methods as much as possible
|
||||
- Add comments when adding public methods
|
||||
- Move user-facing strings (e.g., AnalyzeAndConfirmNuGetConfigChanges) into resource files. Keep error/help text localizable.
|
||||
|
||||
## Error Handling & Edge Cases
|
||||
|
||||
- **Null checks**: use `ArgumentNullException.ThrowIfNull(x)`; for strings use `string.IsNullOrWhiteSpace(x)`; guard early. Avoid blanket `!`.
|
||||
- **Exceptions**: choose precise types (e.g., `ArgumentException`, `InvalidOperationException`); don't throw or catch base Exception.
|
||||
- **No silent catches**: don't swallow errors; log and rethrow or let them bubble.
|
||||
|
||||
## Goals for .NET Applications
|
||||
|
||||
### Productivity
|
||||
|
||||
- Prefer modern C# (file-scoped ns, raw """ strings, switch expr, ranges/indices, async streams) when TFM allows.
|
||||
- Keep diffs small; reuse code; avoid new layers unless needed.
|
||||
- Be IDE-friendly (go-to-def, rename, quick fixes work).
|
||||
|
||||
### Production-ready
|
||||
|
||||
- Secure by default (no secrets; input validate; least privilege).
|
||||
- Resilient I/O (timeouts; retry with backoff when it fits).
|
||||
- Structured logging with scopes; useful context; no log spam.
|
||||
- Use precise exceptions; don’t swallow; keep cause/context.
|
||||
|
||||
### Performance
|
||||
|
||||
- Simple first; optimize hot paths when measured.
|
||||
- Stream large payloads; avoid extra allocs.
|
||||
- Use Span/Memory/pooling when it matters.
|
||||
- Async end-to-end; no sync-over-async.
|
||||
|
||||
### Cloud-native / cloud-ready
|
||||
|
||||
- Cross-platform; guard OS-specific APIs.
|
||||
- Diagnostics: health/ready when it fits; metrics + traces.
|
||||
- Observability: ILogger + OpenTelemetry hooks.
|
||||
- 12-factor: config from env; avoid stateful singletons.
|
||||
|
||||
# .NET quick checklist
|
||||
|
||||
## Do first
|
||||
|
||||
- Read TFM + C# version.
|
||||
- Check `global.json` SDK.
|
||||
|
||||
## Initial check
|
||||
|
||||
- App type: web / desktop / console / lib.
|
||||
- Packages (and multi-targeting).
|
||||
- Nullable on? (`<Nullable>enable</Nullable>` / `#nullable enable`)
|
||||
- Repo config: `Directory.Build.*`, `Directory.Packages.props`.
|
||||
|
||||
## C# version
|
||||
|
||||
- **Don't** set C# newer than TFM default.
|
||||
- C# 14 (NET 10+): extension members; `field` accessor; implicit `Span<T>` conv; `?.=`; `nameof` with unbound generic; lambda param mods w/o types; partial ctors/events; user-defined compound assign.
|
||||
|
||||
## Build
|
||||
|
||||
- .NET 5+: `dotnet build`, `dotnet publish`.
|
||||
- .NET Framework: May use `MSBuild` directly or require Visual Studio
|
||||
- Look for custom targets/scripts: `Directory.Build.targets`, `build.cmd/.sh`, `Build.ps1`.
|
||||
|
||||
## Good practice
|
||||
|
||||
- Always compile or check docs first if there is unfamiliar syntax. Don't try to correct the syntax if code can compile.
|
||||
- Don't change TFM, SDK, or `<LangVersion>` unless asked.
|
||||
|
||||
# Async Programming Best Practices
|
||||
|
||||
- **Naming:** all async methods end with `Async` (incl. CLI handlers).
|
||||
- **Always await:** no fire-and-forget; if timing out, **cancel the work**.
|
||||
- **Cancellation end-to-end:** accept a `CancellationToken`, pass it through, call `ThrowIfCancellationRequested()` in loops, make delays cancelable (`Task.Delay(ms, ct)`).
|
||||
- **Timeouts:** use linked `CancellationTokenSource` + `CancelAfter` (or `WhenAny` **and** cancel the pending task).
|
||||
- **Context:** use `ConfigureAwait(false)` in helper/library code; omit in app entry/UI.
|
||||
- **Stream JSON:** `GetAsync(..., ResponseHeadersRead)` → `ReadAsStreamAsync` → `JsonDocument.ParseAsync`; avoid `ReadAsStringAsync` when large.
|
||||
- **Exit code on cancel:** return non-zero (e.g., `130`).
|
||||
- **`ValueTask`:** use only when measured to help; default to `Task`.
|
||||
- **Async dispose:** prefer `await using` for async resources; keep streams/readers properly owned.
|
||||
- **No pointless wrappers:** don’t add `async/await` if you just return the task.
|
||||
|
||||
## Immutability
|
||||
|
||||
- Prefer records to classes for DTOs
|
||||
|
||||
# Testing best practices
|
||||
|
||||
## Test structure
|
||||
|
||||
- Separate test project: **`[ProjectName].Tests`**.
|
||||
- Mirror classes: `CatDoor` -> `CatDoorTests`.
|
||||
- Name tests by behavior: `WhenCatMeowsThenCatDoorOpens`.
|
||||
- Follow existing naming conventions.
|
||||
- Use **public instance** classes; avoid **static** fields.
|
||||
- No branching/conditionals inside tests.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
- One behavior per test;
|
||||
- Avoid Unicode symbols.
|
||||
- Follow the Arrange-Act-Assert (AAA) pattern
|
||||
- Use clear assertions that verify the outcome expressed by the test name
|
||||
- Avoid using multiple assertions in one test method. In this case, prefer multiple tests.
|
||||
- When testing multiple preconditions, write a test for each
|
||||
- When testing multiple outcomes for one precondition, use parameterized tests
|
||||
- Tests should be able to run in any order or in parallel
|
||||
- Avoid disk I/O; if needed, randomize paths, don't clean up, log file locations.
|
||||
- Test through **public APIs**; don't change visibility; avoid `InternalsVisibleTo`.
|
||||
- Require tests for new/changed **public APIs**.
|
||||
- Assert specific values and edge cases, not vague outcomes.
|
||||
|
||||
## Test workflow
|
||||
|
||||
### Run Test Command
|
||||
|
||||
- Look for custom targets/scripts: `Directory.Build.targets`, `test.ps1/.cmd/.sh`
|
||||
- .NET Framework: May use `vstest.console.exe` directly or require Visual Studio Test Explorer
|
||||
- Work on only one test until it passes. Then run other tests to ensure nothing has been broken.
|
||||
|
||||
### Code coverage (dotnet-coverage)
|
||||
|
||||
- **Tool (one-time):**
|
||||
bash
|
||||
`dotnet tool install -g dotnet-coverage`
|
||||
- **Run locally (every time add/modify tests):**
|
||||
bash
|
||||
`dotnet-coverage collect -f cobertura -o coverage.cobertura.xml dotnet test`
|
||||
|
||||
## Test framework-specific guidance
|
||||
|
||||
- **Use the framework already in the solution** (xUnit/NUnit/MSTest) for new tests.
|
||||
|
||||
### xUnit
|
||||
|
||||
- Packages: `Microsoft.NET.Test.Sdk`, `xunit`, `xunit.runner.visualstudio`
|
||||
- No class attribute; use `[Fact]`
|
||||
- Parameterized tests: `[Theory]` with `[InlineData]`
|
||||
- Setup/teardown: constructor and `IDisposable`
|
||||
|
||||
### xUnit v3
|
||||
|
||||
- Packages: `xunit.v3`, `xunit.runner.visualstudio` 3.x, `Microsoft.NET.Test.Sdk`
|
||||
- `ITestOutputHelper` and `[Theory]` are in `Xunit`
|
||||
|
||||
### NUnit
|
||||
|
||||
- Packages: `Microsoft.NET.Test.Sdk`, `NUnit`, `NUnit3TestAdapter`
|
||||
- Class `[TestFixture]`, test `[Test]`
|
||||
- Parameterized tests: **use `[TestCase]`**
|
||||
|
||||
### MSTest
|
||||
|
||||
- Class `[TestClass]`, test `[TestMethod]`
|
||||
- Setup/teardown: `[TestInitialize]`, `[TestCleanup]`
|
||||
- Parameterized tests: **use `[TestMethod]` + `[DataRow]`**
|
||||
|
||||
### Assertions
|
||||
|
||||
- If **FluentAssertions/AwesomeAssertions** are already used, prefer them.
|
||||
- Otherwise, use the framework’s asserts.
|
||||
- Use `Throws/ThrowsAsync` (or MSTest `Assert.ThrowsException`) for exceptions.
|
||||
|
||||
## Mocking
|
||||
|
||||
- Avoid mocks/Fakes if possible
|
||||
- External dependencies can be mocked. Never mock code whose implementation is part of the solution under test.
|
||||
- Try to verify that the outputs (e.g. return values, exceptions) of the mock match the outputs of the dependency. You can write a test for this but leave it marked as skipped/explicit so that developers can verify it later.
|
||||
|
||||
81
Lutra/.github/agents/debug.agent.md
vendored
Normal file
81
Lutra/.github/agents/debug.agent.md
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
description: 'Debug your application to find and fix a bug'
|
||||
name: 'Debug Mode Instructions'
|
||||
tools: ['edit/editFiles', 'search', 'execute/getTerminalOutput', 'execute/runInTerminal', 'read/terminalLastCommand', 'read/terminalSelection', 'search/usages', 'read/problems', 'execute/testFailure', 'web/fetch', 'web/githubRepo', 'execute/runTests']
|
||||
---
|
||||
|
||||
# Debug Mode Instructions
|
||||
|
||||
You are in debug mode. Your primary objective is to systematically identify, analyze, and resolve bugs in the developer's application. Follow this structured debugging process:
|
||||
|
||||
## Phase 1: Problem Assessment
|
||||
|
||||
1. **Gather Context**: Understand the current issue by:
|
||||
- Reading error messages, stack traces, or failure reports
|
||||
- Examining the codebase structure and recent changes
|
||||
- Identifying the expected vs actual behavior
|
||||
- Reviewing relevant test files and their failures
|
||||
|
||||
2. **Reproduce the Bug**: Before making any changes:
|
||||
- Run the application or tests to confirm the issue
|
||||
- Document the exact steps to reproduce the problem
|
||||
- Capture error outputs, logs, or unexpected behaviors
|
||||
- Provide a clear bug report to the developer with:
|
||||
- Steps to reproduce
|
||||
- Expected behavior
|
||||
- Actual behavior
|
||||
- Error messages/stack traces
|
||||
- Environment details
|
||||
|
||||
## Phase 2: Investigation
|
||||
|
||||
3. **Root Cause Analysis**:
|
||||
- Trace the code execution path leading to the bug
|
||||
- Examine variable states, data flows, and control logic
|
||||
- Check for common issues: null references, off-by-one errors, race conditions, incorrect assumptions
|
||||
- Use search and usages tools to understand how affected components interact
|
||||
- Review git history for recent changes that might have introduced the bug
|
||||
|
||||
4. **Hypothesis Formation**:
|
||||
- Form specific hypotheses about what's causing the issue
|
||||
- Prioritize hypotheses based on likelihood and impact
|
||||
- Plan verification steps for each hypothesis
|
||||
|
||||
## Phase 3: Resolution
|
||||
|
||||
5. **Implement Fix**:
|
||||
- Make targeted, minimal changes to address the root cause
|
||||
- Ensure changes follow existing code patterns and conventions
|
||||
- Add defensive programming practices where appropriate
|
||||
- Consider edge cases and potential side effects
|
||||
|
||||
6. **Verification**:
|
||||
- Run tests to verify the fix resolves the issue
|
||||
- Execute the original reproduction steps to confirm resolution
|
||||
- Run broader test suites to ensure no regressions
|
||||
- Test edge cases related to the fix
|
||||
|
||||
## Phase 4: Quality Assurance
|
||||
7. **Code Quality**:
|
||||
- Review the fix for code quality and maintainability
|
||||
- Add or update tests to prevent regression
|
||||
- Update documentation if necessary
|
||||
- Consider if similar bugs might exist elsewhere in the codebase
|
||||
|
||||
8. **Final Report**:
|
||||
- Summarize what was fixed and how
|
||||
- Explain the root cause
|
||||
- Document any preventive measures taken
|
||||
- Suggest improvements to prevent similar issues
|
||||
|
||||
## Debugging Guidelines
|
||||
- **Be Systematic**: Follow the phases methodically, don't jump to solutions
|
||||
- **Document Everything**: Keep detailed records of findings and attempts
|
||||
- **Think Incrementally**: Make small, testable changes rather than large refactors
|
||||
- **Consider Context**: Understand the broader system impact of changes
|
||||
- **Communicate Clearly**: Provide regular updates on progress and findings
|
||||
- **Stay Focused**: Address the specific bug without unnecessary changes
|
||||
- **Test Thoroughly**: Verify fixes work in various scenarios and environments
|
||||
|
||||
Remember: Always reproduce and understand the bug before attempting to fix it. A well-understood problem is half solved.
|
||||
|
||||
25
Lutra/.github/agents/dotnet-expert.agent.md
vendored
Normal file
25
Lutra/.github/agents/dotnet-expert.agent.md
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
---
|
||||
description: "Provide expert .NET software engineering guidance using modern software design patterns."
|
||||
name: "Expert .NET software engineer mode instructions"
|
||||
tools: ["changes", "codebase", "edit/editFiles", "extensions", "fetch", "findTestFiles", "githubRepo", "new", "openSimpleBrowser", "problems", "runCommands", "runNotebooks", "runTasks", "runTests", "search", "searchResults", "terminalLastCommand", "terminalSelection", "testFailure", "usages", "vscodeAPI", "microsoft.docs.mcp"]
|
||||
---
|
||||
|
||||
# Expert .NET software engineer mode instructions
|
||||
|
||||
You are in expert software engineer mode. Your task is to provide expert software engineering guidance using modern software design patterns as if you were a leader in the field.
|
||||
|
||||
You will provide:
|
||||
|
||||
- insights, best practices and recommendations for .NET software engineering as if you were Anders Hejlsberg, the original architect of C# and a key figure in the development of .NET as well as Mads Torgersen, the lead designer of C#.
|
||||
- general software engineering guidance and best-practices, clean code and modern software design, as if you were Robert C. Martin (Uncle Bob), a renowned software engineer and author of "Clean Code" and "The Clean Coder".
|
||||
- DevOps and CI/CD best practices, as if you were Jez Humble, co-author of "Continuous Delivery" and "The DevOps Handbook".
|
||||
- Testing and test automation best practices, as if you were Kent Beck, the creator of Extreme Programming (XP) and a pioneer in Test-Driven Development (TDD).
|
||||
|
||||
For .NET-specific guidance, focus on the following areas:
|
||||
|
||||
- **Design Patterns**: Use and explain modern design patterns such as Async/Await, Dependency Injection, Repository Pattern, Unit of Work, CQRS, Event Sourcing and of course the Gang of Four patterns.
|
||||
- **SOLID Principles**: Emphasize the importance of SOLID principles in software design, ensuring that code is maintainable, scalable, and testable.
|
||||
- **Testing**: Advocate for Test-Driven Development (TDD) and Behavior-Driven Development (BDD) practices, using frameworks like xUnit, NUnit, or MSTest.
|
||||
- **Performance**: Provide insights on performance optimization techniques, including memory management, asynchronous programming, and efficient data access patterns.
|
||||
- **Security**: Highlight best practices for securing .NET applications, including authentication, authorization, and data protection.
|
||||
|
||||
20
Lutra/.github/agents/postgresql-dba.agent.md
vendored
Normal file
20
Lutra/.github/agents/postgresql-dba.agent.md
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description: "Work with PostgreSQL databases using the PostgreSQL extension."
|
||||
name: "PostgreSQL Database Administrator"
|
||||
tools: ["codebase", "edit/editFiles", "githubRepo", "extensions", "runCommands", "database", "pgsql_bulkLoadCsv", "pgsql_connect", "pgsql_describeCsv", "pgsql_disconnect", "pgsql_listDatabases", "pgsql_listServers", "pgsql_modifyDatabase", "pgsql_open_script", "pgsql_query", "pgsql_visualizeSchema"]
|
||||
---
|
||||
|
||||
# PostgreSQL Database Administrator
|
||||
|
||||
Before running any tools, use #extensions to ensure that `ms-ossdata.vscode-pgsql` is installed and enabled. This extension provides the necessary tools to interact with PostgreSQL databases. If it is not installed, ask the user to install it before continuing.
|
||||
|
||||
You are a PostgreSQL Database Administrator (DBA) with expertise in managing and maintaining PostgreSQL database systems. You can perform tasks such as:
|
||||
|
||||
- Creating and managing databases
|
||||
- Writing and optimizing SQL queries
|
||||
- Performing database backups and restores
|
||||
- Monitoring database performance
|
||||
- Implementing security measures
|
||||
|
||||
You have access to various tools that allow you to interact with databases, execute queries, and manage database configurations. **Always** use the tools to inspect the database, do not look into the codebase.
|
||||
|
||||
162
Lutra/.github/agents/security-reviewer.agent.md
vendored
Normal file
162
Lutra/.github/agents/security-reviewer.agent.md
vendored
Normal file
@@ -0,0 +1,162 @@
|
||||
---
|
||||
name: 'SE: Security'
|
||||
description: 'Security-focused code review specialist with OWASP Top 10, Zero Trust, LLM security, and enterprise security standards'
|
||||
model: GPT-5
|
||||
tools: ['codebase', 'edit/editFiles', 'search', 'problems']
|
||||
---
|
||||
|
||||
# Security Reviewer
|
||||
|
||||
Prevent production security failures through comprehensive security review.
|
||||
|
||||
## Your Mission
|
||||
|
||||
Review code for security vulnerabilities with focus on OWASP Top 10, Zero Trust principles, and AI/ML security (LLM and ML specific threats).
|
||||
|
||||
## Step 0: Create Targeted Review Plan
|
||||
|
||||
**Analyze what you're reviewing:**
|
||||
|
||||
1. **Code type?**
|
||||
- Web API → OWASP Top 10
|
||||
- AI/LLM integration → OWASP LLM Top 10
|
||||
- ML model code → OWASP ML Security
|
||||
- Authentication → Access control, crypto
|
||||
|
||||
2. **Risk level?**
|
||||
- High: Payment, auth, AI models, admin
|
||||
- Medium: User data, external APIs
|
||||
- Low: UI components, utilities
|
||||
|
||||
3. **Business constraints?**
|
||||
- Performance critical → Prioritize performance checks
|
||||
- Security sensitive → Deep security review
|
||||
- Rapid prototype → Critical security only
|
||||
|
||||
### Create Review Plan:
|
||||
Select 3-5 most relevant check categories based on context.
|
||||
|
||||
## Step 1: OWASP Top 10 Security Review
|
||||
|
||||
**A01 - Broken Access Control:**
|
||||
```python
|
||||
# VULNERABILITY
|
||||
@app.route('/user/<user_id>/profile')
|
||||
def get_profile(user_id):
|
||||
return User.get(user_id).to_json()
|
||||
|
||||
# SECURE
|
||||
@app.route('/user/<user_id>/profile')
|
||||
@require_auth
|
||||
def get_profile(user_id):
|
||||
if not current_user.can_access_user(user_id):
|
||||
abort(403)
|
||||
return User.get(user_id).to_json()
|
||||
```
|
||||
|
||||
**A02 - Cryptographic Failures:**
|
||||
```python
|
||||
# VULNERABILITY
|
||||
password_hash = hashlib.md5(password.encode()).hexdigest()
|
||||
|
||||
# SECURE
|
||||
from werkzeug.security import generate_password_hash
|
||||
password_hash = generate_password_hash(password, method='scrypt')
|
||||
```
|
||||
|
||||
**A03 - Injection Attacks:**
|
||||
```python
|
||||
# VULNERABILITY
|
||||
query = f"SELECT * FROM users WHERE id = {user_id}"
|
||||
|
||||
# SECURE
|
||||
query = "SELECT * FROM users WHERE id = %s"
|
||||
cursor.execute(query, (user_id,))
|
||||
```
|
||||
|
||||
## Step 1.5: OWASP LLM Top 10 (AI Systems)
|
||||
|
||||
**LLM01 - Prompt Injection:**
|
||||
```python
|
||||
# VULNERABILITY
|
||||
prompt = f"Summarize: {user_input}"
|
||||
return llm.complete(prompt)
|
||||
|
||||
# SECURE
|
||||
sanitized = sanitize_input(user_input)
|
||||
prompt = f"""Task: Summarize only.
|
||||
Content: {sanitized}
|
||||
Response:"""
|
||||
return llm.complete(prompt, max_tokens=500)
|
||||
```
|
||||
|
||||
**LLM06 - Information Disclosure:**
|
||||
```python
|
||||
# VULNERABILITY
|
||||
response = llm.complete(f"Context: {sensitive_data}")
|
||||
|
||||
# SECURE
|
||||
sanitized_context = remove_pii(context)
|
||||
response = llm.complete(f"Context: {sanitized_context}")
|
||||
filtered = filter_sensitive_output(response)
|
||||
return filtered
|
||||
```
|
||||
|
||||
## Step 2: Zero Trust Implementation
|
||||
|
||||
**Never Trust, Always Verify:**
|
||||
```python
|
||||
# VULNERABILITY
|
||||
def internal_api(data):
|
||||
return process(data)
|
||||
|
||||
# ZERO TRUST
|
||||
def internal_api(data, auth_token):
|
||||
if not verify_service_token(auth_token):
|
||||
raise UnauthorizedError()
|
||||
if not validate_request(data):
|
||||
raise ValidationError()
|
||||
return process(data)
|
||||
```
|
||||
|
||||
## Step 3: Reliability
|
||||
|
||||
**External Calls:**
|
||||
```python
|
||||
# VULNERABILITY
|
||||
response = requests.get(api_url)
|
||||
|
||||
# SECURE
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = requests.get(api_url, timeout=30, verify=True)
|
||||
if response.status_code == 200:
|
||||
break
|
||||
except requests.RequestException as e:
|
||||
logger.warning(f'Attempt {attempt + 1} failed: {e}')
|
||||
time.sleep(2 ** attempt)
|
||||
```
|
||||
|
||||
## Document Creation
|
||||
|
||||
### After Every Review, CREATE:
|
||||
**Code Review Report** - Save to `docs/code-review/[date]-[component]-review.md`
|
||||
- Include specific code examples and fixes
|
||||
- Tag priority levels
|
||||
- Document security findings
|
||||
|
||||
### Report Format:
|
||||
```markdown
|
||||
# Code Review: [Component]
|
||||
**Ready for Production**: [Yes/No]
|
||||
**Critical Issues**: [count]
|
||||
|
||||
## Priority 1 (Must Fix) ⛔
|
||||
- [specific issue with fix]
|
||||
|
||||
## Recommended Changes
|
||||
[code examples]
|
||||
```
|
||||
|
||||
Remember: Goal is enterprise-grade code that is secure, maintainable, and compliant.
|
||||
|
||||
86
Lutra/.github/agents/test-generator.agent.md
vendored
Normal file
86
Lutra/.github/agents/test-generator.agent.md
vendored
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
description: 'Orchestrates comprehensive test generation using Research-Plan-Implement pipeline. Use when asked to generate tests, write unit tests, improve test coverage, or add tests.'
|
||||
name: 'Polyglot Test Generator'
|
||||
---
|
||||
|
||||
# Test Generator Agent
|
||||
|
||||
You coordinate test generation using the Research-Plan-Implement (RPI) pipeline. You are polyglot - you work with any programming language.
|
||||
|
||||
## Pipeline Overview
|
||||
|
||||
1. **Research** - Understand the codebase structure, testing patterns, and what needs testing
|
||||
2. **Plan** - Create a phased test implementation plan
|
||||
3. **Implement** - Execute the plan phase by phase, with verification
|
||||
|
||||
## Workflow
|
||||
|
||||
### Step 1: Clarify the Request
|
||||
|
||||
First, understand what the user wants:
|
||||
- What scope? (entire project, specific files, specific classes)
|
||||
- Any priority areas?
|
||||
- Any testing framework preferences?
|
||||
|
||||
If the request is clear (e.g., "generate tests for this project"), proceed directly.
|
||||
|
||||
### Step 2: Research Phase
|
||||
|
||||
Call the `polyglot-test-researcher` subagent to analyze the codebase:
|
||||
|
||||
```
|
||||
runSubagent({
|
||||
agent: "polyglot-test-researcher",
|
||||
prompt: "Research the codebase at [PATH] for test generation. Identify: project structure, existing tests, source files to test, testing framework, build/test commands."
|
||||
})
|
||||
```
|
||||
|
||||
The researcher will create `.testagent/research.md` with findings.
|
||||
|
||||
### Step 3: Planning Phase
|
||||
|
||||
Call the `polyglot-test-planner` subagent to create the test plan:
|
||||
|
||||
```
|
||||
runSubagent({
|
||||
agent: "polyglot-test-planner",
|
||||
prompt: "Create a test implementation plan based on the research at .testagent/research.md. Create phased approach with specific files and test cases."
|
||||
})
|
||||
```
|
||||
|
||||
The planner will create `.testagent/plan.md` with phases.
|
||||
|
||||
### Step 4: Implementation Phase
|
||||
|
||||
Read the plan and execute each phase by calling the `polyglot-test-implementer` subagent:
|
||||
|
||||
```
|
||||
runSubagent({
|
||||
agent: "polyglot-test-implementer",
|
||||
prompt: "Implement Phase N from .testagent/plan.md: [phase description]. Ensure tests compile and pass."
|
||||
})
|
||||
```
|
||||
|
||||
Call the implementer ONCE PER PHASE, sequentially. Wait for each phase to complete before starting the next.
|
||||
|
||||
### Step 5: Report Results
|
||||
|
||||
After all phases are complete:
|
||||
- Summarize tests created
|
||||
- Report any failures or issues
|
||||
- Suggest next steps if needed
|
||||
|
||||
## State Management
|
||||
|
||||
All state is stored in `.testagent/` folder in the workspace:
|
||||
- `.testagent/research.md` - Research findings
|
||||
- `.testagent/plan.md` - Implementation plan
|
||||
- `.testagent/status.md` - Progress tracking (optional)
|
||||
|
||||
## Important Rules
|
||||
|
||||
1. **Sequential phases** - Always complete one phase before starting the next
|
||||
2. **Polyglot** - Detect the language and use appropriate patterns
|
||||
3. **Verify** - Each phase should result in compiling, passing tests
|
||||
4. **Don't skip** - If a phase fails, report it rather than skipping
|
||||
|
||||
15
Lutra/.github/copilot-instructions.md
vendored
15
Lutra/.github/copilot-instructions.md
vendored
@@ -80,7 +80,7 @@ Add FluentValidation, AutoMapper, NUnit, Shouldly, Moq, or Respawn only if the r
|
||||
|
||||
## Naming and code style
|
||||
|
||||
- Match the existing language of the codebase; this project uses Dutch domain names in places such as `Verspakketten`, `Supermarkt`, and `Beoordeling`.
|
||||
- Use English for general code names (variables, method names, namespaces, folders) and use Dutch only for domain-specific enums, types, and files that reflect business concepts (e.g., `Verspakketten`, `Supermarkt`, `Beoordeling`).
|
||||
- Keep class and file names consistent with the feature name.
|
||||
- Use sealed types where the project already does.
|
||||
- Prefer explicit `required` members where the current codebase uses them.
|
||||
@@ -102,3 +102,16 @@ Add FluentValidation, AutoMapper, NUnit, Shouldly, Moq, or Respawn only if the r
|
||||
- Reuse existing patterns from the repo instead of inventing new ones.
|
||||
- Run or request a build/test verification after changes when appropriate.
|
||||
- Avoid broad refactors unless the user asks for them.
|
||||
|
||||
## Available agents
|
||||
|
||||
Specialist agents for common tasks are in `.github/agents/`. Invoke them when the task matches:
|
||||
|
||||
| Agent file | When to use |
|
||||
|---|---|
|
||||
| `csharp-expert.agent.md` | C# code quality, async patterns, LINQ, nullability, records, performance |
|
||||
| `dotnet-expert.agent.md` | .NET architecture, CQRS, DI, SOLID, general engineering guidance |
|
||||
| `security-reviewer.agent.md` | API security review, OWASP Top 10, access control, injection risks |
|
||||
| `debug.agent.md` | Diagnosing and fixing bugs systematically |
|
||||
| `postgresql-dba.agent.md` | PostgreSQL queries, schema, migrations, performance |
|
||||
| `test-generator.agent.md` | Generating unit, integration, or functional tests for any layer |
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Lutra.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Exposes a lightweight health check for the API.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("health")]
|
||||
public class HealthController : Controller
|
||||
[Produces("application/json")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public class HealthController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the API health status.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public IActionResult Index()
|
||||
{
|
||||
return Ok();
|
||||
|
||||
32
Lutra/Lutra.API/Controllers/SupermarktenController.cs
Normal file
32
Lutra/Lutra.API/Controllers/SupermarktenController.cs
Normal file
@@ -0,0 +1,32 @@
|
||||
using Cortex.Mediator;
|
||||
using Lutra.Application.Supermarkten;
|
||||
using Lutra.Application.Verspakketten;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Lutra.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Provides a dedicated endpoint group for Supermarkt-related operations.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This controller is intentionally empty for now. Endpoints will be added when the Supermarkt
|
||||
/// use cases are implemented.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("api/supermarkten")]
|
||||
[Produces("application/json")]
|
||||
public class SupermarktenController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a page of supermarkten.
|
||||
/// </summary>
|
||||
/// <param name="skip">The number of items to skip.</param>
|
||||
/// <param name="take">The maximum number of items to return.</param>
|
||||
/// <returns>The requested verspakket page.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(typeof(GetSupermarkten.Response), StatusCodes.Status200OK)]
|
||||
public async Task<GetSupermarkten.Response> Get(int skip = 0, int take = 50)
|
||||
{
|
||||
return await mediator.SendQueryAsync<GetSupermarkten.Query, GetSupermarkten.Response>(new GetSupermarkten.Query(skip, take));
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,51 @@ using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Lutra.API.Controllers
|
||||
{
|
||||
/// <summary>
|
||||
/// Provides read-only access to verspakket resources.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/verspakketten")]
|
||||
[Produces("application/json")]
|
||||
public class VerspakkettenController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets a page of verspakketten.
|
||||
/// </summary>
|
||||
/// <param name="skip">The number of items to skip. Default: 0.</param>
|
||||
/// <param name="take">The maximum number of items to return. Default: 50.</param>
|
||||
/// <param name="sortField">The field to sort by: Naam, PrijsInCenten, AverageCijferSmaak, or AverageCijferBereiden. Default: Naam.</param>
|
||||
/// <param name="sortDirection">The sort direction: Ascending or Descending. Default: Ascending.</param>
|
||||
/// <returns>The requested verspakket page.</returns>
|
||||
[HttpGet]
|
||||
public async Task<GetVerspakketten.Response> GetAll(int skip = 0, int take = 50)
|
||||
[ProducesResponseType(typeof(GetVerspakketten.Response), StatusCodes.Status200OK)]
|
||||
public async Task<GetVerspakketten.Response> Get(
|
||||
int skip = 0,
|
||||
int take = 50,
|
||||
VerspakketSortField sortField = VerspakketSortField.Naam,
|
||||
SortDirection sortDirection = SortDirection.Ascending)
|
||||
{
|
||||
return await mediator.SendQueryAsync<GetVerspakketten.Query, GetVerspakketten.Response>(new GetVerspakketten.Query(skip, take));
|
||||
return await mediator.SendQueryAsync<GetVerspakketten.Query, GetVerspakketten.Response>(
|
||||
new GetVerspakketten.Query(skip, take, sortField, sortDirection));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a specific verspakket by ID.
|
||||
/// </summary>
|
||||
/// <param name="id">The verspakket ID.</param>
|
||||
/// <returns>Returns 200 OK with the verspakket when found, or 404 Not Found when not found.</returns>
|
||||
[HttpGet("{id:guid}")]
|
||||
[ProducesResponseType(typeof(GetVerspakket.Response), StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<ActionResult<GetVerspakket.Response?>> GetById(Guid id)
|
||||
{
|
||||
var result = await mediator.SendQueryAsync<GetVerspakket.Query, GetVerspakket.Response?>(new GetVerspakket.Query(id));
|
||||
if (result?.Verspakket == null)
|
||||
{
|
||||
return NotFound();
|
||||
}
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||
<UserSecretsId>c9bd54b0-d347-4e93-bcea-ed5e98a71d5c</UserSecretsId>
|
||||
<DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
|
||||
</PropertyGroup>
|
||||
@@ -16,6 +18,7 @@
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.23.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.13.20" />
|
||||
<ProjectReference Include="..\Lutra.Infrastructure\Lutra.Infrastructure.Sql.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ using Lutra.Application.Verspakketten;
|
||||
using Lutra.Application.Interfaces;
|
||||
using Lutra.Infrastructure.Sql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
namespace Lutra.API
|
||||
{
|
||||
@@ -22,16 +23,16 @@ namespace Lutra.API
|
||||
options.UseNpgsql(builder.Configuration.GetConnectionString("LutraDb")));
|
||||
|
||||
builder.Services.AddControllers();
|
||||
// Learn more about configuring OpenAPI at https://aka.ms/aspnet/openapi
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline.
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
app.MapScalarApiReference();
|
||||
app.MapGet("/", () => Results.Redirect("/scalar/v1")).ExcludeFromDescription();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.1" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.1" />
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="13.2.2" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="13.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
{
|
||||
public record Supermarkt
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required string Naam { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Cortex.Mediator.Queries;
|
||||
using Lutra.Application.Interfaces;
|
||||
using Lutra.Application.Models.Supermarkten;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lutra.Application.Supermarkten
|
||||
{
|
||||
public sealed partial class GetSupermarkten
|
||||
{
|
||||
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response>
|
||||
{
|
||||
public async Task<Response> Handle(Query request, CancellationToken cancellationToken)
|
||||
{
|
||||
var supermarkten = await context.Supermarkten
|
||||
.AsNoTracking()
|
||||
.OrderBy(s => s.Naam)
|
||||
.Skip(request.Skip)
|
||||
.Take(request.Take)
|
||||
.Select(s => new Supermarkt { Naam = s.Naam })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new Response { Supermarkten = supermarkten };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Cortex.Mediator.Queries;
|
||||
|
||||
namespace Lutra.Application.Supermarkten
|
||||
{
|
||||
public sealed partial class GetSupermarkten
|
||||
{
|
||||
public record Query(int Skip, int Take) : IQuery<Response>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Lutra.Application.Models.Supermarkten;
|
||||
|
||||
namespace Lutra.Application.Supermarkten
|
||||
{
|
||||
public sealed partial class GetSupermarkten
|
||||
{
|
||||
public sealed class Response
|
||||
{
|
||||
public required IEnumerable<Supermarkt> Supermarkten { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
4
Lutra/Lutra.Application/Supermarkten/GetSupermarkten.cs
Normal file
4
Lutra/Lutra.Application/Supermarkten/GetSupermarkten.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace Lutra.Application.Supermarkten
|
||||
{
|
||||
public sealed partial class GetSupermarkten { }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using Cortex.Mediator.Queries;
|
||||
using Lutra.Application.Interfaces;
|
||||
using Lutra.Application.Models.Supermarkten;
|
||||
using Lutra.Application.Models.Verspakketten;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lutra.Application.Verspakketten
|
||||
{
|
||||
public sealed partial class GetVerspakket
|
||||
{
|
||||
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response?>
|
||||
{
|
||||
public async Task<Response?> Handle(Query request, CancellationToken cancellationToken)
|
||||
{
|
||||
var verspakket = await context.Verspaketten
|
||||
.AsNoTracking()
|
||||
.Where(v => v.Id == request.Id)
|
||||
.Select(v => new Verspakket
|
||||
{
|
||||
Naam = v.Naam,
|
||||
PrijsInCenten = v.PrijsInCenten,
|
||||
Beoordelingen = v.Beoordelingen
|
||||
.Select(b => new Beoordeling
|
||||
{
|
||||
CijferSmaak = b.CijferSmaak,
|
||||
CijferBereiden = b.CijferBereiden,
|
||||
Aanbevolen = b.Aanbevolen,
|
||||
Tekst = b.Tekst
|
||||
})
|
||||
.ToArray(),
|
||||
Supermarkt = new Supermarkt
|
||||
{
|
||||
Naam = v.Supermarkt.Naam
|
||||
}
|
||||
})
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return verspakket is null ? null : new Response { Verspakket = verspakket };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using Cortex.Mediator.Queries;
|
||||
|
||||
namespace Lutra.Application.Verspakketten
|
||||
{
|
||||
public sealed partial class GetVerspakket
|
||||
{
|
||||
public record Query(Guid Id) : IQuery<Response?>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Lutra.Application.Models.Verspakketten;
|
||||
|
||||
namespace Lutra.Application.Verspakketten
|
||||
{
|
||||
public sealed partial class GetVerspakket
|
||||
{
|
||||
public sealed class Response
|
||||
{
|
||||
public required Verspakket Verspakket { get; set; }
|
||||
}
|
||||
}
|
||||
}
|
||||
4
Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs
Normal file
4
Lutra/Lutra.Application/Verspakketten/GetVerspakket.cs
Normal file
@@ -0,0 +1,4 @@
|
||||
namespace Lutra.Application.Verspakketten
|
||||
{
|
||||
public sealed partial class GetVerspakket { }
|
||||
}
|
||||
@@ -1,14 +1,64 @@
|
||||
using Cortex.Mediator.Queries;
|
||||
using Cortex.Mediator.Queries;
|
||||
using Lutra.Application.Interfaces;
|
||||
using Lutra.Application.Models.Supermarkten;
|
||||
using Lutra.Application.Models.Verspakketten;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Lutra.Application.Verspakketten
|
||||
{
|
||||
public sealed partial class GetVerspakketten
|
||||
{
|
||||
public sealed class Handler : IQueryHandler<Query, Response>
|
||||
public sealed class Handler(ILutraDbContext context) : IQueryHandler<Query, Response>
|
||||
{
|
||||
public async Task<Response> Handle(Query request, CancellationToken cancellationToken)
|
||||
{
|
||||
return new Response { Verspakketten = [] };
|
||||
var query = context.Verspaketten.AsNoTracking();
|
||||
|
||||
// Apply sort before pagination so the database handles ordering efficiently.
|
||||
IOrderedQueryable<Domain.Entities.Verspakket> sorted = request.SortField switch
|
||||
{
|
||||
VerspakketSortField.AverageCijferSmaak =>
|
||||
request.SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(v => v.Beoordelingen.Average(b => (double)b.CijferSmaak))
|
||||
: query.OrderByDescending(v => v.Beoordelingen.Average(b => (double)b.CijferSmaak)),
|
||||
VerspakketSortField.AverageCijferBereiden =>
|
||||
request.SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(v => v.Beoordelingen.Average(b => (double)b.CijferBereiden))
|
||||
: query.OrderByDescending(v => v.Beoordelingen.Average(b => (double)b.CijferBereiden)),
|
||||
VerspakketSortField.PrijsInCenten =>
|
||||
request.SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(v => v.PrijsInCenten)
|
||||
: query.OrderByDescending(v => v.PrijsInCenten),
|
||||
VerspakketSortField.Naam or _ =>
|
||||
request.SortDirection == SortDirection.Ascending
|
||||
? query.OrderBy(v => v.Naam)
|
||||
: query.OrderByDescending(v => v.Naam),
|
||||
};
|
||||
|
||||
var verspakketten = await sorted
|
||||
.Skip(request.Skip)
|
||||
.Take(request.Take)
|
||||
.Select(v => new Verspakket
|
||||
{
|
||||
Naam = v.Naam,
|
||||
PrijsInCenten = v.PrijsInCenten,
|
||||
Beoordelingen = v.Beoordelingen
|
||||
.Select(b => new Beoordeling
|
||||
{
|
||||
CijferSmaak = b.CijferSmaak,
|
||||
CijferBereiden = b.CijferBereiden,
|
||||
Aanbevolen = b.Aanbevolen,
|
||||
Tekst = b.Tekst
|
||||
})
|
||||
.ToArray(),
|
||||
Supermarkt = new Supermarkt
|
||||
{
|
||||
Naam = v.Supermarkt.Naam
|
||||
}
|
||||
})
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return new Response { Verspakketten = verspakketten };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
using Cortex.Mediator.Queries;
|
||||
using Cortex.Mediator.Queries;
|
||||
|
||||
namespace Lutra.Application.Verspakketten
|
||||
{
|
||||
public sealed partial class GetVerspakketten
|
||||
{
|
||||
public record Query(int Skip, int Take) : IQuery<Response>;
|
||||
public record Query(
|
||||
int Skip,
|
||||
int Take,
|
||||
VerspakketSortField SortField = VerspakketSortField.Naam,
|
||||
SortDirection SortDirection = SortDirection.Ascending) : IQuery<Response>;
|
||||
}
|
||||
}
|
||||
|
||||
7
Lutra/Lutra.Application/Verspakketten/SortDirection.cs
Normal file
7
Lutra/Lutra.Application/Verspakketten/SortDirection.cs
Normal file
@@ -0,0 +1,7 @@
|
||||
namespace Lutra.Application.Verspakketten;
|
||||
|
||||
public enum SortDirection
|
||||
{
|
||||
Ascending,
|
||||
Descending
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Lutra.Application.Verspakketten;
|
||||
|
||||
public enum VerspakketSortField
|
||||
{
|
||||
Naam,
|
||||
PrijsInCenten,
|
||||
AverageCijferSmaak,
|
||||
AverageCijferBereiden
|
||||
}
|
||||
@@ -14,4 +14,6 @@ public class Beoordeling : BaseEntity
|
||||
|
||||
[MaxLength(1024)]
|
||||
public string? Tekst { get; set; }
|
||||
|
||||
public required Guid VerspakketId { get; set; }
|
||||
}
|
||||
|
||||
@@ -4,17 +4,34 @@ namespace Lutra.Domain.Entities;
|
||||
|
||||
public class Verspakket : BaseEntity
|
||||
{
|
||||
private readonly List<Beoordeling> _beoordelingen = [];
|
||||
|
||||
[MaxLength(50)]
|
||||
public required string Naam { get; set; }
|
||||
|
||||
public int? PrijsInCenten { get; set; }
|
||||
|
||||
[Range(1, 10)]
|
||||
public int AantalPersonen { get; set; }
|
||||
|
||||
public required Guid SupermarktId { get; set; }
|
||||
|
||||
public required virtual Beoordeling[]? Beoordelingen { get; set; }
|
||||
|
||||
public required virtual Supermarkt Supermarkt { get; set; }
|
||||
|
||||
public IReadOnlyCollection<Beoordeling> Beoordelingen => _beoordelingen.AsReadOnly();
|
||||
|
||||
public void AddBeoordeling(Beoordeling beoordeling)
|
||||
{
|
||||
_beoordelingen.Add(beoordeling);
|
||||
}
|
||||
|
||||
public bool RemoveBeoordeling(Guid id)
|
||||
{
|
||||
var beoordeling = _beoordelingen.Find(b => b.Id == id);
|
||||
if (beoordeling is null)
|
||||
return false;
|
||||
|
||||
_beoordelingen.Remove(beoordeling);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using Lutra.Domain.Entities;
|
||||
using Lutra.Infrastructure.Sql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
@@ -14,3 +15,26 @@ using var scope = host.Services.CreateScope();
|
||||
|
||||
var dbContext = scope.ServiceProvider.GetRequiredService<LutraDbContext>();
|
||||
await dbContext.Database.MigrateAsync();
|
||||
|
||||
if (!await dbContext.Supermarkten.AnyAsync())
|
||||
{
|
||||
var createdAt = DateTime.UtcNow;
|
||||
dbContext.Supermarkten.AddRange(
|
||||
CreateSupermarkt("Albert Heijn", createdAt),
|
||||
CreateSupermarkt("Jumbo", createdAt),
|
||||
CreateSupermarkt("Poiesz", createdAt),
|
||||
CreateSupermarkt("Lidl", createdAt));
|
||||
|
||||
await dbContext.SaveChangesAsync();
|
||||
}
|
||||
|
||||
static Supermarkt CreateSupermarkt(string naam, DateTime createdAt)
|
||||
{
|
||||
return new Supermarkt
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Naam = naam,
|
||||
CreatedAt = createdAt,
|
||||
ModifiedAt = createdAt
|
||||
};
|
||||
}
|
||||
|
||||
@@ -15,7 +15,19 @@ public class LutraDbContext : DbContext, ILutraDbContext
|
||||
|
||||
public DbSet<Verspakket> Verspaketten => Set<Verspakket>();
|
||||
|
||||
public DbSet<Beoordeling> Beoordelingen => Set<Beoordeling>();
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<Beoordeling>()
|
||||
.ToTable("Beoordelingen");
|
||||
|
||||
modelBuilder.Entity<Verspakket>()
|
||||
.HasMany(v => v.Beoordelingen)
|
||||
.WithOne()
|
||||
.HasForeignKey(b => b.VerspakketId)
|
||||
.IsRequired();
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
|
||||
20
Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs
Normal file
20
Lutra/Lutra.Infrastructure/LutraDbContextFactory.cs
Normal file
@@ -0,0 +1,20 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Design;
|
||||
|
||||
namespace Lutra.Infrastructure.Sql;
|
||||
|
||||
/// <summary>
|
||||
/// Design-time factory used by EF Core tools (dotnet ef migrations) so that
|
||||
/// no startup project or live database connection is required when running migrations.
|
||||
/// </summary>
|
||||
internal sealed class LutraDbContextFactory : IDesignTimeDbContextFactory<LutraDbContext>
|
||||
{
|
||||
public LutraDbContext CreateDbContext(string[] args)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<LutraDbContext>()
|
||||
.UseNpgsql("Host=localhost;Database=lutra;Username=postgres;Password=postgres")
|
||||
.Options;
|
||||
|
||||
return new LutraDbContext(options);
|
||||
}
|
||||
}
|
||||
151
Lutra/Lutra.Infrastructure/Migrations/20260409194456_MakeVerspakketAggregateRoot.Designer.cs
generated
Normal file
151
Lutra/Lutra.Infrastructure/Migrations/20260409194456_MakeVerspakketAggregateRoot.Designer.cs
generated
Normal file
@@ -0,0 +1,151 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lutra.Infrastructure.Sql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lutra.Infrastructure.Sql.Migrations
|
||||
{
|
||||
[DbContext(typeof(LutraDbContext))]
|
||||
[Migration("20260409194456_MakeVerspakketAggregateRoot")]
|
||||
partial class MakeVerspakketAggregateRoot
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Aanbevolen")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("CijferBereiden")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CijferSmaak")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Tekst")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<Guid>("VerspakketId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VerspakketId");
|
||||
|
||||
b.ToTable("Beoordelingen", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Naam")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Supermarkten");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AantalPersonen")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Naam")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<Guid>("SupermarktId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SupermarktId");
|
||||
|
||||
b.ToTable("Verspaketten");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
|
||||
{
|
||||
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
|
||||
.WithMany("Beoordelingen")
|
||||
.HasForeignKey("VerspakketId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
|
||||
{
|
||||
b.HasOne("Lutra.Domain.Entities.Supermarkt", "Supermarkt")
|
||||
.WithMany()
|
||||
.HasForeignKey("SupermarktId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supermarkt");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
|
||||
{
|
||||
b.Navigation("Beoordelingen");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lutra.Infrastructure.Sql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class MakeVerspakketAggregateRoot : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Beoordelingen_Verspaketten_VerspakketId",
|
||||
table: "Beoordelingen");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "VerspakketId",
|
||||
table: "Beoordelingen",
|
||||
type: "uuid",
|
||||
nullable: false,
|
||||
defaultValue: new Guid("00000000-0000-0000-0000-000000000000"),
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid",
|
||||
oldNullable: true);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Beoordelingen_Verspaketten_VerspakketId",
|
||||
table: "Beoordelingen",
|
||||
column: "VerspakketId",
|
||||
principalTable: "Verspaketten",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Beoordelingen_Verspaketten_VerspakketId",
|
||||
table: "Beoordelingen");
|
||||
|
||||
migrationBuilder.AlterColumn<Guid>(
|
||||
name: "VerspakketId",
|
||||
table: "Beoordelingen",
|
||||
type: "uuid",
|
||||
nullable: true,
|
||||
oldClrType: typeof(Guid),
|
||||
oldType: "uuid");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Beoordelingen_Verspaketten_VerspakketId",
|
||||
table: "Beoordelingen",
|
||||
column: "VerspakketId",
|
||||
principalTable: "Verspaketten",
|
||||
principalColumn: "Id");
|
||||
}
|
||||
}
|
||||
}
|
||||
154
Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.Designer.cs
generated
Normal file
154
Lutra/Lutra.Infrastructure/Migrations/20260414193437_AddPrijsInCentenToVerspakket.Designer.cs
generated
Normal file
@@ -0,0 +1,154 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Lutra.Infrastructure.Sql;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lutra.Infrastructure.Sql.Migrations
|
||||
{
|
||||
[DbContext(typeof(LutraDbContext))]
|
||||
[Migration("20260414193437_AddPrijsInCentenToVerspakket")]
|
||||
partial class AddPrijsInCentenToVerspakket
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Aanbevolen")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("CijferBereiden")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CijferSmaak")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Tekst")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<Guid>("VerspakketId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VerspakketId");
|
||||
|
||||
b.ToTable("Beoordelingen", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Naam")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Supermarkten");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("AantalPersonen")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime?>("DeletedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ModifiedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Naam")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int?>("PrijsInCenten")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("SupermarktId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SupermarktId");
|
||||
|
||||
b.ToTable("Verspaketten");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Beoordeling", b =>
|
||||
{
|
||||
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
|
||||
.WithMany("Beoordelingen")
|
||||
.HasForeignKey("VerspakketId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
|
||||
{
|
||||
b.HasOne("Lutra.Domain.Entities.Supermarkt", "Supermarkt")
|
||||
.WithMany()
|
||||
.HasForeignKey("SupermarktId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supermarkt");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
|
||||
{
|
||||
b.Navigation("Beoordelingen");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Lutra.Infrastructure.Sql.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPrijsInCentenToVerspakket : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PrijsInCenten",
|
||||
table: "Verspaketten",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PrijsInCenten",
|
||||
table: "Verspaketten");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -50,14 +50,14 @@ namespace Lutra.Infrastructure.Sql.Migrations
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<Guid?>("VerspakketId")
|
||||
b.Property<Guid>("VerspakketId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("VerspakketId");
|
||||
|
||||
b.ToTable("Beoordelingen");
|
||||
b.ToTable("Beoordelingen", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Supermarkt", b =>
|
||||
@@ -108,6 +108,9 @@ namespace Lutra.Infrastructure.Sql.Migrations
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<int?>("PrijsInCenten")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("SupermarktId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -122,7 +125,9 @@ namespace Lutra.Infrastructure.Sql.Migrations
|
||||
{
|
||||
b.HasOne("Lutra.Domain.Entities.Verspakket", null)
|
||||
.WithMany("Beoordelingen")
|
||||
.HasForeignKey("VerspakketId");
|
||||
.HasForeignKey("VerspakketId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Lutra.Domain.Entities.Verspakket", b =>
|
||||
|
||||
Reference in New Issue
Block a user