diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml
index ea58d2fef..b9e6f17b2 100644
--- a/.forgejo/workflows/buildAndTest.yml
+++ b/.forgejo/workflows/buildAndTest.yml
@@ -31,6 +31,7 @@ jobs:
steps:
- name: Clone yavsc
run: |
+ set -e
cd /src
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src
cd _src
@@ -40,12 +41,23 @@ jobs:
fi
git submodule update --init --recursive
echo "✅ Checked out at $(git rev-parse HEAD) on $(git branch --show-current 2>/dev/null || echo detached HEAD)"
-
+ - name: Secret scan
+ run: |
+ echo "🔍 Scanning for secrets..."
+ cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n10 \
+ - name: Build
+ run: |
+ echo "🏗️ Building the solution..."
+ cd /src/_src && dotnet build --verbosity normal
+ - name: Install SkiaSharp native dependencies
+ run: |
+ echo "📦 Installing libfontconfig/libfreetype for SkiaSharp (Avalonia.Headless)..."
+ apt-get update && apt-get install -y --no-install-recommends \
+ libfontconfig1 libfreetype6 libexpat1 zlib1g libbz2-1.0 libpng16-16 libbrotli1 \
+ && rm -rf /var/lib/apt/lists/*
- name: Test
run: |
echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \
--verbosity normal \
- --filter="Category!=Platform-Android" \
- --logger "xunit;LogFileName=test-results.xml" \
- && echo "✅ Success !" || echo "❌ Fail ($?)!"
+ --filter="Category!=Platform-Android"
diff --git a/.forgejo/workflows/release.yml b/.forgejo/workflows/release.yml
index a72f92bd6..911fc831d 100644
--- a/.forgejo/workflows/release.yml
+++ b/.forgejo/workflows/release.yml
@@ -175,6 +175,12 @@ jobs:
run: |
cd /src/_src
dotnet restore
+ - name: Test
+ run: |
+ cd /src/_src && dotnet test \
+ --verbosity normal \
+ --filter="Category!=Platform-Android" \
+ --logger "xunit;LogFileName=test-results.xml"
- name: Build de PostIt.Android ARM64
run: |
@@ -200,6 +206,7 @@ jobs:
RELEASE_BODY: ${{ env.RELEASE_BODY }}
IS_PRERELEASE: ${{ env.IS_PRERELEASE }}
run: |
+ set -e
if [[ -z "$TAG" ]]; then
echo "::error::No tag resolved for the API call."
exit 1
diff --git a/.gitignore b/.gitignore
index b7813f604..056cf69d2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -35,6 +35,7 @@ appsettings-*.*.json
generated/
*.tmp
+tmp/
DataDir/
*.tests.trx
diff --git a/.gitleaksignore b/.gitleaksignore
new file mode 100644
index 000000000..056cf69d2
--- /dev/null
+++ b/.gitleaksignore
@@ -0,0 +1,45 @@
+# Exclure uniquement les dossiers de sortie de compilation
+bin/
+obj/
+src/*/bin/
+src/*/obj/
+test/*/bin/
+test/*/obj/
+
+# Toolchain front (Node / esbuild)
+node_modules/
+build/
+package-lock.json
+
+# Exclure les caches lourds
+.git/
+.vs/
+
+.env
+
+.*.env
+
+*.csproj.lscache
+data/
+appsettings.*.json
+appsettings-*.*.json
+
+# Exception: the Testing-environment override for Yavsc.Org is a tracked
+# configuration source, not a secrets file. TestWebApplicationFactory
+# (Yavsc.Org.Tests) flips ASPNETCORE_ENVIRONMENT to "Testing" so
+# AddConfiguration("org") in Program.Main loads this file as the
+# last in the chain (it is optional). It overrides the connection
+# string and SMTP section for the in-memory test host and contains
+# no production secrets.
+!src/Yavsc.Org/appsettings-org.Testing.json
+
+generated/
+*.tmp
+tmp/
+DataDir/
+
+*.tests.trx
+*.tests.html
+
+*.log
+
diff --git a/.vscode/launch.json b/.vscode/launch.json
index dc8d3c68d..d5088f01d 100644
--- a/.vscode/launch.json
+++ b/.vscode/launch.json
@@ -4,6 +4,22 @@
// Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
+ {
+ "name": "PostIt Desktop",
+ "type": "dotnet",
+ "request": "launch",
+ "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj"
+ },
+ {
+ "name": "PostIt Desktop local",
+ "type": "coreclr",
+ "request": "launch",
+ "program": "${workspaceFolder}/src/PostIt/PostIt.Desktop/bin/Debug/net10.0/PostIt.Desktop.dll",
+ "env": {
+ "POSTIT_SETTINGS_JSON": "/home/paul/Workspace/yavsc/src/PostIt/PostIt/postit-settings.json"
+ },
+ "preLaunchTask": "dotnet: build-postit-desktop"
+ },
{
"name": "Android Debug",
"type": "mono",
@@ -20,10 +36,10 @@
"port": 55555
},
{
- "name": "API",
+ "name": "Yavsc API",
"type": "dotnet",
"request": "launch",
- "projectPath": "${workspaceFolder}/src/Api/Api.csproj"
+ "projectPath": "${workspaceFolder}/src/Yavsc.Api/Yavsc.Api.csproj"
},
{
"name": "Yavsc Org",
@@ -37,12 +53,7 @@
"request": "launch",
"projectPath": "${workspaceFolder}/src/Yavsc.Blogs/Yavsc.Blogs.csproj"
},
- {
- "name": "PostIt Desktop",
- "type": "dotnet",
- "request": "launch",
- "projectPath": "${workspaceFolder}/src/PostIt/PostIt.Desktop/PostIt.Desktop.csproj",
- },
+
{
"name": "Test PostIt.Android launch (Xamarin.UITest)",
"type": "coreclr",
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index fd46437ac..71384cd8c 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -7,7 +7,7 @@
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
- "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$",
+ "regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
"file": 1,
"line": 2,
"column": 3,
@@ -47,7 +47,10 @@
"group": "build",
"isBuildCommand": true,
"isTestCommand": false,
- "isBackground": true
+ "isBackground": true,
+ "options": {
+ "cwd": "${workspaceFolder}"
+ }
},
{
"label": "test blogs backend",
@@ -63,6 +66,28 @@
"isDefault": false
}
},
+ {
+ "label": "test api backend (npgsql)",
+ "type": "process",
+ "problemMatcher": "$msCompile",
+ "command": "dotnet",
+ "args": [
+ "test",
+ "Yavsc.Api.Test.csproj",
+ "-v",
+ "minimal"
+ ],
+ "options": {
+ "cwd": "src/Yavsc.Api.Test",
+ "env": {
+ "YAVSC_API_TEST_DB_PROVIDER": "npgsql"
+ }
+ },
+ "group": {
+ "kind": "test",
+ "isDefault": false
+ }
+ },
{
"label": "build-webapi",
"type": "process",
@@ -76,6 +101,21 @@
"kind": "build"
},
"isBackground": true
+ },
+ {
+ "label": "dotnet: build-postit-desktop",
+ "type": "process",
+ "isBuildCommand": true,
+ "isTestCommand": false,
+ "isBackground": true,
+ "command": "dotnet",
+ "args": ["build", "/property:GenerateFullPaths=true"],
+ "options": {
+ "cwd": "src/PostIt/PostIt.Desktop"
+ },
+ "group": {
+ "kind": "build"
+ }
}
]
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ec42b81bf..111c1491a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,116 @@
# Changelog
+## [1.0.8-rc14] - unstable
+
+### Added
+
+* [PostIt] Ajout d'un `BillingQueryDetailsPageViewModel` et de sa page associee pour afficher le detail d'une commande billing depuis l'historique.
+* [PostIt] Ajout d'un mode detail avec section metier (statut, date, description, motif, infos) et section technique repliable (code, client, provision, lieu, prestations).
+* [PostIt] Ajout d'un badge de statut enrichi (couleur + pictogramme) sur le detail d'une commande pour visualiser l'etat en un coup d'oeil.
+* [PostIt] Ajout d'un bloc d'actions rapide en tete du detail (`Retour`, `Ouvrir en edition`) pour eviter le scroll jusqu'au bas de page.
+* [PostIt] Ajout d'un style monospace sur les metadonnees techniques (code billing, client, provision, lieu, prestations) pour faciliter la lecture des identifiants et valeurs brutes.
+
+### Changed
+
+* [PostIt] Le bouton d'ouverture depuis la liste billing ouvre maintenant une page de detail dediee avant l'eventuelle edition.
+* [PostIt] Amelioration UX des pages billing: badges de statut colores, actions remontees en haut de page, et typographie monospace sur les metadonnees techniques.
+
+### Fixed
+
+* [Yavsc.Api] Correction d'un 500 sur le refresh du catalogue d'activites lorsque `Activity.Description` est `NULL` en base (nullabilite explicite + projection null-safe + gardes sur codes vides).
+* [Yavsc.Api] Correction des erreurs 400/500 sur les routes billing (`Rdv`, `Brush`, `MBrush`) en imposant `ClientId` depuis l'utilisateur authentifie et en ignorant les champs server-owned lors de la validation modele.
+* [Yavsc.Api] Correction du `PUT /api/v1/billing/Rdv/{id}`: mise a jour controlee de l'entite existante (et non remplacement brut du graphe JSON), ce qui supprime les `BadRequest` parasites.
+* [Yavsc.Api] Correction PostgreSQL `timestamptz` sur RDV: normalisation UTC de `EventDate` sur `POST/PUT /api/v1/billing/Rdv` pour eviter l'erreur `Cannot write DateTime with Kind=Local`.
+* [Yavsc.Api] Correction du flux FrontOffice accept/reject de query: sauvegarde avec contexte utilisateur et fallback d'injection pour `IBillingService` afin d'eviter les erreurs serveur en environnement de test.
+* [Yavsc.Blogs] Correction des `BadRequest` sur `POST/PUT /api/v1/blogspot` avec payload JSON (PostIt): les proprietes de navigation/serveur (`Author`, `Tags`, `Comments`, audit) ne bloquent plus la validation.
+* [Yavsc.Org] Correction du flux MVC de creation de commentaire: `SaveChangesAsync(userId)` est utilise pour renseigner les champs d'audit requis (`UserCreated`/`UserModified`).
+* [Yavsc.Api.Test] Stabilisation des fixtures de seed billing: remplissage des metadonnees d'audit (`UserCreated`, `UserModified`, dates) pour eviter les echecs SQLite `NOT NULL`.
+
+## [1.0.8-rc13] - unstable
+
+### Added
+
+* [PostIt] Integration d'un selecteur de lieu RDV base sur Mapsui (carte interactive dans le formulaire `Rdv`).
+* [PostIt] Ajout d'un marqueur de position et d'une action de recentrage sur la carte RDV.
+* [PostIt] Ajout d'un service de reverse geocoding pour suggerer une adresse a partir des coordonnees carte.
+* [PostIt] Cache et debounce des resolutions d'adresse RDV pour limiter les appels reseau et lisser l'UX.
+* [PostIt.Tests] Nouvelles non-regressions sur le panneau d'adresse suggeree RDV et le comportement de la carte.
+* [Yavsc.Abstract] Activation de `#nullable enable annotations` sur les fichiers legacy avec annotations nullable.
+* [Yavsc.Server] Activation de `#nullable enable annotations` sur les fichiers legacy avec annotations nullable.
+
+### Changed
+
+* [PostIt] Generalisation de la barre de statut d'action (severite explicite) sur pages principales, dialogues et formulaires billing.
+* [PostIt] Harmonisation des messages de statut utilisateur en francais.
+* [PostIt] Renforcement des gardes de navigation dans les flux de gestion des membres de cercle.
+* [PostIt] Le flux RDV conserve l'adresse saisie manuellement et propose l'adresse resolue comme suggestion explicite.
+* [PostIt] Le flux de geolocalisation RDV tolere les positions proches dans le cache de suggestion d'adresse.
+
+### Fixed
+
+* [PostIt.Desktop] Correction d'un crash au demarrage OIDC (`No authority specified`) via durcissement des valeurs par defaut de configuration d'authentification.
+* [PostIt] Correction de la persistance des settings: l'etat runtime de statut n'est plus serialize dans le JSON utilisateur.
+* [PostIt.Tests] Ajout d'un verrou de non-regression sur le premier chargement des settings.
+* [PostIt] Correction du binding de la date RDV: `DatePicker.SelectedDate` est aligne sur un proxy `DateTimeOffset?` (`EventDateSelection`).
+
+## [1.0.8-rc12] - unstable
+
+### Added
+
+* [PostIt] Nouveau helper d'image `ImageHelper` pour charger des bitmaps depuis les ressources et depuis le web.
+* [PostIt] Affichage de l'avatar XS dans la liste des performers d'activites, avec fallback visuel (initiale utilisateur).
+* [PostIt.Tests] Nouveaux tests autour des URLs avatar et de la source d'autorite.
+* [contrib] Ajout d'un `README.md` utilitaire pour les symboles/icones.
+
+### Changed
+
+* [PostIt] Les avatars ne sont plus relies en string sur `Image.Source`: ils sont telecharges et lies en `Bitmap`.
+* [Yavsc.Api.Client] `ActivityApiClient` accepte une base d'avatar dediee et construit les URLs avatar depuis l'autorite d'identification.
+* [PostIt] Les clients Activites/Billing utilisent maintenant `ApiUrl` en lecture dynamique: un changement via Parametres prend effet sans redemarrer l'application (apres sauvegarde et rafraichissement de la page).
+* [PostIt] Le header de `MainPage` n'utilise plus `ScrollViewer`; remplacement par une barre de commandes basee sur `WrapPanel`.
+* [PostIt] Alignement de la navigation blogs: renommage `PushMainPageAsync` -> `PushBlogsPageAsync` et ajustement de `HomePageViewModel`.
+
+### Fixed
+
+* [PostIt.Android] Correction d'un 404 sur la page Activites au premier lancement: la configuration embarquee pointait `ApiUrl` vers le host Blogs au lieu de l'API metier.
+* [PostIt] Correction du bouton Sauver de la page Parametres: binding vers `SaveCommand` pour persister correctement `ApiUrl`/`BlogsApiUrl`.
+
+## [1.0.8-rc11] - unstable
+
+### Added
+
+nothing
+
+### Changed
+
+* [Yavsc.Api.Test] Mise a jour de `Microsoft.EntityFrameworkCore.Sqlite` vers `10.0.11` afin de supprimer l'alerte NU1903 liee a `SQLitePCLRaw.lib.e_sqlite3` 2.1.11.
+* [Yavsc.Org] Nettoyage de la configuration NuGet pour le restore: suppression du fichier local `Directory.Packages.props` au profit du fichier racine centralise.
+* [Yavsc.Org] Suppression de references de packages redondantes dans le projet, sans impact fonctionnel attendu.
+
+### Fixed
+
+* [Yavsc.Api.Test] Le restore n'emet plus le warning de vulnerabilite `NU1903` sur `SQLitePCLRaw.lib.e_sqlite3`.
+* [Yavsc.Org] Suppression d'une vulnerabilite de severite elevee sur AutoMapper apres publication et consommation de la nouvelle version candidate de `HigginsSoft.IdentityServer8`.
+
+## [1.0.8-rc10] - unstable
+
+### Added
+
+* [PostIt] Une page d'historique des commandes billing permet maintenant d'ouvrir une commande existante.
+* [PostIt] Une vue "Demandes en cours" en lecture seule est disponible pour le performer, filtrée sur les statuts actifs (Inserted, Accepted, InProgress).
+* [Yavsc.Org] Nouvelles entités `Country` et `PerformerCodeInputValidation` pour piloter la validation du code entreprise performer par pays.
+
+### Changed
+
+* [PostIt] La page détail billing se préremplit depuis une commande existante (Rdv, Brush, MBrush) et passe en mode mise à jour.
+* [Yavsc.Org] Le formulaire `Manage/SetActivity` inclut désormais le pays d'exercice (`fr`, `en`, `pt`) et applique la regex associée au champ `SIREN`.
+* [Yavsc.Org] La vérification externe du numéro d'entreprise est conservée uniquement pour le pays `fr`.
+
+### Fixed
+
+* [PostIt] Le flux historique n'est plus limité à une simple liste: l'action d'ouverture charge la commande cible puis navigue vers la page détail.
+* [Yavsc.Org] Le champ `SIREN` n'est plus validé avec une règle unique indépendante du pays d'exercice.
+
## [1.0.8-rc9] - unstable
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fd408c5c1..8aa0567d3 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -49,6 +49,19 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt.
+## Onboarding assiste par agents IA
+
+Pour accelerer la prise en main du depot avec Copilot/Plan/Explore :
+
+- Parcours pas-a-pas : [doc/onboarding-agents.md](./doc/onboarding-agents.md)
+- Playbook d'usage des agents : [doc/agent-playbook.md](./doc/agent-playbook.md)
+- Matrice intentions -> agent -> preuves : [doc/agent-intent-matrix.md](./doc/agent-intent-matrix.md)
+
+Regle minimale en contribution assistee par agent :
+- expliciter l'impact architecture,
+- justifier le niveau de tests execute,
+- documenter les risques residuels.
+
## Le CHANGELOG.md
Le `CHANGELOG.md` est un document de changement de version
@@ -61,8 +74,8 @@ et ce projet adhère au [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
À noter : la **parité du numéro de patch** porte une signification de canal :
-- **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
-- **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
+- **patch pair** (ex. `1.0.0`, `1.0.2`) → **preview**
+- **patch impair** (ex. `1.0.1`, `1.0.3`) → **stable**
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
Cette convention est partagée avec le dépôt
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 7505c8512..f1be93f91 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -1,15 +1,31 @@
true
+ 8.1.0-pazofrc007
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -17,14 +33,24 @@
-
+
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/Makefile b/Makefile
index 08f0461d2..d99fe5e5e 100644
--- a/Makefile
+++ b/Makefile
@@ -48,70 +48,4 @@ docker-build:
docker-run:
docker run -d -p 5000:5000 --name yavsc yavsc
-# Crée une branche release/ depuis main, met à jour les
-# `` des .csproj via dotnet-gitversion, et la
-# pousse sur origin.
-#
-# Usage : make release V=1.0.7-rc1
-#
-# Pré-requis : être sur main, working tree clean. La cible
-# vérifie les deux et refuse sinon — elle ne fait JAMAIS
-# de checkout automatique, c'est à l'opérateur de s'être
-# positionné sur la bonne branche au préalable (sinon le
-# bump pourrait partir sur une branche tierce par accident).
-#
-# Notes :
-# - Le nom de branche vient de l'argument V (ex: 1.0.7-rc1
-# donne release/1.0.7-rc1). C'est une étiquette d'intention,
-# pas la version assembly.
-# - La version dans les .csproj vient de GitVersion qui la
-# calcule depuis l'historique git (tag le plus proche +
-# nombre de commits). C'est la version assembly réelle.
-# - L'ordre (fetch → branche → bump → push) garantit qu'on
-# part d'un main synchro et qu'on ne pollue pas main avec
-# le bump (qui vit sur la branche release).
-# - Fail-fast si la branche existe déjà en local ou sur origin.
-release:
- @if [ -z "$(V)" ]; then \
- echo "Usage: make release V="; \
- echo " V : version semver (ex. 1.0.7-rc1) — sert à nommer la branche."; \
- exit 1; \
- fi
- @if [ -n "$$(git status --porcelain)" ]; then \
- echo "Working tree sale, refus de créer une branche release."; \
- git status --short; \
- exit 1; \
- fi
- @BRANCH="release/$(V)"; \
- if git show-ref --verify --quiet "refs/heads/$$BRANCH"; then \
- echo "La branche $$BRANCH existe déjà en local."; \
- echo " Pour la supprimer : git branch -D $$BRANCH"; \
- exit 1; \
- fi; \
- if git ls-remote --exit-code --heads origin "$$BRANCH" >/dev/null 2>&1; then \
- echo "La branche $$BRANCH existe déjà sur origin."; \
- exit 1; \
- fi; \
- echo "==> Fetch + vérification synchro main"; \
- git fetch origin main; \
- if ! git merge-base --is-ancestor origin/main HEAD; then \
- echo "main a avancé plus loin que HEAD. Fais :"; \
- echo " git pull --ff-only origin main"; \
- exit 1; \
- fi; \
- echo "==> Création de $$BRANCH depuis main"; \
- git checkout -b "$$BRANCH"; \
- echo "==> dotnet-gitversion /updateprojectfiles"; \
- dotnet-gitversion /updateprojectfiles; \
- echo "==> Commit du bump"; \
- git add .; \
- if git diff --cached --quiet; then \
- echo "Pas de changements à committer (gitversion n'a produit aucune diff)."; \
- else \
- git commit -m "chore(release): bump version via gitversion for $(V)"; \
- fi; \
- echo "==> Push de $$BRANCH sur origin"; \
- git push -u origin "$$BRANCH"; \
- echo "==> Terminé. Branche $$BRANCH live sur origin."
-
-.PHONY: test release
+.PHONY: test install docker-image docker-build docker-run
diff --git a/README.md b/README.md
index b132f3f20..549348f4a 100644
--- a/README.md
+++ b/README.md
@@ -28,6 +28,10 @@ sous [`doc/`](./doc/). Voir l'[index de la documentation](./doc/README.md)
pour le sommaire complet. La racine de l'architecture est
[Architecture.md](./doc/Architecture.md).
+Pour une prise en main guidee avec agents IA:
+- parcours onboarding: [doc/onboarding-agents.md](./doc/onboarding-agents.md)
+- playbook d'usage: [doc/agent-playbook.md](./doc/agent-playbook.md)
+
# Construction et déploiement
diff --git a/ROADMAP.md b/ROADMAP.md
index 4c00e629c..364b98bda 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -68,7 +68,7 @@ Trois principes non négociables traversent tous les jalons :
>
> Chaque jalon a un **critère de sortie** vérifiable.
-### Jalon 0 — Fondations techniques *(en cours)*
+### Jalon 0 — Fondations techniques
> Cible : pouvoir parler du domaine sans se battre avec le runtime.
@@ -81,7 +81,7 @@ Trois principes non négociables traversent tous les jalons :
---
-### Jalon 1 — Prestation signée de bout en bout
+### Jalon 1 — Prestation signée de bout en bout *(en cours)*
> Cible : un projet client/fournisseur aboutit à un **devis signé par les deux parties**, traçable, avec notifications.
diff --git a/contrib/.env-sample b/contrib/.env-sample
new file mode 100644
index 000000000..fb01ede43
--- /dev/null
+++ b/contrib/.env-sample
@@ -0,0 +1,24 @@
+# parametres de déploiement au Makefile
+
+POSTGRES_HOST=localhost
+POSTGRES_PORT=5432
+POSTGRES_DB=yavsc
+POSTGRES_USER=yavsc
+POSTGRES_PASSWORD=
+
+HTTP_HOST=localhost
+
+Org_PORT=83
+Blogs_PORT=85
+Api_PORT=87
+
+PostIt_CLIENT_ID=postit
+
+ASPNETCORE_Smtp__Host="mercure.pschneider.fr"
+ASPNETCORE_Smtp__Port=465
+ASPNETCORE_Smtp__SenderName="Paul Schneider"
+ASPNETCORE_Smtp__SenderEmail="paul@pschneider.fr"
+ASPNETCORE_Smtp__UserName="paul"
+ASPNETCORE_Smtp__Password=""
+
+DESTDIR=/srv/www/yavsc
diff --git a/contrib/Makefile b/contrib/Makefile
index 151045db0..4e4ef4df6 100644
--- a/contrib/Makefile
+++ b/contrib/Makefile
@@ -1,4 +1,4 @@
-APP_PROJECT_NAMES=Org Blogs
+APP_PROJECT_NAMES=Org Blogs Api
SLNDIR=..
include $(SLNDIR)/.env
@@ -9,9 +9,11 @@ generated/:
generated/yavscOrg.service:
generated/yavscBlogs.service:
+generated/yavscApi.service:
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@cat template.service | APP_NAME="$*" \
+ DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$*_$(HTTP_PORT)" \
BASEAPPDIR="$(BASEAPPDIR)" \
@@ -33,11 +35,12 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@
-copy-services: copy-service-Org copy-service-Blogs
+copy-services: copy-service-Org copy-service-Blogs copy-service-Api
copy-service-Org: /etc/systemd/system/yavscOrg.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service
+copy-service-Api: /etc/systemd/system/yavscApi.service
-copy-binaries: build_publish_Org build_publish_Blogs stop-services
+copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
@for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \
@@ -60,6 +63,8 @@ copy-binaries: build_publish_Org build_publish_Blogs stop-services
build_publish_%: clean_publish_dir_%
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
+build_publish: build_publish_Org build_publish_Blogs build_publish_Api
+
clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
@@ -84,12 +89,21 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
+$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
showConfig:
@echo CONFIGURATION: $(CONFIGURATION)
@echo BASEAPPDIR: $(BASEAPPDIR)
+showApiLogs:
+ @sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
+
+showOrgLogs:
+ @sudo journalctl -u yavscOrg.service -S "2 min ago" | tee yavscOrg.log
+
+showBlogsLogs:
+ @sudo journalctl -u yavscBlogs.service -S "2 min ago" | tee yavscBlogs.log
+
clean:
@rm -rf generated
-.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean
diff --git a/doc/README.md b/doc/README.md
index 912a2e562..fb9bea943 100644
--- a/doc/README.md
+++ b/doc/README.md
@@ -18,6 +18,9 @@ La racine de l'architecture est [Architecture.md](Architecture.md).
| [architecture/postit.md](architecture/postit.md) | PostIt — topologie des projets, ViewLocator custo, navigation, DI, conventions de binding |
| [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md) | Découpage des projets .NET (Abstract, Server, Org, Api, Blogs, Web, Org.Tests) |
| [testing.md](testing.md) | Stratégie de test : conventions des dossiers, EF Core in-memory, auth stubs, scaffold partagé |
+| [onboarding-agents.md](onboarding-agents.md) | Parcours pas-à-pas pour prise en main agents IA + architecture + tests |
+| [agent-playbook.md](agent-playbook.md) | Playbook d'usage de Copilot, Plan, Explore avec scénarios et anti-patterns |
+| [agent-intent-matrix.md](agent-intent-matrix.md) | Matrice intentions développeur -> agent -> preuves attendues |
## Roadmap & design exploration
diff --git a/doc/agent-intent-matrix.md b/doc/agent-intent-matrix.md
new file mode 100644
index 000000000..975cd7b79
--- /dev/null
+++ b/doc/agent-intent-matrix.md
@@ -0,0 +1,20 @@
+# Matrice intentions -> agent -> preuves
+
+Cette matrice aide a choisir rapidement l'agent adapte et a exiger
+une sortie verifiable.
+
+| Intention developpeur | Agent principal | Entrees minimales | Sortie minimale attendue | Verification |
+|---|---|---|---|---|
+| Comprendre un BC avant changement | Explore | BC cible, profondeur, contrainte de perimetre | Composants, points d'entree, tests relies, risques | Lire les fichiers cites + confirmer tests proposes |
+| Decomposer une tache transverse | Plan | Objectif, contraintes, definition of done | Etapes ordonnees, dependances, criteres de verif | Verifier que chaque etape a une preuve observable |
+| Implementer une modif locale | Copilot | Fichier cible, comportement attendu, conventions | Patch minimal, justification courte | Build/test du projet impacte |
+| Ajouter un test smoke | Copilot (+Explore) | Route/endpoint, projet de test cible | Test + commande cible | Execution test cible |
+| Corriger une regression | Plan + Copilot | Symptome, zone suspecte, test attendu | Fix + test NonRegression | Test rouge avant, vert apres |
+| Diagnostiquer flux PostIt/OIDC | Explore + Plan | Flux, symptome, plateforme | Carte du flux + hypotheses testables | Verification manuelle + tests existants |
+
+## Regles d'arbitrage
+
+- Si l'intention est "comprendre": commencer par Explore.
+- Si l'intention est "orchestrer": commencer par Plan.
+- Si l'intention est "produire": utiliser Copilot apres cadrage.
+- Si une sortie n'inclut pas de preuve, elle est incomplete.
diff --git a/doc/agent-playbook.md b/doc/agent-playbook.md
new file mode 100644
index 000000000..ecc14f1d2
--- /dev/null
+++ b/doc/agent-playbook.md
@@ -0,0 +1,101 @@
+# Playbook d'usage des agents IA (Yavsc)
+
+Ce playbook normalise l'usage de Copilot, Plan et Explore dans le depot.
+Il privilegie des sorties verifiables: fichiers, commandes tests, risques.
+
+## Quand utiliser quel agent
+
+- Plan: quand la tache est ambigue, transverse ou risquee.
+- Explore: quand il faut cartographier rapidement des zones du code.
+- Copilot: quand les specifications sont claires et localisees.
+
+## Prompt type (base)
+
+Utiliser ce squelette avant toute tache non triviale:
+
+```text
+Contexte:
+Objectif:
+Contraintes:
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Activity/ActivitiesPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/ActivitiesPage.axaml.cs
new file mode 100644
index 000000000..955170075
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/ActivitiesPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PostIt.Views;
+
+public partial class ActivitiesPage : ContentPage
+{
+ public ActivitiesPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml
new file mode 100644
index 000000000..52b255129
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml.cs
new file mode 100644
index 000000000..1ca28f04b
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/BillingQueriesPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PostIt.Views;
+
+public partial class BillingQueriesPage : ContentPage
+{
+ public BillingQueriesPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
\ No newline at end of file
diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml
new file mode 100644
index 000000000..82231ad83
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml
@@ -0,0 +1,131 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml.cs
new file mode 100644
index 000000000..ed5336ee0
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/BillingQueryDetailsPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PostIt.Views;
+
+public partial class BillingQueryDetailsPage : ContentPage
+{
+ public BillingQueryDetailsPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml b/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml
new file mode 100644
index 000000000..2565092e5
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml.cs
new file mode 100644
index 000000000..1d19715b8
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/CommandFormsPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PostIt.Views;
+
+public partial class CommandFormsPage : ContentPage
+{
+ public CommandFormsPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
\ No newline at end of file
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml
new file mode 100644
index 000000000..7b25ab226
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml.cs
new file mode 100644
index 000000000..1c58efa10
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/EstimateEditionPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PostIt.Views;
+
+public partial class EstimateEditionPage : ContentPage
+{
+ public EstimateEditionPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml
new file mode 100644
index 000000000..173fe903b
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml.cs b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml.cs
new file mode 100644
index 000000000..ce2e9ad40
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Activity/ProviderOngoingRequestsPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PostIt.Views;
+
+public partial class ProviderOngoingRequestsPage : ContentPage
+{
+ public ProviderOngoingRequestsPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
new file mode 100644
index 000000000..d4124a651
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
new file mode 100644
index 000000000..09ccc79f4
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Blogs/BlogsPage.axaml.cs
@@ -0,0 +1,71 @@
+using System;
+using System.IO;
+using System.Linq;
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using Avalonia.Controls.Primitives;
+
+namespace PostIt.Views.Blogs;
+
+public partial class BlogsPage : ContentPage
+{
+ public BlogsPage()
+ {
+ InitializeComponent();
+ }
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+ if (DataContext is ViewModels.BlogsViewModel vm)
+ {
+ if (!vm.IsLoaded)
+ {
+ vm.RefreshAsync().Wait();
+ }
+ }
+ }
+
+ private async void AddAttachment_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is null || DataContext is not ViewModels.BlogsViewModel vm)
+ return;
+
+ var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Choisir des fichiers à joindre au billet",
+ AllowMultiple = true,
+ });
+
+ if (files.Count == 0)
+ return;
+
+ var uploads = new System.Collections.Generic.List(files.Count);
+ foreach (var file in files)
+ {
+ await using var stream = await file.OpenReadAsync();
+ using var memory = new MemoryStream();
+ await stream.CopyToAsync(memory);
+ uploads.Add(new Yavsc.Api.Client.BlogUploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
+ }
+
+ vm.DraftAttachments.Clear();
+ foreach (var upload in uploads)
+ vm.DraftAttachments.Add(upload);
+ }
+
+ private static string GetMimeType(string fileName)
+ {
+ var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
+ return ext switch
+ {
+ ".png" => "image/png",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".webp" => "image/webp",
+ ".gif" => "image/gif",
+ ".pdf" => "application/pdf",
+ _ => "application/octet-stream"
+ };
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Commands/BrushPage.axaml b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml
new file mode 100644
index 000000000..d01ea4a1b
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml
@@ -0,0 +1,106 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Commands/BrushPage.axaml.cs b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml.cs
new file mode 100644
index 000000000..c028472ff
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml.cs
@@ -0,0 +1,13 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PostIt.Views.Commands;
+
+public partial class BrushPage : ContentPage
+{
+ public BrushPage()
+ {
+ InitializeComponent();
+ }
+}
diff --git a/src/PostIt/PostIt/Views/Commands/RdvPage.axaml b/src/PostIt/PostIt/Views/Commands/RdvPage.axaml
new file mode 100644
index 000000000..e521a5a6b
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Commands/RdvPage.axaml
@@ -0,0 +1,125 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Commands/RdvPage.axaml.cs b/src/PostIt/PostIt/Views/Commands/RdvPage.axaml.cs
new file mode 100644
index 000000000..749d15cdd
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Commands/RdvPage.axaml.cs
@@ -0,0 +1,236 @@
+using System;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+using Mapsui;
+using Mapsui.Layers;
+using Mapsui.Projections;
+using Mapsui.Styles;
+using Mapsui.Tiling;
+using Mapsui.UI.Avalonia;
+using PostIt.Services;
+using PostIt.ViewModels.Commands;
+
+namespace PostIt.Views.Commands;
+
+public partial class RdvPage : ContentPage
+{
+ private const double DefaultLatitude = 48.8566;
+ private const double DefaultLongitude = 2.3522;
+ private const int DefaultZoomLevel = 4;
+ private const int SelectedZoomLevel = 13;
+ internal const double ReverseGeocodingCacheToleranceDegrees = 0.0001;
+ private static readonly TimeSpan ReverseGeocodingDebounce = TimeSpan.FromMilliseconds(350);
+
+ private MapControl? _locationMap;
+ private MemoryLayer? _selectionLayer;
+ private RdvViewModel? _currentViewModel;
+ private readonly IReverseGeocodingService _reverseGeocodingService;
+ private CancellationTokenSource? _reverseGeocodeCts;
+ private double? _lastResolvedLatitude;
+ private double? _lastResolvedLongitude;
+ private string? _lastResolvedAddress;
+
+ public RdvPage()
+ : this(null)
+ {
+ }
+
+ public RdvPage(IReverseGeocodingService? reverseGeocodingService)
+ {
+ _reverseGeocodingService = reverseGeocodingService ?? new NominatimReverseGeocodingService();
+ InitializeComponent();
+ InitializeMap();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+
+ private void InitializeMap()
+ {
+ _locationMap = this.FindControl("LocationMap");
+ if (_locationMap is null)
+ return;
+
+ var map = new Map();
+ map.Layers.Add(OpenStreetMap.CreateTileLayer());
+ _selectionLayer = CreateSelectionLayer();
+ map.Layers.Add(_selectionLayer);
+ _locationMap.Map = map;
+ _locationMap.MapTapped += OnMapTapped;
+
+ DataContextChanged += OnDataContextChanged;
+ AttachViewModel(DataContext as RdvViewModel);
+ }
+
+ private void OnDataContextChanged(object? sender, System.EventArgs e)
+ {
+ AttachViewModel(DataContext as RdvViewModel);
+ CenterFromViewModel();
+ }
+
+ private async void OnMapTapped(object? sender, MapEventArgs e)
+ {
+ if (DataContext is not RdvViewModel vm)
+ return;
+
+ var (longitude, latitude) = SphericalMercator.ToLonLat(e.WorldPosition.X, e.WorldPosition.Y);
+ vm.ApplyLocationFromMap(latitude, longitude);
+ UpdateMarkerFromViewModel();
+ CenterMap(latitude, longitude, zoomLevel: SelectedZoomLevel);
+ await TryResolveAddressAsync(vm, latitude, longitude);
+ }
+
+ private async void OnCenterCurrentLocationClicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ if (DataContext is not RdvViewModel vm || !vm.CanUseCurrentLocation)
+ return;
+
+ await vm.UseCurrentLocationCommand.ExecuteAsync(null);
+ UpdateMarkerFromViewModel();
+ CenterFromViewModel();
+
+ if (vm.Latitude.HasValue && vm.Longitude.HasValue)
+ await TryResolveAddressAsync(vm, vm.Latitude.Value, vm.Longitude.Value);
+ }
+
+ private void AttachViewModel(RdvViewModel? vm)
+ {
+ if (_currentViewModel is not null)
+ _currentViewModel.PropertyChanged -= OnViewModelPropertyChanged;
+
+ _currentViewModel = vm;
+
+ if (_currentViewModel is not null)
+ _currentViewModel.PropertyChanged += OnViewModelPropertyChanged;
+
+ UpdateMarkerFromViewModel();
+ }
+
+ private void OnViewModelPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName == nameof(RdvViewModel.Latitude)
+ || e.PropertyName == nameof(RdvViewModel.Longitude))
+ {
+ UpdateMarkerFromViewModel();
+ }
+ }
+
+ private void CenterFromViewModel()
+ {
+ if (DataContext is not RdvViewModel vm)
+ {
+ CenterMap(DefaultLatitude, DefaultLongitude, zoomLevel: DefaultZoomLevel);
+ return;
+ }
+
+ if (vm.Latitude.HasValue && vm.Longitude.HasValue)
+ {
+ CenterMap(vm.Latitude.Value, vm.Longitude.Value, zoomLevel: SelectedZoomLevel);
+ return;
+ }
+
+ CenterMap(DefaultLatitude, DefaultLongitude, zoomLevel: DefaultZoomLevel);
+ }
+
+ private void CenterMap(double latitude, double longitude, int zoomLevel)
+ {
+ if (_locationMap?.Map is null)
+ return;
+
+ var (x, y) = SphericalMercator.FromLonLat(longitude, latitude);
+ _locationMap.Map.Navigator.CenterOn(x, y);
+ _locationMap.Map.Navigator.ZoomToLevel(zoomLevel);
+ }
+
+ private void UpdateMarkerFromViewModel()
+ {
+ if (_selectionLayer is null)
+ return;
+
+ if (DataContext is not RdvViewModel vm
+ || !vm.Latitude.HasValue
+ || !vm.Longitude.HasValue)
+ {
+ _selectionLayer.Features = Enumerable.Empty();
+ _locationMap?.RefreshData(ChangeType.Discrete);
+ return;
+ }
+
+ var (x, y) = SphericalMercator.FromLonLat(vm.Longitude.Value, vm.Latitude.Value);
+ _selectionLayer.Features = new IFeature[] { new PointFeature(x, y) };
+ _locationMap?.RefreshData(ChangeType.Discrete);
+ }
+
+ private static MemoryLayer CreateSelectionLayer()
+ {
+ return new MemoryLayer("selected-location")
+ {
+ Style = new SymbolStyle
+ {
+ SymbolType = SymbolType.Ellipse,
+ Fill = new Brush(Color.Crimson),
+ Outline = new Pen(Color.White, 2),
+ SymbolScale = 0.9,
+ },
+ Features = Enumerable.Empty(),
+ };
+ }
+
+ private async Task TryResolveAddressAsync(RdvViewModel vm, double latitude, double longitude)
+ {
+ if (_lastResolvedLatitude.HasValue
+ && _lastResolvedLongitude.HasValue
+ && AreCoordinatesClose(_lastResolvedLatitude.Value, _lastResolvedLongitude.Value, latitude, longitude)
+ && !string.IsNullOrWhiteSpace(_lastResolvedAddress))
+ {
+ vm.ApplyResolvedAddress(_lastResolvedAddress);
+ return;
+ }
+
+ _reverseGeocodeCts?.Cancel();
+ _reverseGeocodeCts?.Dispose();
+ _reverseGeocodeCts = new CancellationTokenSource();
+ var cancellationToken = _reverseGeocodeCts.Token;
+
+ try
+ {
+ vm.NotifyReverseGeocodingStarted();
+ await Task.Delay(ReverseGeocodingDebounce, cancellationToken).ConfigureAwait(true);
+
+ var resolved = await _reverseGeocodingService
+ .TryResolveAddressAsync(latitude, longitude, cancellationToken)
+ .ConfigureAwait(true);
+
+ if (!string.IsNullOrWhiteSpace(resolved))
+ {
+ _lastResolvedLatitude = latitude;
+ _lastResolvedLongitude = longitude;
+ _lastResolvedAddress = resolved;
+ vm.ApplyResolvedAddress(resolved);
+ return;
+ }
+
+ vm.NotifyReverseGeocodingUnavailable();
+ }
+ catch (OperationCanceledException)
+ {
+ vm.IsResolvingAddress = false;
+ }
+ }
+
+ internal static bool AreCoordinatesClose(
+ double latitudeA,
+ double longitudeA,
+ double latitudeB,
+ double longitudeB)
+ {
+ return Math.Abs(latitudeA - latitudeB) <= ReverseGeocodingCacheToleranceDegrees
+ && Math.Abs(longitudeA - longitudeB) <= ReverseGeocodingCacheToleranceDegrees;
+ }
+}
diff --git a/src/PostIt/PostIt/Views/HomePage.axaml b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
similarity index 70%
rename from src/PostIt/PostIt/Views/HomePage.axaml
rename to src/PostIt/PostIt/Views/Layout/HomePage.axaml
index 16e66cc00..813acc430 100644
--- a/src/PostIt/PostIt/Views/HomePage.axaml
+++ b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
@@ -18,5 +18,13 @@
Command="{Binding OpenBlogs}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
+
+
diff --git a/src/PostIt/PostIt/Views/HomePage.axaml.cs b/src/PostIt/PostIt/Views/Layout/HomePage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/HomePage.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/HomePage.axaml.cs
diff --git a/src/PostIt/PostIt/Views/MainView.axaml b/src/PostIt/PostIt/Views/Layout/MainView.axaml
similarity index 92%
rename from src/PostIt/PostIt/Views/MainView.axaml
rename to src/PostIt/PostIt/Views/Layout/MainView.axaml
index 643c0f208..92f53eb54 100644
--- a/src/PostIt/PostIt/Views/MainView.axaml
+++ b/src/PostIt/PostIt/Views/Layout/MainView.axaml
@@ -6,11 +6,11 @@
xmlns:views="using:PostIt.Views"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.MainView"
- x:DataType="vm:MainViewModel">
+ x:DataType="vm:HomePageViewModel">
-
+
diff --git a/src/PostIt/PostIt/Views/MainView.axaml.cs b/src/PostIt/PostIt/Views/Layout/MainView.axaml.cs
similarity index 87%
rename from src/PostIt/PostIt/Views/MainView.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/MainView.axaml.cs
index 654b90e2d..22bf537c6 100644
--- a/src/PostIt/PostIt/Views/MainView.axaml.cs
+++ b/src/PostIt/PostIt/Views/Layout/MainView.axaml.cs
@@ -13,7 +13,7 @@ public partial class MainView : UserControl
protected override void OnDataContextChanged(EventArgs e)
{
base.OnDataContextChanged(e);
- if (DataContext is ViewModels.MainViewModel vm)
+ if (DataContext is ViewModels.BlogsViewModel vm)
{
if (!vm.IsLoaded)
{
diff --git a/src/PostIt/PostIt/Views/MainWindow.axaml b/src/PostIt/PostIt/Views/Layout/MainWindow.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/MainWindow.axaml
rename to src/PostIt/PostIt/Views/Layout/MainWindow.axaml
diff --git a/src/PostIt/PostIt/Views/MainWindow.axaml.cs b/src/PostIt/PostIt/Views/Layout/MainWindow.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/MainWindow.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/MainWindow.axaml.cs
diff --git a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml b/src/PostIt/PostIt/Views/Layout/SessionStatusBanner.axaml
similarity index 100%
rename from src/PostIt/PostIt/Views/SessionStatusBanner.axaml
rename to src/PostIt/PostIt/Views/Layout/SessionStatusBanner.axaml
diff --git a/src/PostIt/PostIt/Views/SessionStatusBanner.axaml.cs b/src/PostIt/PostIt/Views/Layout/SessionStatusBanner.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/SessionStatusBanner.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/SessionStatusBanner.axaml.cs
diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml b/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml
similarity index 63%
rename from src/PostIt/PostIt/Views/SettingsPage.axaml
rename to src/PostIt/PostIt/Views/Layout/SettingsPage.axaml
index 96f563a7a..2973679b5 100644
--- a/src/PostIt/PostIt/Views/SettingsPage.axaml
+++ b/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml
@@ -2,6 +2,7 @@
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="cl:avalonia.Controls"
+ xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.SettingsPage"
xmlns:vm="using:PostIt.ViewModels"
x:DataType="vm:Settings"
@@ -39,25 +40,34 @@
-
+
+ Text="{Binding ApiUrl, Mode=TwoWay}"/>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml.cs b/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml.cs
new file mode 100644
index 000000000..b50245919
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Layout/SettingsPage.axaml.cs
@@ -0,0 +1,116 @@
+
+
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Avalonia.Controls;
+using Avalonia.Platform.Storage;
+using Microsoft.Extensions.DependencyInjection;
+using PostIt.Services;
+using PostIt.ViewModels;
+
+namespace PostIt.Views;
+public partial class SettingsPage: ContentPage
+{
+ private const int MaxAvatarSizeMegabytes = 2;
+ private const string AcceptedAvatarFormats = "PNG, JPG/JPEG, WEBP, GIF";
+
+ private int _avatarStatusVersion;
+
+ public SettingsPage()
+ {
+ InitializeComponent();
+ }
+
+ private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var statusVersion = Interlocked.Increment(ref _avatarStatusVersion);
+ ChooseAvatarButton.IsEnabled = false;
+ SetAvatarStatus("Selection d'un fichier avatar...", StatusSeverity.Info);
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is null)
+ {
+ SetAvatarStatus("Impossible d'acceder a la fenetre active.", StatusSeverity.Error);
+ ChooseAvatarButton.IsEnabled = true;
+ return;
+ }
+
+ var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = "Choisir un avatar",
+ AllowMultiple = false,
+ FileTypeFilter = new[]
+ {
+ new FilePickerFileType("Images")
+ {
+ Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.webp", "*.gif" }
+ }
+ }
+ });
+
+ var file = files.FirstOrDefault();
+ if (file is null)
+ {
+ SetAvatarStatus("Upload annule.", StatusSeverity.Warning);
+ ChooseAvatarButton.IsEnabled = true;
+ return;
+ }
+
+ try
+ {
+ SetAvatarStatus("Upload avatar en cours...", StatusSeverity.Info);
+
+ await using var stream = await file.OpenReadAsync();
+ var api = ((App)App.Current!).ServiceProvider!.GetRequiredService();
+ var message = await api.SetAvatarAsync(stream, file.Name, GetMimeType(file.Name));
+ SetAvatarStatus(string.IsNullOrWhiteSpace(message)
+ ? "Avatar mis a jour."
+ : message,
+ StatusSeverity.Info);
+
+ _ = ClearSuccessStatusLaterAsync(statusVersion);
+ }
+ catch (Exception ex)
+ {
+ SetAvatarStatus($"Echec upload avatar: {ex.Message}", StatusSeverity.Error);
+ Console.Error.WriteLine($"🩎 Avatar upload failed: {ex.Message}");
+ }
+ finally
+ {
+ ChooseAvatarButton.IsEnabled = true;
+ }
+ }
+
+ private async Task ClearSuccessStatusLaterAsync(int statusVersion)
+ {
+ await Task.Delay(TimeSpan.FromSeconds(5)).ConfigureAwait(true);
+ if (statusVersion != _avatarStatusVersion)
+ return;
+
+ SetAvatarStatus($"Avatar pret. Formats supportes: {AcceptedAvatarFormats}. Taille max: {MaxAvatarSizeMegabytes} MB.", StatusSeverity.Info);
+ }
+
+ private void SetAvatarStatus(string message, StatusSeverity severity)
+ {
+ if (DataContext is Settings settings)
+ {
+ settings.SetActionStatus(message, severity);
+ }
+ }
+
+ private static string GetMimeType(string fileName)
+ {
+ var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
+ return ext switch
+ {
+ ".png" => "image/png",
+ ".jpg" or ".jpeg" => "image/jpeg",
+ ".webp" => "image/webp",
+ ".gif" => "image/gif",
+ _ => "application/octet-stream"
+ };
+ }
+}
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml
deleted file mode 100644
index 0d2f5411f..000000000
--- a/src/PostIt/PostIt/Views/MainPage.axaml
+++ /dev/null
@@ -1,112 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml.cs b/src/PostIt/PostIt/Views/MainPage.axaml.cs
deleted file mode 100644
index 97e3f9227..000000000
--- a/src/PostIt/PostIt/Views/MainPage.axaml.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using System;
-using Avalonia.Controls;
-using Avalonia.Controls.Primitives;
-
-namespace PostIt.Views;
-
-public partial class MainPage : ContentPage
-{
- public MainPage()
- {
- InitializeComponent();
- }
-
- protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
- {
- base.OnApplyTemplate(e);
- if (DataContext is ViewModels.MainViewModel vm)
- {
- if (!vm.IsLoaded)
- {
- vm.RefreshAsync().Wait();
- }
- }
- }
-}
diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml.cs b/src/PostIt/PostIt/Views/SettingsPage.axaml.cs
deleted file mode 100644
index 14916bb30..000000000
--- a/src/PostIt/PostIt/Views/SettingsPage.axaml.cs
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-using Avalonia.Controls;
-
-namespace PostIt.Views;
-public partial class SettingsPage: ContentPage
-{
- public SettingsPage()
- {
- InitializeComponent();
- }
-}
\ No newline at end of file
diff --git a/src/PostIt/PostIt/Views/SignaturePage.axaml b/src/PostIt/PostIt/Views/Signature/SignaturePage.axaml
similarity index 94%
rename from src/PostIt/PostIt/Views/SignaturePage.axaml
rename to src/PostIt/PostIt/Views/Signature/SignaturePage.axaml
index d868f2a48..021208b54 100644
--- a/src/PostIt/PostIt/Views/SignaturePage.axaml
+++ b/src/PostIt/PostIt/Views/Signature/SignaturePage.axaml
@@ -3,6 +3,7 @@
xmlns:d="http://github.com/avaloniaui/avalonia"
xmlns:vm="using:PostIt.ViewModels"
xmlns:controls="using:PostIt.Controls"
+ xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.SignaturePage"
x:DataType="vm:SignaturePageViewModel"
HorizontalAlignment="Stretch"
@@ -59,10 +60,8 @@
-
+
0)
+ {
+ var poly = new Polyline
+ {
+ Stroke = StrokeBrush,
+ StrokeThickness = StrokeThickness,
+ StrokeLineCap = PenLineCap.Round,
+ StrokeJoin = PenLineJoin.Round,
+ };
+
+ var pts = new List(pending.Count / 2);
+ for (int p = 0; p < pending.Count; p += 2)
+ {
+ int nx = pending[p];
+ int ny = pending[p + 1];
+ pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
+ }
+
+ poly.Points = pts;
+ InkLayer.Children.Add(poly);
+ }
}
}
diff --git a/src/PostIt/PostIt/postit-settings sample.json b/src/PostIt/PostIt/postit-settings sample.json
deleted file mode 100644
index 6d8da8faf..000000000
--- a/src/PostIt/PostIt/postit-settings sample.json
+++ /dev/null
@@ -1,15 +0,0 @@
-{
- "Authentication": {
- "ClientId": "postit",
- "Authority": "https://yavsc.pschneider.fr"
- },
- "RedirectUri": "postit://callback",
- "DarkMode": false,
- "ApiUrl": "https://blogs.pschneider.fr/api/v1/",
- "Scopes": [
- "openid",
- "profile",
- "offline_access",
- "blogs"
- ]
-}
diff --git a/src/PostIt/PostIt/postit-settings-sample.json b/src/PostIt/PostIt/postit-settings-sample.json
new file mode 100644
index 000000000..fbf1f68a0
--- /dev/null
+++ b/src/PostIt/PostIt/postit-settings-sample.json
@@ -0,0 +1,17 @@
+{
+ "Authentication": {
+ "ClientId": "postit",
+ "Authority": "https://yavsc.pschneider.fr",
+ "Scopes": [
+ "openid",
+ "profile",
+ "offline_access",
+ "blogs",
+ "api"
+ ],
+ "RedirectUri": "postit://callback"
+ },
+ "DarkMode": false,
+ "ApiUrl": "https://api.pschneider.fr/api/v1/"
+
+}
diff --git a/src/PostIt/PostIt/postit-settings.json b/src/PostIt/PostIt/postit-settings.json
index 079479515..f62a9ac1c 100644
--- a/src/PostIt/PostIt/postit-settings.json
+++ b/src/PostIt/PostIt/postit-settings.json
@@ -1,15 +1,21 @@
{
- "Authentication": {
- "ClientId": "postit",
- "Authority": "https://yavsc.pschneider.fr/"
- },
- "RedirectUri": "postit://callback",
- "DarkMode": true,
- "ApiUrl": "https://blogs.pschneider.fr/api/v1/",
+ "Authentication": {
+ "Authority": "https://localhost:5001",
+ "ClientId": "postit",
"Scopes": [
- "openid",
- "profile",
- "offline_access",
- "blogs"
- ]
-}
+ "blogs",
+ "api"
+ ],
+ "RedirectUri": "postit://callback"
+ },
+ "DarkMode": true,
+ "BlogsApiUrl": "https://localhost:5003/api/v1/",
+ "ApiUrl": "https://localhost:5005/api/v1/",
+ "SearchText": "",
+ "ProviderOngoingRequestsSortOption": "",
+ "Loaded": true,
+ "IsDirty": true,
+ "CanNavigateNext": false,
+ "CanNavigatePrevious": true,
+ "SaveCommand": {}
+}
\ No newline at end of file
diff --git a/src/Yavsc.Abstract/Billing/IBillable.cs b/src/Yavsc.Abstract/Billing/IBillable.cs
index 416dc5bee..f936a57df 100644
--- a/src/Yavsc.Abstract/Billing/IBillable.cs
+++ b/src/Yavsc.Abstract/Billing/IBillable.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
using Yavsc.Services;
namespace Yavsc.Billing
diff --git a/src/Yavsc.Abstract/Blogspot/BlogPostAuthorDto.cs b/src/Yavsc.Abstract/Blogspot/BlogPostAuthorDto.cs
index e332822e1..ab1b3fbe8 100644
--- a/src/Yavsc.Abstract/Blogspot/BlogPostAuthorDto.cs
+++ b/src/Yavsc.Abstract/Blogspot/BlogPostAuthorDto.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
namespace Yavsc.Blogspot;
///
diff --git a/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs b/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs
index 9cb5b1430..bdb2ccdc8 100644
--- a/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs
+++ b/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
using Yavsc.Abstract.Identity.Security;
using System.Text.Json.Serialization;
diff --git a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs
index 1ef371803..38a50b78f 100644
--- a/src/Yavsc.Abstract/Blogspot/IBlogPost.cs
+++ b/src/Yavsc.Abstract/Blogspot/IBlogPost.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
diff --git a/src/Yavsc.Abstract/Chat/ChatHubConstants.cs b/src/Yavsc.Abstract/Chat/ChatHubConstants.cs
index b54c00c74..c673e6fae 100644
--- a/src/Yavsc.Abstract/Chat/ChatHubConstants.cs
+++ b/src/Yavsc.Abstract/Chat/ChatHubConstants.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
namespace Yavsc.Abstract.Chat
{
public static class ChatHubConstants
diff --git a/src/Yavsc.Abstract/Chat/IChatRoom.cs b/src/Yavsc.Abstract/Chat/IChatRoom.cs
index 1d6f68dd5..133ca2837 100644
--- a/src/Yavsc.Abstract/Chat/IChatRoom.cs
+++ b/src/Yavsc.Abstract/Chat/IChatRoom.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
using System.ComponentModel.DataAnnotations;
namespace Yavsc.Abstract.Chat
diff --git a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs
index dd805864e..14bc86435 100644
--- a/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs
+++ b/src/Yavsc.Abstract/FileSystem/AbstractFileSystemHelpers.cs
@@ -1,4 +1,6 @@
-using System.Text;
+#nullable enable annotations
+
+using System.Text;
using Yavsc.ViewModels.UserFiles;
namespace Yavsc.Server.Helpers
diff --git a/src/Yavsc.Abstract/FileSystem/FsOperationInfo.cs b/src/Yavsc.Abstract/FileSystem/FsOperationInfo.cs
index 76a481492..0e3c0e0c7 100644
--- a/src/Yavsc.Abstract/FileSystem/FsOperationInfo.cs
+++ b/src/Yavsc.Abstract/FileSystem/FsOperationInfo.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
namespace Yavsc.Abstract.Helpers
{
public enum ErrorCode {
diff --git a/src/Yavsc.Abstract/FileSystem/UserDirectoryInfo.cs b/src/Yavsc.Abstract/FileSystem/UserDirectoryInfo.cs
index fa727d0c0..2ec5dcfaf 100644
--- a/src/Yavsc.Abstract/FileSystem/UserDirectoryInfo.cs
+++ b/src/Yavsc.Abstract/FileSystem/UserDirectoryInfo.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
using Yavsc.Server.Helpers;
namespace Yavsc.ViewModels.UserFiles
diff --git a/src/Yavsc.Abstract/Files/FileServerUrlHelpers.cs b/src/Yavsc.Abstract/Files/FileServerUrlHelpers.cs
new file mode 100644
index 000000000..64bcb405b
--- /dev/null
+++ b/src/Yavsc.Abstract/Files/FileServerUrlHelpers.cs
@@ -0,0 +1,67 @@
+namespace Yavsc.Abstract.Files;
+
+///
+/// Helpers pour dériver les URL publiques des fichiers statiques à partir
+/// d'une URL d'autorité OIDC ou d'un autre point d'entrée racine.
+///
+public static class FileServerUrlHelpers
+{
+ ///
+ /// Dérive la racine publique des fichiers utilisateur en alignant
+ /// le chemin sur .
+ ///
+ ///
+ /// URL absolue de base, typiquement l'autorité OIDC de Yavsc.Org.
+ ///
+ /// Une URL absolue pointant vers la racine des fichiers utilisateur.
+ public static Uri GetUserFilesBaseUri(Uri authorityBaseUrl)
+ {
+ ArgumentNullException.ThrowIfNull(authorityBaseUrl);
+
+ if (!authorityBaseUrl.IsAbsoluteUri)
+ {
+ throw new ArgumentException(
+ "The authority base URL must be absolute.",
+ nameof(authorityBaseUrl));
+ }
+
+ var baseString = authorityBaseUrl.GetLeftPart(UriPartial.Authority);
+ return new Uri(new Uri(baseString, UriKind.Absolute), EnsureTrailingSlash(Yavsc.Constants.UserFilesPath));
+ }
+
+ ///
+ /// Dérive la racine publique des fichiers utilisateur en alignant
+ /// le chemin sur .
+ ///
+ ///
+ /// URL absolue de base, typiquement l'autorité OIDC de Yavsc.Org.
+ ///
+ /// Une URL absolue pointant vers la racine des fichiers utilisateur.
+ public static Uri GetUserFilesBaseUri(string authorityBaseUrl)
+ => GetUserFilesBaseUri(new Uri(authorityBaseUrl, UriKind.Absolute));
+
+ ///
+ /// Construit l'URL d'un fichier utilisateur à partir de la base d'autorité
+ /// et d'un chemin relatif sous la racine des fichiers.
+ ///
+ public static Uri GetUserFilesUri(Uri authorityBaseUrl, string relativePath)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(relativePath);
+
+ var baseUri = GetUserFilesBaseUri(authorityBaseUrl);
+ return new Uri(baseUri, NormalizeRelativePath(relativePath));
+ }
+
+ ///
+ /// Construit l'URL d'un fichier utilisateur à partir de la base d'autorité
+ /// et d'un chemin relatif sous la racine des fichiers.
+ ///
+ public static Uri GetUserFilesUri(string authorityBaseUrl, string relativePath)
+ => GetUserFilesUri(new Uri(authorityBaseUrl, UriKind.Absolute), relativePath);
+
+ private static string EnsureTrailingSlash(string path)
+ => path.EndsWith("/", StringComparison.Ordinal) ? path : path + "/";
+
+ private static string NormalizeRelativePath(string relativePath)
+ => relativePath.TrimStart('/');
+}
diff --git a/src/Yavsc.Abstract/Google/GDate.cs b/src/Yavsc.Abstract/Google/GDate.cs
index 3506ba416..10e4de92e 100644
--- a/src/Yavsc.Abstract/Google/GDate.cs
+++ b/src/Yavsc.Abstract/Google/GDate.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
//
// GDate.cs
//
diff --git a/src/Yavsc.Abstract/Google/Messaging/MessageWithPayloadResponse.cs b/src/Yavsc.Abstract/Google/Messaging/MessageWithPayloadResponse.cs
index 59538ca62..980a4abcf 100644
--- a/src/Yavsc.Abstract/Google/Messaging/MessageWithPayloadResponse.cs
+++ b/src/Yavsc.Abstract/Google/Messaging/MessageWithPayloadResponse.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
//
// MessageWithPayloadResponse.cs
//
diff --git a/src/Yavsc.Abstract/HairCut/HairPrestationDto.cs b/src/Yavsc.Abstract/HairCut/HairPrestationDto.cs
new file mode 100644
index 000000000..0c4b80e2c
--- /dev/null
+++ b/src/Yavsc.Abstract/HairCut/HairPrestationDto.cs
@@ -0,0 +1,11 @@
+namespace Yavsc.Models.Haircut;
+
+///
+/// Lightweight hair-prestation description exposed to API clients.
+///
+public sealed class HairPrestationDto
+{
+ public long Id { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public string Details { get; set; } = string.Empty;
+}
\ No newline at end of file
diff --git a/src/Yavsc.Abstract/IT/CodeFromChars.cs b/src/Yavsc.Abstract/IT/CodeFromChars.cs
index ff441568e..e0e31ce28 100644
--- a/src/Yavsc.Abstract/IT/CodeFromChars.cs
+++ b/src/Yavsc.Abstract/IT/CodeFromChars.cs
@@ -44,7 +44,6 @@ namespace Yavsc.Abstract.IT
public bool Validate()
{
// this is a n*n task
-
throw new NotImplementedException();
}
diff --git a/src/Yavsc.Abstract/IT/Fixing/Bug.cs b/src/Yavsc.Abstract/IT/Fixing/Bug.cs
index f0b403bb7..60bd155f6 100644
--- a/src/Yavsc.Abstract/IT/Fixing/Bug.cs
+++ b/src/Yavsc.Abstract/IT/Fixing/Bug.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Yavsc.Attributes.Validation;
diff --git a/src/Yavsc.Abstract/Identity/IApplicationUser.cs b/src/Yavsc.Abstract/Identity/IApplicationUser.cs
index 5d1d5b1ca..0b8e1047c 100644
--- a/src/Yavsc.Abstract/Identity/IApplicationUser.cs
+++ b/src/Yavsc.Abstract/Identity/IApplicationUser.cs
@@ -1,4 +1,6 @@
-namespace Yavsc.Abstract.Identity
+#nullable enable annotations
+
+namespace Yavsc.Abstract.Identity
{
public interface IApplicationUser
{
diff --git a/src/Yavsc.Abstract/Identity/Security/FileAccessControlRulePayload.cs b/src/Yavsc.Abstract/Identity/Security/FileAccessControlRulePayload.cs
new file mode 100644
index 000000000..af3af22bc
--- /dev/null
+++ b/src/Yavsc.Abstract/Identity/Security/FileAccessControlRulePayload.cs
@@ -0,0 +1,12 @@
+
+namespace Yavsc.Models.Access
+{
+ using Yavsc.Abstract.Identity.Security;
+
+ public class FileAccessControlRulePayload : CircleAuthorization
+ {
+ public virtual string Path { get; set; }
+
+
+ }
+}
diff --git a/src/Yavsc.Abstract/Identity/UserDisplayHelpers.cs b/src/Yavsc.Abstract/Identity/UserDisplayHelpers.cs
index 242615527..4277d6e3b 100644
--- a/src/Yavsc.Abstract/Identity/UserDisplayHelpers.cs
+++ b/src/Yavsc.Abstract/Identity/UserDisplayHelpers.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
namespace Yavsc.Abstract.Identity
{
///
diff --git a/src/Yavsc.Abstract/Interfaces/Workflow/IBookQueryData.cs b/src/Yavsc.Abstract/Interfaces/Workflow/IBookQueryData.cs
index 2c844f925..eb3dee591 100644
--- a/src/Yavsc.Abstract/Interfaces/Workflow/IBookQueryData.cs
+++ b/src/Yavsc.Abstract/Interfaces/Workflow/IBookQueryData.cs
@@ -1,4 +1,6 @@
-using Yavsc.Abstract.Identity;
+#nullable enable annotations
+
+using Yavsc.Abstract.Identity;
namespace Yavsc.Interfaces
{
diff --git a/src/Yavsc.Abstract/Messaging/Comment.cs b/src/Yavsc.Abstract/Messaging/Comment.cs
index 3b8702ae1..d8eef456c 100644
--- a/src/Yavsc.Abstract/Messaging/Comment.cs
+++ b/src/Yavsc.Abstract/Messaging/Comment.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
using Yavsc.Interfaces;
diff --git a/src/Yavsc.Abstract/Messaging/Notification.cs b/src/Yavsc.Abstract/Messaging/Notification.cs
index dbbf735d5..f7b35b48b 100644
--- a/src/Yavsc.Abstract/Messaging/Notification.cs
+++ b/src/Yavsc.Abstract/Messaging/Notification.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
diff --git a/src/Yavsc.Abstract/Messaging/RdvQueryEvent.cs b/src/Yavsc.Abstract/Messaging/RdvQueryEvent.cs
index b2168bd7c..9e2ecb754 100644
--- a/src/Yavsc.Abstract/Messaging/RdvQueryEvent.cs
+++ b/src/Yavsc.Abstract/Messaging/RdvQueryEvent.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
//
// BookQueryEvent.cs
//
diff --git a/src/Yavsc.Abstract/Messaging/RdvQueryProviderInfo.cs b/src/Yavsc.Abstract/Messaging/RdvQueryProviderInfo.cs
index 87125c544..94eeb5ab9 100644
--- a/src/Yavsc.Abstract/Messaging/RdvQueryProviderInfo.cs
+++ b/src/Yavsc.Abstract/Messaging/RdvQueryProviderInfo.cs
@@ -1,3 +1,5 @@
+#nullable enable annotations
+
using Yavsc.Abstract.Identity;
using Yavsc.Models.Relationship;
diff --git a/src/Yavsc.Abstract/Workflow/ActivityInfo.cs b/src/Yavsc.Abstract/Workflow/ActivityInfo.cs
new file mode 100644
index 000000000..c0f543f40
--- /dev/null
+++ b/src/Yavsc.Abstract/Workflow/ActivityInfo.cs
@@ -0,0 +1,17 @@
+namespace Yavsc.Abstract.Workflow;
+
+///
+/// Activity node returned by the browsing API.
+///
+public sealed class ActivityInfo
+{
+ public string Code { get; set; } = string.Empty;
+ public string Name { get; set; } = string.Empty;
+ public string ParentCode { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public string Photo { get; set; } = string.Empty;
+ public int Rate { get; set; }
+ public int PerformerCount { get; set; }
+ public List Forms { get; set; } = new();
+ public List Children { get; set; } = new();
+}
diff --git a/src/Yavsc.Abstract/Workflow/CommandFormSummary.cs b/src/Yavsc.Abstract/Workflow/CommandFormSummary.cs
new file mode 100644
index 000000000..865690e08
--- /dev/null
+++ b/src/Yavsc.Abstract/Workflow/CommandFormSummary.cs
@@ -0,0 +1,11 @@
+namespace Yavsc.Abstract.Workflow;
+
+///
+/// Lightweight command-form description exposed to API clients.
+///
+public sealed class CommandFormSummary
+{
+ public long Id { get; set; }
+ public string ActionName { get; set; } = string.Empty;
+ public string Title { get; set; } = string.Empty;
+}
diff --git a/src/Yavsc.Abstract/Workflow/Country.cs b/src/Yavsc.Abstract/Workflow/Country.cs
new file mode 100644
index 000000000..d782d0aac
--- /dev/null
+++ b/src/Yavsc.Abstract/Workflow/Country.cs
@@ -0,0 +1,19 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Yavsc.Models.Workflow
+{
+ ///
+ /// Supported country of exercise for performer profile validations.
+ ///
+ public class Country
+ {
+ [Key]
+ [MaxLength(2)]
+ [MinLength(2)]
+ public string Code { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(64)]
+ public string DisplayName { get; set; } = string.Empty;
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Abstract/Workflow/IMobileDeviceDeclaration.cs b/src/Yavsc.Abstract/Workflow/IMobileDeviceDeclaration.cs
index 6e55ebe51..7a1dbc261 100644
--- a/src/Yavsc.Abstract/Workflow/IMobileDeviceDeclaration.cs
+++ b/src/Yavsc.Abstract/Workflow/IMobileDeviceDeclaration.cs
@@ -1,4 +1,6 @@
-// Copyright (C) 2016 Paul Schneider
+#nullable enable annotations
+
+// Copyright (C) 2016 Paul Schneider
//
// This file is part of yavsc.
//
diff --git a/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs b/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs
index e53583a59..606285a6c 100644
--- a/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs
+++ b/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs
@@ -1,8 +1,11 @@
+#nullable enable annotations
+
namespace Yavsc.Workflow
{
public interface IPerformerProfile
{
string PerformerId { get; set; }
+ string ExerciseCountryCode { get; set; }
string SIREN { get; set; }
bool AcceptNotifications { get; set; }
long OrganizationAddressId { get; set; }
diff --git a/src/Yavsc.Abstract/Workflow/PerformerActivity.cs b/src/Yavsc.Abstract/Workflow/PerformerActivity.cs
new file mode 100644
index 000000000..a5ee3b254
--- /dev/null
+++ b/src/Yavsc.Abstract/Workflow/PerformerActivity.cs
@@ -0,0 +1,19 @@
+namespace Yavsc.Abstract.Workflow;
+
+///
+/// Lightweight performer description returned for one activity.
+///
+public sealed class PerformerActivity
+{
+ public string PerformerId { get; set; } = string.Empty;
+ public bool HasPerformerProfile { get; set; }
+ public string UserName { get; set; } = string.Empty;
+ public bool Active { get; set; }
+ public bool AcceptNotifications { get; set; }
+ public bool AcceptPublicContact { get; set; }
+ public string WebSite { get; set; } = string.Empty;
+ public string ActivityCode { get; set; } = string.Empty;
+ public string ActivityName { get; set; } = string.Empty;
+ public string SettingsClassName { get; set; } = string.Empty;
+ public int ExtraActivityCount { get; set; }
+}
diff --git a/src/Yavsc.Abstract/Workflow/PerformerCodeInputValidation.cs b/src/Yavsc.Abstract/Workflow/PerformerCodeInputValidation.cs
new file mode 100644
index 000000000..4ed60e465
--- /dev/null
+++ b/src/Yavsc.Abstract/Workflow/PerformerCodeInputValidation.cs
@@ -0,0 +1,88 @@
+#nullable enable annotations
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Yavsc.Models.Workflow
+{
+ ///
+ /// Validation rule for performer business code input by country.
+ ///
+ public class PerformerCodeInputValidation
+ {
+ [Key]
+ public long Id { get; set; }
+
+ [Required]
+ [MaxLength(2)]
+ [MinLength(2)]
+ public string CountryCode { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(256)]
+ public string RegularExpression { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(128)]
+ public string ErrorMessage { get; set; } = string.Empty;
+
+ [ForeignKey(nameof(CountryCode))]
+ public Country Country { get; set; }
+ }
+
+ public static class PerformerCodeInputValidationCatalog
+ {
+ public static readonly IReadOnlyList Countries = new List
+ {
+ new() { Code = "fr", DisplayName = "France" },
+ new() { Code = "en", DisplayName = "England" },
+ new() { Code = "pt", DisplayName = "Portugal" },
+ };
+
+ public static readonly IReadOnlyList Rules = new List
+ {
+ new()
+ {
+ Id = 1,
+ CountryCode = "fr",
+ RegularExpression = "^[0-9]{9,14}$",
+ ErrorMessage = "Le code FR doit contenir entre 9 et 14 chiffres."
+ },
+ new()
+ {
+ Id = 2,
+ CountryCode = "en",
+ RegularExpression = "^[A-Za-z0-9]{8,14}$",
+ ErrorMessage = "Le code EN doit contenir entre 8 et 14 caracteres alphanumeriques."
+ },
+ new()
+ {
+ Id = 3,
+ CountryCode = "pt",
+ RegularExpression = "^[0-9]{9}$",
+ ErrorMessage = "Le code PT doit contenir exactement 9 chiffres."
+ },
+ };
+
+ public static string NormalizeCountryCode(string? countryCode)
+ {
+ return (countryCode ?? string.Empty).Trim().ToLowerInvariant();
+ }
+
+ public static PerformerCodeInputValidation? GetRule(string? countryCode)
+ {
+ var normalized = NormalizeCountryCode(countryCode);
+ foreach (var rule in Rules)
+ {
+ if (string.Equals(rule.CountryCode, normalized, StringComparison.Ordinal))
+ {
+ return rule;
+ }
+ }
+
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api.Client/ActivityApiClient.cs b/src/Yavsc.Api.Client/ActivityApiClient.cs
new file mode 100644
index 000000000..8199de9d4
--- /dev/null
+++ b/src/Yavsc.Api.Client/ActivityApiClient.cs
@@ -0,0 +1,106 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Yavsc.Abstract.Workflow;
+
+namespace Yavsc.Api.Client;
+
+///
+/// HTTP client for browsing business activities and their performers.
+/// Uses absolute URLs so it can coexist with other Yavsc clients that
+/// target a different API host on the same shared transport. The same
+/// activity payload also carries the eligible billing forms for a
+/// selected performer/activity pair.
+///
+public sealed class ActivityApiClient
+{
+ private const string PathPrefix = "activity";
+
+ private readonly IYavscApiClient _api;
+ private readonly Func _businessBaseAddress;
+ private readonly Func _avatarBaseAddress;
+
+ public ActivityApiClient(IYavscApiClient api, string businessBaseAddress, string? avatarBaseAddress = null)
+ : this(
+ api,
+ () => businessBaseAddress,
+ () => avatarBaseAddress)
+ {
+ }
+
+ public ActivityApiClient(
+ IYavscApiClient api,
+ Func businessBaseAddress,
+ Func? avatarBaseAddress = null)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
+ _avatarBaseAddress = avatarBaseAddress ?? (() => null);
+
+ // Validate initial values early to fail fast on invalid setup.
+ _ = ResolveBusinessBaseAddress();
+ _ = ResolveAvatarBaseAddress();
+ }
+
+ public Task> GetCatalogAsync(
+ string? parentCode = null,
+ CancellationToken ct = default)
+ {
+ var path = string.IsNullOrWhiteSpace(parentCode)
+ ? $"{PathPrefix}/catalog"
+ : $"{PathPrefix}/catalog?parentCode={Uri.EscapeDataString(parentCode)}";
+
+ return _api.CallAsync>(HttpMethod.Get, Absolute(path), ct: ct);
+ }
+
+ public Task> GetUsersAsync(
+ string activityCode,
+ CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(activityCode))
+ throw new ArgumentException("Activity code is required.", nameof(activityCode));
+
+ return _api.CallAsync>(
+ HttpMethod.Get,
+ Absolute($"{PathPrefix}/{Uri.EscapeDataString(activityCode)}/users"),
+ ct: ct);
+ }
+
+ public Task> GetPerformersAsync(
+ string activityCode,
+ CancellationToken ct = default)
+ => GetUsersAsync(activityCode, ct);
+
+ public string BuildAvatarXsUrl(string? userName)
+ {
+ var siteRoot = new Uri(ResolveAvatarBaseAddress(), "/");
+
+ if (string.IsNullOrWhiteSpace(userName))
+ {
+ return new Uri(siteRoot, "images/Users/icon_user.xs.png").ToString();
+ }
+
+ return new Uri(siteRoot, $"avatars/{Uri.EscapeDataString(userName)}.xs.png").ToString();
+ }
+
+ private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
+
+ private Uri ResolveBusinessBaseAddress()
+ {
+ var raw = _businessBaseAddress();
+ if (string.IsNullOrWhiteSpace(raw))
+ throw new InvalidOperationException("Business base address is required.");
+
+ return new Uri(raw, UriKind.Absolute);
+ }
+
+ private Uri ResolveAvatarBaseAddress()
+ {
+ var raw = _avatarBaseAddress();
+ return string.IsNullOrWhiteSpace(raw)
+ ? ResolveBusinessBaseAddress()
+ : new Uri(raw, UriKind.Absolute);
+ }
+}
diff --git a/src/Yavsc.Api.Client/BillingApiClient.cs b/src/Yavsc.Api.Client/BillingApiClient.cs
new file mode 100644
index 000000000..a9e9deda6
--- /dev/null
+++ b/src/Yavsc.Api.Client/BillingApiClient.cs
@@ -0,0 +1,395 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Haircut;
+using Yavsc;
+
+namespace Yavsc.Api.Client;
+
+///
+/// HTTP client for posting commands to the business billing routes.
+/// Uses absolute URLs so it can coexist with blog-targeting clients on
+/// the same shared transport.
+///
+public sealed class BillingApiClient
+{
+ private const string PathPrefix = "billing";
+
+ private readonly IYavscApiClient _api;
+ private readonly Func _businessBaseAddress;
+
+ public BillingApiClient(IYavscApiClient api, string businessBaseAddress)
+ : this(api, () => businessBaseAddress)
+ {
+ }
+
+ public BillingApiClient(IYavscApiClient api, Func businessBaseAddress)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
+
+ // Validate initial value early to fail fast on invalid setup.
+ _ = ResolveBusinessBaseAddress();
+ }
+
+ public Task CreateAsync(string billingCode, object payload, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(billingCode))
+ throw new ArgumentException("Billing code is required.", nameof(billingCode));
+
+ return _api.CallAsync(
+ HttpMethod.Post,
+ Absolute($"{PathPrefix}/{Uri.EscapeDataString(billingCode)}"),
+ body: payload,
+ ct: ct);
+ }
+
+ public Task> GetHairPrestationsAsync(string billingCode, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(billingCode))
+ throw new ArgumentException("Billing code is required.", nameof(billingCode));
+
+ return _api.CallAsync>(
+ HttpMethod.Get,
+ Absolute($"{PathPrefix}/{Uri.EscapeDataString(billingCode)}/prestations"),
+ ct: ct);
+ }
+
+ public async Task> GetQuerySummariesAsync(string billingCode, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(billingCode))
+ throw new ArgumentException("Billing code is required.", nameof(billingCode));
+
+ var items = await _api.CallAsync>(
+ HttpMethod.Get,
+ Absolute($"{PathPrefix}/{Uri.EscapeDataString(billingCode)}"),
+ ct: ct) ?? new List();
+
+ foreach (var item in items)
+ {
+ item.BillingCode = billingCode;
+ }
+
+ return items;
+ }
+
+ public Task> GetProviderOngoingQueriesAsync(CancellationToken ct = default)
+ {
+ return _api.CallAsync>(
+ HttpMethod.Get,
+ Absolute("bill/provider/ongoing"),
+ ct: ct);
+ }
+
+ public async Task GetQueryAsync(string billingCode, long queryId, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(billingCode))
+ throw new ArgumentException("Billing code is required.", nameof(billingCode));
+ if (queryId <= 0)
+ throw new ArgumentOutOfRangeException(nameof(queryId));
+
+ var code = billingCode.Trim();
+ var path = Absolute($"{PathPrefix}/{Uri.EscapeDataString(code)}/{queryId}");
+
+ if (string.Equals(code, BillingCodes.Rdv, StringComparison.Ordinal))
+ {
+ var dto = await _api.CallAsync(HttpMethod.Get, path, ct: ct).ConfigureAwait(false);
+ return MapRdv(dto, code);
+ }
+
+ if (string.Equals(code, BillingCodes.Brush, StringComparison.Ordinal))
+ {
+ var dto = await _api.CallAsync(HttpMethod.Get, path, ct: ct).ConfigureAwait(false);
+ return MapBrush(dto, code);
+ }
+
+ if (string.Equals(code, BillingCodes.MBrush, StringComparison.Ordinal))
+ {
+ var dto = await _api.CallAsync(HttpMethod.Get, path, ct: ct).ConfigureAwait(false);
+ return MapMBrush(dto, code);
+ }
+
+ throw new NotSupportedException($"Billing code '{code}' is not supported.");
+ }
+
+ public Task UpdateAsync(string billingCode, long queryId, BillingQueryDetailsDto payload, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(billingCode))
+ throw new ArgumentException("Billing code is required.", nameof(billingCode));
+ if (queryId <= 0)
+ throw new ArgumentOutOfRangeException(nameof(queryId));
+ if (payload is null)
+ throw new ArgumentNullException(nameof(payload));
+
+ var code = billingCode.Trim();
+
+ return _api.CallAsync(
+ HttpMethod.Put,
+ Absolute($"{PathPrefix}/{Uri.EscapeDataString(code)}/{queryId}"),
+ body: BuildUpdatePayload(code, queryId, payload),
+ ct: ct);
+ }
+
+ private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
+
+ private Uri ResolveBusinessBaseAddress()
+ {
+ var raw = _businessBaseAddress();
+ if (string.IsNullOrWhiteSpace(raw))
+ throw new InvalidOperationException("Business base address is required.");
+
+ return new Uri(raw, UriKind.Absolute);
+ }
+
+ private static BillingQueryDetailsDto MapRdv(RdvQueryResponse dto, string billingCode)
+ {
+ return new BillingQueryDetailsDto
+ {
+ Id = dto.Id,
+ BillingCode = billingCode,
+ ActivityCode = dto.ActivityCode ?? string.Empty,
+ PerformerId = dto.PerformerId ?? string.Empty,
+ ClientId = dto.ClientId ?? string.Empty,
+ Description = dto.Description ?? string.Empty,
+ Consent = dto.Consent,
+ EventDate = dto.EventDate,
+ Status = dto.Status,
+ Reason = dto.Reason ?? string.Empty,
+ Provisional = dto.Provisional,
+ Location = dto.Location is null
+ ? null
+ : new BillingLocationDto
+ {
+ Id = dto.Location.Id > 0 ? dto.Location.Id : null,
+ Address = dto.Location.Address ?? string.Empty,
+ Latitude = dto.Location.Latitude,
+ Longitude = dto.Location.Longitude,
+ }
+ };
+ }
+
+ private static BillingQueryDetailsDto MapBrush(HairCutQueryResponse dto, string billingCode)
+ {
+ return new BillingQueryDetailsDto
+ {
+ Id = dto.Id,
+ BillingCode = billingCode,
+ ActivityCode = dto.ActivityCode ?? string.Empty,
+ PerformerId = dto.PerformerId ?? string.Empty,
+ ClientId = dto.ClientId ?? string.Empty,
+ Description = dto.Description ?? string.Empty,
+ Consent = dto.Consent,
+ EventDate = dto.EventDate,
+ Status = dto.Status,
+ AdditionalInfo = dto.AdditionalInfo ?? string.Empty,
+ Provisional = dto.Provisional,
+ PrestationId = dto.PrestationId,
+ Location = dto.Location is null
+ ? null
+ : new BillingLocationDto
+ {
+ Id = dto.Location.Id > 0 ? dto.Location.Id : null,
+ Address = dto.Location.Address ?? string.Empty,
+ Latitude = dto.Location.Latitude,
+ Longitude = dto.Location.Longitude,
+ }
+ };
+ }
+
+ private static BillingQueryDetailsDto MapMBrush(HairMultiCutQueryResponse dto, string billingCode)
+ {
+ return new BillingQueryDetailsDto
+ {
+ Id = dto.Id,
+ BillingCode = billingCode,
+ ActivityCode = dto.ActivityCode ?? string.Empty,
+ PerformerId = dto.PerformerId ?? string.Empty,
+ ClientId = dto.ClientId ?? string.Empty,
+ Description = dto.Description ?? string.Empty,
+ Consent = dto.Consent,
+ EventDate = dto.EventDate,
+ Status = dto.Status,
+ Provisional = dto.Provisional,
+ PrestationIds = (dto.Prestations ?? new List())
+ .Select(p => p.PrestationId)
+ .Where(id => id > 0)
+ .ToList(),
+ Location = dto.Location is null
+ ? null
+ : new BillingLocationDto
+ {
+ Id = dto.Location.Id > 0 ? dto.Location.Id : null,
+ Address = dto.Location.Address ?? string.Empty,
+ Latitude = dto.Location.Latitude,
+ Longitude = dto.Location.Longitude,
+ }
+ };
+ }
+
+ private static object BuildUpdatePayload(string billingCode, long queryId, BillingQueryDetailsDto payload)
+ {
+ if (string.Equals(billingCode, BillingCodes.Rdv, StringComparison.Ordinal))
+ {
+ if (payload.EventDate is null)
+ throw new ArgumentException("EventDate is required for Rdv.", nameof(payload));
+
+ return new
+ {
+ Id = queryId,
+ ActivityCode = payload.ActivityCode,
+ PerformerId = payload.PerformerId,
+ ClientId = payload.ClientId,
+ Consent = payload.Consent,
+ EventDate = payload.EventDate.Value,
+ Location = ToLocationPayload(payload.Location),
+ Reason = payload.Reason,
+ Status = payload.Status,
+ Provisional = payload.Provisional,
+ Description = payload.Description,
+ };
+ }
+
+ if (string.Equals(billingCode, BillingCodes.Brush, StringComparison.Ordinal))
+ {
+ if (payload.PrestationId is null || payload.PrestationId <= 0)
+ throw new ArgumentException("PrestationId is required for Brush.", nameof(payload));
+
+ return new
+ {
+ Id = queryId,
+ ActivityCode = payload.ActivityCode,
+ PerformerId = payload.PerformerId,
+ ClientId = payload.ClientId,
+ Consent = payload.Consent,
+ EventDate = payload.EventDate,
+ Location = ToLocationPayload(payload.Location),
+ PrestationId = payload.PrestationId.Value,
+ AdditionalInfo = payload.AdditionalInfo,
+ Status = payload.Status,
+ Provisional = payload.Provisional,
+ Description = payload.Description,
+ };
+ }
+
+ if (string.Equals(billingCode, BillingCodes.MBrush, StringComparison.Ordinal))
+ {
+ if (payload.EventDate is null)
+ throw new ArgumentException("EventDate is required for MBrush.", nameof(payload));
+ if (payload.PrestationIds is null || payload.PrestationIds.Count == 0)
+ throw new ArgumentException("At least one prestation is required for MBrush.", nameof(payload));
+
+ return new
+ {
+ Id = queryId,
+ ActivityCode = payload.ActivityCode,
+ PerformerId = payload.PerformerId,
+ ClientId = payload.ClientId,
+ Consent = payload.Consent,
+ EventDate = payload.EventDate.Value,
+ Location = ToLocationPayload(payload.Location),
+ Prestations = payload.PrestationIds
+ .Where(id => id > 0)
+ .Select(id => new { PrestationId = id })
+ .ToList(),
+ Status = payload.Status,
+ Provisional = payload.Provisional,
+ Description = payload.Description,
+ };
+ }
+
+ throw new NotSupportedException($"Billing code '{billingCode}' is not supported.");
+ }
+
+ private static object? ToLocationPayload(BillingLocationDto? location)
+ {
+ if (location is null)
+ {
+ return null;
+ }
+
+ var payload = new Dictionary
+ {
+ ["Address"] = location.Address,
+ };
+
+ if (location.Id.HasValue && location.Id.Value > 0)
+ {
+ payload["Id"] = location.Id.Value;
+ }
+
+ if (location.Latitude.HasValue)
+ {
+ payload["Latitude"] = location.Latitude.Value;
+ }
+
+ if (location.Longitude.HasValue)
+ {
+ payload["Longitude"] = location.Longitude.Value;
+ }
+
+ return payload;
+ }
+
+ private sealed class BillingLocationResponse
+ {
+ public long Id { get; set; }
+ public string? Address { get; set; }
+ public double Latitude { get; set; }
+ public double Longitude { get; set; }
+ }
+
+ private sealed class RdvQueryResponse
+ {
+ public long Id { get; set; }
+ public string? ActivityCode { get; set; }
+ public string? PerformerId { get; set; }
+ public string? ClientId { get; set; }
+ public string? Description { get; set; }
+ public bool Consent { get; set; }
+ public DateTime EventDate { get; set; }
+ public QueryStatus Status { get; set; }
+ public string? Reason { get; set; }
+ public decimal? Provisional { get; set; }
+ public BillingLocationResponse? Location { get; set; }
+ }
+
+ private sealed class HairCutQueryResponse
+ {
+ public long Id { get; set; }
+ public string? ActivityCode { get; set; }
+ public string? PerformerId { get; set; }
+ public string? ClientId { get; set; }
+ public string? Description { get; set; }
+ public bool Consent { get; set; }
+ public DateTime? EventDate { get; set; }
+ public QueryStatus Status { get; set; }
+ public decimal? Provisional { get; set; }
+ public long PrestationId { get; set; }
+ public string? AdditionalInfo { get; set; }
+ public BillingLocationResponse? Location { get; set; }
+ }
+
+ private sealed class HairMultiCutQueryResponse
+ {
+ public long Id { get; set; }
+ public string? ActivityCode { get; set; }
+ public string? PerformerId { get; set; }
+ public string? ClientId { get; set; }
+ public string? Description { get; set; }
+ public bool Consent { get; set; }
+ public DateTime EventDate { get; set; }
+ public QueryStatus Status { get; set; }
+ public decimal? Provisional { get; set; }
+ public BillingLocationResponse? Location { get; set; }
+ public List? Prestations { get; set; }
+ }
+
+ private sealed class HairPrestationCollectionItemResponse
+ {
+ public long PrestationId { get; set; }
+ }
+}
diff --git a/src/Yavsc.Api.Client/BlogApiClient.cs b/src/Yavsc.Api.Client/BlogApiClient.cs
index 8284c5ed9..3de5b37ec 100644
--- a/src/Yavsc.Api.Client/BlogApiClient.cs
+++ b/src/Yavsc.Api.Client/BlogApiClient.cs
@@ -1,8 +1,5 @@
-using System;
-using System.Collections.Generic;
-using System.Net.Http;
-using System.Threading;
-using System.Threading.Tasks;
+using System.Net.Http.Headers;
+using System.Text.Json;
using Yavsc.Blogspot;
namespace Yavsc.Api.Client;
@@ -62,11 +59,18 @@ public sealed class BlogApiClient
public Task GetPostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Get, $"{_pathPrefix}/{id}", ct: ct);
- public Task CreatePostAsync(BlogPostDto post, CancellationToken ct = default)
- => _api.CallAsync(HttpMethod.Post, _pathPrefix, body: post, ct: ct);
+ public Task CreatePostAsync(
+ BlogPostDto post,
+ IReadOnlyCollection? files = null,
+ CancellationToken ct = default)
+ => SendPostAsync(HttpMethod.Post, _pathPrefix, post, files, ct);
- public Task UpdatePostAsync(long id, BlogPostDto post, CancellationToken ct = default)
- => _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", body: post, ct: ct);
+ public Task UpdatePostAsync(
+ long id,
+ BlogPostDto post,
+ IReadOnlyCollection? files = null,
+ CancellationToken ct = default)
+ => SendPostAsync(HttpMethod.Put, $"{_pathPrefix}/{id}", post, files, ct);
public Task DeletePostAsync(long id, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Delete, $"{_pathPrefix}/{id}", ct: ct);
@@ -81,4 +85,39 @@ public sealed class BlogApiClient
public Task SetPublishAsync(long id, bool publish, CancellationToken ct = default)
=> _api.CallAsync(HttpMethod.Put, $"{_pathPrefix}/{id}/publish",
body: new { publish }, ct: ct);
+
+ private async Task SendPostAsync(
+ HttpMethod method,
+ string path,
+ BlogPostDto post,
+ IReadOnlyCollection? files,
+ CancellationToken ct)
+ {
+ if (files is null || files.Count == 0)
+ return await _api.CallAsync(method, path, body: post, ct: ct);
+
+ return await _api.CallAsync(method, path, () => CreateMultipartContent(post, files), ct: ct);
+ }
+
+ private static HttpContent CreateMultipartContent(BlogPostDto post, IReadOnlyCollection files)
+ {
+ var content = new MultipartFormDataContent();
+ var blogJson = JsonSerializer.Serialize(post, new JsonSerializerOptions
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull,
+ });
+
+ content.Add(new StringContent(blogJson), "blog");
+
+ foreach (var file in files)
+ {
+ var fileContent = new ByteArrayContent(file.Content);
+ fileContent.Headers.ContentType = new MediaTypeHeaderValue(
+ string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
+ content.Add(fileContent, "file", file.FileName);
+ }
+
+ return content;
+ }
}
diff --git a/src/Yavsc.Api.Client/BlogUploadFile.cs b/src/Yavsc.Api.Client/BlogUploadFile.cs
new file mode 100644
index 000000000..4b3a7b26d
--- /dev/null
+++ b/src/Yavsc.Api.Client/BlogUploadFile.cs
@@ -0,0 +1,6 @@
+namespace Yavsc.Api.Client;
+
+///
+/// Buffered file payload for multipart blog uploads.
+///
+public sealed record BlogUploadFile(string FileName, byte[] Content, string? ContentType = null);
diff --git a/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs b/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs
new file mode 100644
index 000000000..eb33ba816
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections.Generic;
+using System.Text.Json.Serialization;
+using Yavsc;
+
+namespace Yavsc.Api.Client;
+
+///
+/// Normalized billing-query shape used by PostIt when opening an existing
+/// command from history.
+///
+public sealed class BillingQueryDetailsDto
+{
+ public long Id { get; set; }
+ public string BillingCode { get; set; } = string.Empty;
+ public string ActivityCode { get; set; } = string.Empty;
+ public string PerformerId { get; set; } = string.Empty;
+ public string ClientId { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public bool Consent { get; set; } = true;
+ public DateTime? EventDate { get; set; }
+ public QueryStatus Status { get; set; } = QueryStatus.Inserted;
+ public string Reason { get; set; } = string.Empty;
+ public string AdditionalInfo { get; set; } = string.Empty;
+ public decimal? Provisional { get; set; }
+ public BillingLocationDto? Location { get; set; }
+ public long? PrestationId { get; set; }
+ public List PrestationIds { get; set; } = new();
+}
+
+public sealed class BillingLocationDto
+{
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public long? Id { get; set; }
+
+ public string Address { get; set; } = string.Empty;
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public double? Latitude { get; set; }
+
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
+ public double? Longitude { get; set; }
+}
diff --git a/src/Yavsc.Api.Client/Dtos/BillingQuerySummaryDto.cs b/src/Yavsc.Api.Client/Dtos/BillingQuerySummaryDto.cs
new file mode 100644
index 000000000..0102b6917
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/BillingQuerySummaryDto.cs
@@ -0,0 +1,23 @@
+using System;
+using Yavsc;
+
+namespace Yavsc.Api.Client;
+
+///
+/// Lightweight billing-query projection consumed by PostIt list views.
+/// Extra JSON fields from concrete query types are ignored.
+///
+public sealed class BillingQuerySummaryDto
+{
+ public long Id { get; set; }
+ public string BillingCode { get; set; } = string.Empty;
+ public string ActivityCode { get; set; } = string.Empty;
+ public string PerformerId { get; set; } = string.Empty;
+ public string ClientId { get; set; } = string.Empty;
+ public QueryStatus Status { get; set; }
+ public string Description { get; set; } = string.Empty;
+ public DateTime? EventDate { get; set; }
+ public string Reason { get; set; } = string.Empty;
+ public string AdditionalInfo { get; set; } = string.Empty;
+ public decimal? Provisional { get; set; }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs
new file mode 100644
index 000000000..43c09d94a
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/EstimateDtos.cs
@@ -0,0 +1,52 @@
+using System;
+using System.Collections.Generic;
+
+namespace Yavsc.Api.Client;
+
+///
+/// A single billable line of an estimate, mirroring the JSON shape of
+/// the server-side Yavsc.Models.Billing.CommandLine entity.
+///
+public sealed class EstimateLineDto
+{
+ public long Id { get; set; }
+ public string Name { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public int Count { get; set; } = 1;
+ public decimal UnitaryCost { get; set; }
+ public long EstimateId { get; set; }
+ public string Currency { get; set; } = "EUR";
+}
+
+///
+/// Estimate payload exchanged with the api/v1/estimate routes
+/// (EstimateApiController). and
+/// are always initialised: the server-side
+/// entity reads them from non-nullable string properties and a null
+/// list would break its serialisation.
+///
+public sealed class EstimateDto
+{
+ public long Id { get; set; }
+ public long? CommandId { get; set; }
+ public string Title { get; set; } = string.Empty;
+ public string Description { get; set; } = string.Empty;
+ public List Bill { get; set; } = new();
+ public List AttachedGraphics { get; set; } = new();
+ public List AttachedFiles { get; set; } = new();
+ public string? OwnerId { get; set; }
+ public string ClientId { get; set; } = string.Empty;
+ public string CommandType { get; set; } = string.Empty;
+ public DateTime ProviderValidationDate { get; set; }
+ public DateTime ClientValidationDate { get; set; }
+}
+
+///
+/// Response of a successful estimate creation
+/// (Ok(new { estimate.Id, estimate.Bill })).
+///
+public sealed class EstimateCreatedDto
+{
+ public long Id { get; set; }
+ public List Bill { get; set; } = new();
+}
diff --git a/src/Yavsc.Api.Client/EstimateApiClient.cs b/src/Yavsc.Api.Client/EstimateApiClient.cs
new file mode 100644
index 000000000..15b260786
--- /dev/null
+++ b/src/Yavsc.Api.Client/EstimateApiClient.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Yavsc.Api.Client;
+
+///
+/// HTTP client for the estimate routes (api/v1/estimate,
+/// served by EstimateApiController). Follows the same
+/// DTO↔path mapper shape as : all
+/// transport concerns (base URL, JSON, Bearer auth, silent refresh
+/// on 401) are delegated to .
+///
+public sealed class EstimateApiClient
+{
+ private const string PathPrefix = "estimate";
+
+ private readonly IYavscApiClient _api;
+ private readonly Func _businessBaseAddress;
+
+ public EstimateApiClient(IYavscApiClient api, string businessBaseAddress)
+ : this(api, () => businessBaseAddress)
+ {
+ }
+
+ public EstimateApiClient(IYavscApiClient api, Func businessBaseAddress)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ _businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
+
+ // Validate initial value early to fail fast on invalid setup.
+ _ = ResolveBusinessBaseAddress();
+ }
+
+ ///
+ /// Lists the estimates of the given owner; when
+ /// is null, the server falls back to the current user.
+ ///
+ public Task> GetEstimatesAsync(string? ownerId = null, CancellationToken ct = default)
+ {
+ var path = string.IsNullOrWhiteSpace(ownerId)
+ ? PathPrefix
+ : $"{PathPrefix}?ownerId={Uri.EscapeDataString(ownerId)}";
+
+ return _api.CallAsync>(HttpMethod.Get, Absolute(path), ct: ct);
+ }
+
+ public Task GetEstimateAsync(long id, CancellationToken ct = default)
+ {
+ if (id <= 0)
+ throw new ArgumentOutOfRangeException(nameof(id));
+
+ return _api.CallAsync(HttpMethod.Get, Absolute($"{PathPrefix}/{id}"), ct: ct);
+ }
+
+ ///
+ /// Creates an estimate. When is set,
+ /// the server also stamps the linked command as validated.
+ ///
+ public async Task CreateAsync(EstimateDto estimate, CancellationToken ct = default)
+ {
+ if (estimate is null)
+ throw new ArgumentNullException(nameof(estimate));
+
+ var created = await _api.CallAsync(
+ HttpMethod.Post,
+ Absolute(PathPrefix),
+ body: estimate,
+ ct: ct).ConfigureAwait(false);
+
+ return created ?? new EstimateCreatedDto();
+ }
+
+ public Task UpdateAsync(long id, EstimateDto estimate, CancellationToken ct = default)
+ {
+ if (id <= 0)
+ throw new ArgumentOutOfRangeException(nameof(id));
+ if (estimate is null)
+ throw new ArgumentNullException(nameof(estimate));
+
+ estimate.Id = id;
+
+ return _api.CallAsync(
+ HttpMethod.Put,
+ Absolute($"{PathPrefix}/{id}"),
+ body: estimate,
+ ct: ct);
+ }
+
+ public Task DeleteAsync(long id, CancellationToken ct = default)
+ {
+ if (id <= 0)
+ throw new ArgumentOutOfRangeException(nameof(id));
+
+ return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
+ }
+
+ private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
+
+ private Uri ResolveBusinessBaseAddress()
+ {
+ var raw = _businessBaseAddress();
+ if (string.IsNullOrWhiteSpace(raw))
+ throw new InvalidOperationException("Business base address is required.");
+
+ return new Uri(raw, UriKind.Absolute);
+ }
+}
diff --git a/src/Yavsc.Api.Client/IYavscApiClient.cs b/src/Yavsc.Api.Client/IYavscApiClient.cs
index 209ec07d7..1d470b682 100644
--- a/src/Yavsc.Api.Client/IYavscApiClient.cs
+++ b/src/Yavsc.Api.Client/IYavscApiClient.cs
@@ -53,10 +53,24 @@ public interface IYavscApiClient : IAsyncDisposable
object? body = null,
CancellationToken ct = default);
+ /// Call a multipart endpoint with a typed return value.
+ Task CallAsync(
+ HttpMethod method,
+ string path,
+ Func contentFactory,
+ CancellationToken ct = default);
+
/// Call a JSON endpoint that returns no useful body (DELETE, 204, etc.).
Task CallAsync(
HttpMethod method,
string path,
object? body = null,
CancellationToken ct = default);
+
+ /// Call a multipart endpoint that returns no useful body.
+ Task CallAsync(
+ HttpMethod method,
+ string path,
+ Func contentFactory,
+ CancellationToken ct = default);
}
diff --git a/src/Yavsc.Api.Test/ActivityApiControllerTests.cs b/src/Yavsc.Api.Test/ActivityApiControllerTests.cs
new file mode 100644
index 000000000..0525e349f
--- /dev/null
+++ b/src/Yavsc.Api.Test/ActivityApiControllerTests.cs
@@ -0,0 +1,161 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Abstract.Workflow;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class ActivityApiControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public ActivityApiControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+ return http;
+ }
+
+ [Fact]
+ public async Task GetUsers_returns_declared_user_for_exact_activity_code()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.Single(payload!);
+ Assert.Equal("alice", payload[0].PerformerId);
+ Assert.Equal("alice", payload[0].UserName);
+ Assert.True(payload[0].HasPerformerProfile);
+ Assert.True(payload[0].Active);
+ Assert.Equal("dev", payload[0].ActivityCode);
+ }
+
+ [Fact]
+ public async Task GetUsers_returns_user_even_when_performer_inactive()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using (var scope = _fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var performer = db.Performers.Single(p => p.PerformerId == "alice");
+ performer.Active = false;
+ db.SaveChanges();
+ }
+
+ using var http = NewClient();
+ var response = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.Single(payload!);
+ Assert.Equal("alice", payload[0].PerformerId);
+ Assert.False(payload[0].Active);
+ }
+
+ [Fact]
+ public async Task Catalog_and_Performers_are_consistent_for_dev_activity()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient();
+
+ var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
+
+ var catalog = await catalogResponse.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(catalog);
+
+ var dev = catalog!.Single(a => a.Code == "dev");
+ Assert.True(dev.PerformerCount > 0);
+
+ var performersResponse = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, performersResponse.StatusCode);
+
+ var performers = await performersResponse.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(performers);
+ Assert.Equal(dev.PerformerCount, performers!.Count);
+ Assert.Contains(performers, p => p.PerformerId == "alice");
+ }
+
+ [Fact]
+ public async Task Catalog_does_not_list_activity_without_declaration()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient();
+
+ var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
+
+ var catalog = await catalogResponse.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(catalog);
+ Assert.DoesNotContain(catalog!, a => a.Code == "ghost");
+ Assert.Contains(catalog!, a => a.Code == "dev");
+ }
+
+ [Fact]
+ public async Task Catalog_lists_declared_activity_even_when_performer_inactive()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient();
+
+ var catalogResponse = await http.GetAsync("/api/v1/activity/catalog", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, catalogResponse.StatusCode);
+
+ var catalog = await catalogResponse.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(catalog);
+ Assert.Contains(catalog!, a => a.Code == "declared-only");
+
+ var response = await http.GetAsync("/api/v1/activity/declared-only/users", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.Single(payload!);
+ Assert.Equal("bob", payload[0].PerformerId);
+ Assert.Equal("bob", payload[0].UserName);
+ Assert.True(payload[0].HasPerformerProfile);
+ Assert.False(payload[0].Active);
+ }
+
+ [Fact]
+ public async Task Performers_alias_returns_same_payload_as_users_endpoint()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient();
+
+ var usersResponse = await http.GetAsync("/api/v1/activity/dev/users", TestContext.Current.CancellationToken);
+ var performersResponse = await http.GetAsync("/api/v1/activity/dev/performers", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, usersResponse.StatusCode);
+ Assert.Equal(HttpStatusCode.OK, performersResponse.StatusCode);
+
+ var usersPayload = await usersResponse.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ var performersPayload = await performersResponse.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+
+ Assert.NotNull(usersPayload);
+ Assert.NotNull(performersPayload);
+ Assert.Equal(usersPayload!.Count, performersPayload!.Count);
+ Assert.Equal(usersPayload[0].PerformerId, performersPayload[0].PerformerId);
+ Assert.Equal(usersPayload[0].UserName, performersPayload[0].UserName);
+ }
+}
diff --git a/src/Yavsc.Api.Test/ApiCollection.cs b/src/Yavsc.Api.Test/ApiCollection.cs
new file mode 100644
index 000000000..2c670522b
--- /dev/null
+++ b/src/Yavsc.Api.Test/ApiCollection.cs
@@ -0,0 +1,6 @@
+namespace Yavsc.Api.Test;
+
+[CollectionDefinition("Yavsc Api")]
+public sealed class ApiCollection
+{
+}
diff --git a/src/Yavsc.Api.Test/BillingControllerTests.cs b/src/Yavsc.Api.Test/BillingControllerTests.cs
new file mode 100644
index 000000000..38b9a9919
--- /dev/null
+++ b/src/Yavsc.Api.Test/BillingControllerTests.cs
@@ -0,0 +1,236 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Helpers;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Workflow;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class BillingControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public BillingControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+
+ return http;
+ }
+
+ [Fact]
+ public async Task GetProviderOngoingCommands_returns_current_provider_requests()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedActivityGraph();
+
+ using (var scope = _fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ var location = db.Locations.Single();
+
+ db.RdvQueries.Add(new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "bob",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "alice",
+ UserModified = "alice",
+ DateCreated = DateTime.UtcNow.AddMinutes(-10),
+ DateModified = DateTime.UtcNow.AddMinutes(-8),
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = location,
+ Reason = "Rendez-vous fournisseur",
+ Status = QueryStatus.InProgress,
+ Description = "Commande fournisseur en cours",
+ });
+
+ db.RdvQueries.Add(new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "alice",
+ PerformerId = "bob",
+ Consent = true,
+ UserCreated = "bob",
+ UserModified = "bob",
+ DateCreated = DateTime.UtcNow.AddMinutes(-20),
+ DateModified = DateTime.UtcNow.AddMinutes(-20),
+ EventDate = DateTime.UtcNow.AddDays(2),
+ Location = location,
+ Reason = "Commande d'un autre prestataire",
+ Status = QueryStatus.Accepted,
+ Description = "Autre prestataire",
+ });
+
+ db.SaveChanges();
+ }
+
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.NotEmpty(payload!);
+ Assert.All(payload!, item => Assert.Equal("alice", item.PerformerId));
+ Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv);
+ }
+
+ [Fact]
+ public async Task GetProviderOngoingCommands_ignores_rows_with_invalid_discriminator()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedActivityGraph();
+
+ using (var scope = _fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ db.Database.ExecuteSqlInterpolated($@"
+INSERT INTO ""NominativeServiceCommands""
+(""ActivityCode"", ""ClientId"", ""Consent"", ""DateCreated"", ""DateModified"", ""Description"", ""Discriminator"", ""PerformerId"", ""Status"", ""UserCreated"", ""UserModified"")
+VALUES
+({"dev"}, {"bob"}, {true}, {DateTime.UtcNow.AddMinutes(-5)}, {DateTime.UtcNow.AddMinutes(-4)}, {"Legacy malformed row"}, {""}, {"alice"}, {(int)QueryStatus.Accepted}, {"alice"}, {"alice"});
+");
+
+ var location = db.Locations.Single();
+ db.RdvQueries.Add(new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "bob",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "alice",
+ UserModified = "alice",
+ DateCreated = DateTime.UtcNow.AddMinutes(-3),
+ DateModified = DateTime.UtcNow.AddMinutes(-2),
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = location,
+ Reason = "Commande valide",
+ Status = QueryStatus.InProgress,
+ Description = "Commande fournisseur valide",
+ });
+
+ db.SaveChanges();
+ }
+
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.NotEmpty(payload!);
+ Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Rdv && item.PerformerId == "alice");
+ Assert.DoesNotContain(payload!, item => string.IsNullOrWhiteSpace(item.BillingCode));
+ }
+
+ [Fact]
+ public async Task GetProviderOngoingCommands_returns_haircut_and_grouped_haircut_requests()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedHaircutGraph();
+
+ using (var scope = _fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ db.UserActivities.Add(new UserActivity
+ {
+ UserId = "alice",
+ DoesCode = "brush",
+ Weight = 50,
+ });
+ db.UserActivities.Add(new UserActivity
+ {
+ UserId = "alice",
+ DoesCode = "mbrush",
+ Weight = 50,
+ });
+ db.CommandForm.Add(new CommandForm
+ {
+ ActivityCode = "brush",
+ ActionName = BillingCodes.Brush,
+ Title = "Brush",
+ });
+ db.CommandForm.Add(new CommandForm
+ {
+ ActivityCode = "mbrush",
+ ActionName = BillingCodes.MBrush,
+ Title = "MBrush",
+ });
+ db.SaveChanges();
+ }
+
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.Contains(payload!, item => item.BillingCode == BillingCodes.Brush && item.PerformerId == "alice");
+ Assert.Contains(payload!, item => item.BillingCode == BillingCodes.MBrush && item.PerformerId == "alice");
+ }
+
+ [Fact]
+ public async Task GetProviderOngoingCommands_excludes_requests_outside_performer_declared_activities()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedHaircutGraph();
+
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/bill/provider/ongoing", TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ var payload = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(payload);
+ Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.Brush);
+ Assert.DoesNotContain(payload!, item => item.BillingCode == BillingCodes.MBrush);
+ }
+
+ private sealed class ProviderOngoingCommandDto
+ {
+ public long Id { get; set; }
+ public string BillingCode { get; set; } = string.Empty;
+ public string ActivityCode { get; set; } = string.Empty;
+ public string PerformerId { get; set; } = string.Empty;
+ public string ClientId { get; set; } = string.Empty;
+ public QueryStatus Status { get; set; }
+ public string Description { get; set; } = string.Empty;
+ }
+}
diff --git a/src/Yavsc.Api.Test/Directory.Packages.props b/src/Yavsc.Api.Test/Directory.Packages.props
new file mode 100644
index 000000000..c2edf9eae
--- /dev/null
+++ b/src/Yavsc.Api.Test/Directory.Packages.props
@@ -0,0 +1,7 @@
+
+
+
+
diff --git a/src/Yavsc.Api.Test/EstimateApiControllerTests.cs b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs
new file mode 100644
index 000000000..f48f81ce5
--- /dev/null
+++ b/src/Yavsc.Api.Test/EstimateApiControllerTests.cs
@@ -0,0 +1,209 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Workflow;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class EstimateApiControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public EstimateApiControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+
+ return http;
+ }
+
+ ///
+ /// Seed a provider (alice) and a client (bob) with a pending
+ /// from bob to alice, and return the
+ /// command id.
+ ///
+ private long SeedPendingCommand()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var location = db.Locations.Single();
+
+ var query = new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "bob",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "bob",
+ UserModified = "bob",
+ DateCreated = DateTime.UtcNow.AddMinutes(-10),
+ DateModified = DateTime.UtcNow.AddMinutes(-10),
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = location,
+ Reason = "Demande de devis",
+ Status = QueryStatus.InProgress,
+ Description = "Demande en attente de devis",
+ };
+ db.RdvQueries.Add(query);
+ db.SaveChanges();
+
+ return query.Id;
+ }
+
+ private static object NewEstimatePayload(long? commandId, string clientId, string? ownerId = null)
+ => new
+ {
+ CommandId = commandId,
+ ClientId = clientId,
+ OwnerId = ownerId,
+ CommandType = BillingCodes.Rdv,
+ Title = "Devis prestation",
+ Description = "Devis détaillé",
+ AttachedFiles = Array.Empty(),
+ AttachedGraphics = Array.Empty(),
+ Bill = new[]
+ {
+ new { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
+ new { Name = "Remise", Description = "Remise fidélité", Count = 1, UnitaryCost = -20m, Currency = "EUR" },
+ },
+ };
+
+ [Fact]
+ public async Task PostEstimate_creates_the_estimate_and_validates_the_linked_command()
+ {
+ var commandId = SeedPendingCommand();
+ using var http = NewClient("alice");
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId, clientId: "bob"),
+ TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var estimateId = doc.RootElement.GetProperty("id").GetInt64();
+ Assert.True(estimateId > 0);
+ Assert.Equal(2, doc.RootElement.GetProperty("bill").GetArrayLength());
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var estimate = db.Estimates.Include(e => e.Bill).Single(e => e.Id == estimateId);
+ Assert.Equal("alice", estimate.OwnerId);
+ Assert.Equal("bob", estimate.ClientId);
+ Assert.Equal(commandId, estimate.CommandId);
+ Assert.Equal(BillingCodes.Rdv, estimate.CommandType);
+ Assert.Equal(2, estimate.Bill.Count);
+ Assert.Contains(estimate.Bill, line => line.UnitaryCost == -20m);
+
+ // PostEstimate stamps the linked command as validated.
+ var query = db.RdvQueries.Single(q => q.Id == commandId);
+ Assert.NotNull(query.ValidationDate);
+ }
+
+ [Fact]
+ public async Task PostEstimate_without_command_creates_a_standalone_estimate()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient("alice");
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId: null, clientId: "bob"),
+ TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var doc = JsonDocument.Parse(body);
+ var estimateId = doc.RootElement.GetProperty("id").GetInt64();
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var estimate = db.Estimates.Single(e => e.Id == estimateId);
+ Assert.Null(estimate.CommandId);
+ Assert.Equal("alice", estimate.OwnerId);
+ }
+
+ [Fact]
+ public async Task PostEstimate_for_another_owner_is_rejected()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient("alice");
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId: null, clientId: "bob", ownerId: "bob"),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.Empty(db.Estimates);
+ }
+
+ [Fact]
+ public async Task PostEstimate_with_an_unknown_command_id_is_rejected()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+ using var http = NewClient("alice");
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId: 999999, clientId: "bob"),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ Assert.Empty(db.Estimates);
+ }
+
+ [Fact]
+ public async Task PostEstimate_without_token_is_unauthorized()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+ using var http = new HttpClient(handler) { BaseAddress = new Uri(_fixture.BaseAddress) };
+
+ var response = await http.PostAsJsonAsync(
+ "/api/v1/estimate",
+ NewEstimatePayload(commandId: null, clientId: "bob"),
+ TestContext.Current.CancellationToken);
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ }
+}
diff --git a/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs
new file mode 100644
index 000000000..51e939ceb
--- /dev/null
+++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs
@@ -0,0 +1,494 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Metadata;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Tokens;
+using Npgsql;
+using Yavsc.Controllers;
+using Yavsc.Interfaces.Workflow;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Google.Messaging;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Messaging;
+using Yavsc.Models.Relationship;
+using Yavsc.Models.Workflow;
+using Yavsc.Services;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test.Fixtures;
+
+public sealed class ApiWebServerFixture : WebHostFixture
+{
+ private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
+ private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
+ private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
+ private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true";
+
+ protected override int HttpsPort => 5104;
+
+ private static SqliteConnection? _sharedSqliteConnection;
+ private static readonly object _sqliteLock = new();
+ private static readonly object _npgsqlLock = new();
+ private static string? _sharedNpgsqlConnectionString;
+
+ protected override WebApplication BuildApp(WebApplicationBuilder builder)
+ {
+ if (UseNpgsqlProvider())
+ {
+ var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
+ builder.Services.AddDbContext(opt =>
+ opt.UseNpgsql(npgsqlConnectionString,
+ x => x.MigrationsAssembly("Yavsc.Org")));
+ }
+ else
+ {
+ SqliteConnection sharedConnection;
+ lock (_sqliteLock)
+ {
+ if (_sharedSqliteConnection is null)
+ {
+ _sharedSqliteConnection = new SqliteConnection(
+ "Data Source=YavscApiTests;Mode=Memory;Cache=Shared");
+ _sharedSqliteConnection.Open();
+ }
+ sharedConnection = _sharedSqliteConnection;
+ }
+
+ builder.Services.AddDbContext(opt =>
+ opt.UseSqlite(sharedConnection));
+ }
+
+ builder.Services.AddControllers()
+ .AddApplicationPart(typeof(ActivityApiController).Assembly);
+
+ builder.Services.AddLocalization();
+ builder.Services.Configure(_ => { });
+ builder.Services.AddTransient();
+ builder.Services.AddTransient();
+ builder.Services.AddAuthorization();
+
+ builder.Services.AddAuthentication("Bearer")
+ .AddJwtBearer("Bearer", options =>
+ {
+ options.IncludeErrorDetails = true;
+ options.MapInboundClaims = false;
+ options.TokenValidationParameters = new TokenValidationParameters
+ {
+ ValidateIssuer = true,
+ ValidIssuer = TestTokenIssuer.Issuer,
+ ValidateAudience = false,
+ ValidateLifetime = true,
+ ValidateIssuerSigningKey = true,
+ IssuerSigningKey = TestTokenIssuer.SigningKey,
+ NameClaimType = "sub",
+ RoleClaimType = Yavsc.Constants.RoleClaimType,
+ };
+ });
+
+ return builder.Build();
+ }
+
+ protected override async Task ConfigurePipelineAsync(WebApplication app)
+ {
+ app.UseDeveloperExceptionPage();
+ app.UseRouting();
+ app.UseAuthentication();
+ app.UseAuthorization();
+ app.MapControllers();
+
+ using (var scope = app.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ if (UseNpgsqlProvider())
+ {
+ // Apply the EF Core migrations (Yavsc.Org assembly) so the
+ // test database schema matches the production provider.
+ // EnsureCreated must not be used here: it would create the
+ // schema without the migrations history and break Migrate().
+ db.Database.Migrate();
+ }
+ else
+ {
+ db.Database.EnsureCreated();
+ }
+ }
+
+ await Task.CompletedTask;
+ return app;
+ }
+
+ public string BaseAddress => Addresses.First(a => a.StartsWith("https://", StringComparison.Ordinal));
+
+ public static bool UseNpgsqlProvider()
+ => string.Equals(
+ Environment.GetEnvironmentVariable(DbProviderEnvVar),
+ "npgsql",
+ StringComparison.OrdinalIgnoreCase);
+
+ private static string EnsureNpgsqlDatabaseCreated()
+ {
+ lock (_npgsqlLock)
+ {
+ if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
+ {
+ return _sharedNpgsqlConnectionString;
+ }
+
+ var adminConnectionString = BuildAdminConnectionString();
+ var databaseName = DedicatedNpgsqlDatabaseName;
+
+ using (var adminConnection = new NpgsqlConnection(adminConnectionString))
+ {
+ adminConnection.Open();
+ using var existsCommand = adminConnection.CreateCommand();
+ existsCommand.CommandText = "SELECT 1 FROM pg_database WHERE datname = @databaseName";
+ existsCommand.Parameters.AddWithValue("databaseName", databaseName);
+
+ if (existsCommand.ExecuteScalar() is null)
+ {
+ using var createCommand = adminConnection.CreateCommand();
+ createCommand.CommandText = $"CREATE DATABASE \"{databaseName}\"";
+ createCommand.ExecuteNonQuery();
+ }
+ }
+
+ var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
+ {
+ Database = databaseName,
+ Pooling = false,
+ IncludeErrorDetail = true
+ };
+
+ _sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
+ return _sharedNpgsqlConnectionString;
+ }
+ }
+
+ private static string BuildAdminConnectionString()
+ {
+ var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
+ var source = string.IsNullOrWhiteSpace(configured)
+ ? DefaultDevelopmentConnectionString
+ : configured;
+
+ var builder = new NpgsqlConnectionStringBuilder(source)
+ {
+ Pooling = false,
+ IncludeErrorDetail = true
+ };
+
+ if (string.IsNullOrWhiteSpace(configured))
+ {
+ builder.Database = "postgres";
+ }
+ else if (string.IsNullOrWhiteSpace(builder.Database))
+ {
+ builder.Database = "postgres";
+ }
+
+ return builder.ToString();
+ }
+
+ private sealed class NoopMessageSender : IYavscMessageSender
+ {
+ public Task NotifyBookQueryAsync(IEnumerable connectionIds, RdvQueryEvent ev)
+ => Task.FromResult(new MessageWithPayloadResponse());
+
+ public Task NotifyEstimateAsync(IEnumerable connectionIds, EstimationEvent ev)
+ => Task.FromResult(new MessageWithPayloadResponse());
+
+ public Task NotifyHairCutQueryAsync(IEnumerable connectionIds, HairCutQueryEvent ev)
+ => Task.FromResult(new MessageWithPayloadResponse());
+
+ public Task NotifyAsync(IEnumerable connectionIds, IEvent yaev)
+ => Task.FromResult(new MessageWithPayloadResponse());
+ }
+
+ public void ResetAndSeedActivityGraph()
+ {
+ using var scope = Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ ResetDatabase(db);
+ db.Database.EnsureCreated();
+
+ var user = new ApplicationUser
+ {
+ Id = "alice",
+ UserName = "alice",
+ Email = "alice@example.test",
+ EmailConfirmed = true,
+ FullName = "Alice",
+ };
+ db.Users.Add(user);
+
+ var location = new Location
+ {
+ Address = "1 rue du Test",
+ Latitude = 48.8566,
+ Longitude = 2.3522,
+ };
+ db.Add(location);
+ db.SaveChanges();
+
+ var activity = new Activity
+ {
+ Code = "dev",
+ Name = "Dev",
+ Hidden = false,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ };
+ db.Activities.Add(activity);
+
+ db.Activities.Add(new Activity
+ {
+ Code = "ghost",
+ Name = "Ghost",
+ Hidden = false,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ });
+
+ var performer = new PerformerProfile
+ {
+ PerformerId = "alice",
+ SIREN = "123456789",
+ OrganizationAddressId = location.Id,
+ AcceptNotifications = true,
+ AcceptPublicContact = true,
+ Active = true,
+ Rate = 5,
+ WebSite = "https://alice.dev",
+ };
+ db.Performers.Add(performer);
+
+ db.UserActivities.Add(new UserActivity
+ {
+ UserId = "alice",
+ DoesCode = "dev",
+ Weight = 100,
+ });
+
+ db.Users.Add(new ApplicationUser
+ {
+ Id = "bob",
+ UserName = "bob",
+ Email = "bob@example.test",
+ EmailConfirmed = true,
+ FullName = "Bob",
+ });
+
+ db.Activities.Add(new Activity
+ {
+ Code = "declared-only",
+ Name = "Declared Only",
+ Hidden = false,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ });
+
+ db.Performers.Add(new PerformerProfile
+ {
+ PerformerId = "bob",
+ SIREN = "987654321",
+ OrganizationAddressId = location.Id,
+ AcceptNotifications = false,
+ AcceptPublicContact = false,
+ Active = false,
+ Rate = 0,
+ WebSite = "",
+ });
+
+ db.UserActivities.Add(new UserActivity
+ {
+ UserId = "bob",
+ DoesCode = "declared-only",
+ Weight = 10,
+ });
+
+ db.SaveChanges();
+ }
+
+ private static void ResetDatabase(ApplicationDbContext db)
+ {
+ if (UseNpgsqlProvider())
+ {
+ // Purge only the tables of the test graph, children before
+ // parents, so no DELETE violates a foreign key. Estimate and
+ // CommandLine reference NominativeServiceCommand (CommandId) and
+ // must be deleted before the Rdv/HairCut/HairMultiCut queries.
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+ db.Set().RemoveRange(db.Set());
+
+ db.SaveChanges();
+ return;
+ }
+
+ db.Database.EnsureDeleted();
+ }
+
+ public void ResetAndSeedRdvQueryGraph()
+ {
+ ResetAndSeedActivityGraph();
+
+ using var scope = Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var location = db.Locations.Single(l => l.Address == "1 rue du Test");
+
+ db.RdvQueries.Add(new RdvQuery
+ {
+ ActivityCode = "dev",
+ ClientId = "alice",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "alice",
+ UserModified = "alice",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = location,
+ Reason = "Initial rendez-vous",
+ Status = Yavsc.QueryStatus.Inserted,
+ });
+
+ db.SaveChanges();
+ }
+
+ public void ResetAndSeedHaircutGraph()
+ {
+ ResetAndSeedActivityGraph();
+
+ using var scope = Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var location = db.Locations.Single(l => l.Address == "1 rue du Test");
+
+ if (!db.Activities.Any(a => a.Code == "brush"))
+ {
+ db.Activities.Add(new Activity
+ {
+ Code = "brush",
+ Name = "Brush",
+ Hidden = false,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ });
+ }
+
+ if (!db.Activities.Any(a => a.Code == "mbrush"))
+ {
+ db.Activities.Add(new Activity
+ {
+ Code = "mbrush",
+ Name = "MBrush",
+ Hidden = false,
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ });
+ }
+
+ if (!db.BrusherProfile.Any(p => p.UserId == "alice"))
+ {
+ db.BrusherProfile.Add(new BrusherProfile
+ {
+ UserId = "alice",
+ ActionDistance = 25,
+ WomenLongCutPrice = 50m,
+ WomenHalfCutPrice = 40m,
+ WomenShortCutPrice = 30m,
+ ManCutPrice = 20m,
+ KidCutPrice = 15m,
+ ShampooPrice = 5m,
+ });
+ }
+
+ var prestation1 = new HairPrestation
+ {
+ Gender = HairCutGenders.Women,
+ Length = HairLength.HalfLong,
+ Cut = true,
+ Shampoo = true,
+ Dressing = HairDressings.Brushing,
+ Tech = HairTechnos.NoTech,
+ Cares = false,
+ Taints = new List(),
+ };
+ var prestation2 = new HairPrestation
+ {
+ Gender = HairCutGenders.Man,
+ Length = HairLength.Short,
+ Cut = true,
+ Shampoo = false,
+ Dressing = HairDressings.Brushing,
+ Tech = HairTechnos.NoTech,
+ Cares = false,
+ Taints = new List(),
+ };
+
+ db.HairPrestation.AddRange(prestation1, prestation2);
+ db.SaveChanges();
+
+ db.HairCutQueries.Add(new HairCutQuery
+ {
+ ActivityCode = "brush",
+ ClientId = "alice",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "alice",
+ UserModified = "alice",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ EventDate = DateTime.UtcNow.AddDays(3),
+ Location = location,
+ PrestationId = prestation1.Id,
+ Prestation = prestation1,
+ AdditionalInfo = "Coupe test",
+ Status = Yavsc.QueryStatus.Inserted,
+ Description = "Haircut seed",
+ });
+
+ db.HairMultiCutQueries.Add(new HairMultiCutQuery
+ {
+ ActivityCode = "mbrush",
+ ClientId = "alice",
+ PerformerId = "alice",
+ Consent = true,
+ UserCreated = "alice",
+ UserModified = "alice",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow,
+ EventDate = DateTime.UtcNow.AddDays(4),
+ Location = location,
+ Prestations = new List
+ {
+ new() { PrestationId = prestation1.Id, Prestation = prestation1 },
+ new() { PrestationId = prestation2.Id, Prestation = prestation2 },
+ },
+ Status = Yavsc.QueryStatus.Inserted,
+ });
+
+ db.SaveChanges();
+ }
+
+ public override void Dispose()
+ {
+ // Keep the shared in-memory SQLite connection alive for the
+ // whole test process. Closing it from one fixture instance can
+ // drop the schema while other tests are still running.
+ base.Dispose();
+ }
+}
diff --git a/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs
new file mode 100644
index 000000000..97ed2e61c
--- /dev/null
+++ b/src/Yavsc.Api.Test/FrontOfficeApiControllerTests.cs
@@ -0,0 +1,62 @@
+using System.Net;
+using System.Net.Http.Headers;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Helpers;
+using Yavsc.Models;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class FrontOfficeApiControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public FrontOfficeApiControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+ return http;
+ }
+
+ [Fact]
+ public async Task Front_accept_query_updates_status_without_server_error()
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ _fixture.ResetAndSeedRdvQueryGraph();
+
+ long queryId;
+ using (var scope = _fixture.Services.CreateScope())
+ {
+ var db = scope.ServiceProvider.GetRequiredService();
+ queryId = db.RdvQueries.Select(q => q.Id).Single();
+ }
+
+ using var http = NewClient();
+ var response = await http.PostAsync($"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", content: null, TestContext.Current.CancellationToken);
+ var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
+
+ using var assertScope = _fixture.Services.CreateScope();
+ var assertDb = assertScope.ServiceProvider.GetRequiredService();
+ var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
+
+ Assert.Equal(QueryStatus.Accepted, updated.Status);
+ }
+}
diff --git a/src/Yavsc.Api.Test/HairCutQueryApiControllerTests.cs b/src/Yavsc.Api.Test/HairCutQueryApiControllerTests.cs
new file mode 100644
index 000000000..b4b7e3e42
--- /dev/null
+++ b/src/Yavsc.Api.Test/HairCutQueryApiControllerTests.cs
@@ -0,0 +1,120 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.EntityFrameworkCore;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Models;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Relationship;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class HairCutQueryApiControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public HairCutQueryApiControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+ return http;
+ }
+
+ [Fact]
+ public async Task Billing_brush_route_supports_crud()
+ {
+ _fixture.ResetAndSeedHaircutGraph();
+ using var http = NewClient();
+
+ var prestationId = await GetPrestationIdAsync();
+
+ var createPayload = new HairCutQuery
+ {
+ ActivityCode = "brush",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = DateTime.UtcNow.AddDays(5),
+ Location = new Location
+ {
+ Address = "2 rue de la Coupe",
+ Latitude = 48.8570,
+ Longitude = 2.3525,
+ },
+ PrestationId = prestationId,
+ AdditionalInfo = "Brushing test",
+ Status = QueryStatus.Inserted,
+ Description = "Haircut create",
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Brush", createPayload, TestContext.Current.CancellationToken);
+ if (createResponse.StatusCode != HttpStatusCode.Created)
+ {
+ var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ Assert.Fail($"Unexpected status {createResponse.StatusCode}: {body}");
+ }
+
+ var created = await createResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(created);
+ Assert.NotEqual(0, created!.Id);
+ Assert.Equal("alice", created.ClientId);
+
+ var getResponse = await http.GetAsync($"/api/v1/billing/Brush/{created.Id}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
+
+ var fetched = await getResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(fetched);
+ Assert.Equal(created.Id, fetched!.Id);
+ Assert.Equal("Brushing test", fetched.AdditionalInfo);
+
+ fetched.AdditionalInfo = "Brushing modifié";
+ var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/Brush/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
+
+ var deleteResponse = await http.DeleteAsync($"/api/v1/billing/Brush/{fetched.Id}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
+
+ var missingResponse = await http.GetAsync($"/api/v1/billing/Brush/{fetched.Id}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
+ }
+
+ [Fact]
+ public async Task Billing_brush_route_exposes_prestation_catalog()
+ {
+ _fixture.ResetAndSeedHaircutGraph();
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/billing/Brush/prestations", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var catalog = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(catalog);
+ Assert.NotEmpty(catalog!);
+ Assert.All(catalog!, item => Assert.False(string.IsNullOrWhiteSpace(item.Title)));
+ Assert.Contains(catalog!, item => item.Title == "Femme · Cheveux mi-longs"
+ && item.Details == "Coupe · Brushing · Aucune technique spécifique · Shampoing · Sans soins");
+ }
+
+ private async Task GetPrestationIdAsync()
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.HairPrestation.Select(p => p.Id).FirstAsync(TestContext.Current.CancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api.Test/HairMultiCutQueryApiControllerTests.cs b/src/Yavsc.Api.Test/HairMultiCutQueryApiControllerTests.cs
new file mode 100644
index 000000000..8d940b8b1
--- /dev/null
+++ b/src/Yavsc.Api.Test/HairMultiCutQueryApiControllerTests.cs
@@ -0,0 +1,123 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Models;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Relationship;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class HairMultiCutQueryApiControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public HairMultiCutQueryApiControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+ return http;
+ }
+
+ [Fact]
+ public async Task Billing_mbrush_route_supports_crud()
+ {
+ _fixture.ResetAndSeedHaircutGraph();
+ using var http = NewClient();
+
+ var prestationIds = await GetPrestationIdsAsync();
+
+ var createPayload = new HairMultiCutQuery
+ {
+ ActivityCode = "mbrush",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = DateTime.UtcNow.AddDays(6),
+ Location = new Location
+ {
+ Address = "3 rue du Groupe",
+ Latitude = 48.8580,
+ Longitude = 2.3530,
+ },
+ Prestations = prestationIds.Select(id => new HairPrestationCollectionItem { PrestationId = id }).ToList(),
+ Status = QueryStatus.Inserted,
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/MBrush", createPayload, TestContext.Current.CancellationToken);
+ if (createResponse.StatusCode != HttpStatusCode.Created)
+ {
+ var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+ Assert.Fail($"Unexpected status {createResponse.StatusCode}: {body}");
+ }
+
+ var created = await createResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(created);
+ Assert.NotEqual(0, created!.Id);
+ Assert.Equal("alice", created.ClientId);
+ Assert.Equal(2, created.Prestations.Count);
+
+ var getResponse = await http.GetAsync($"/api/v1/billing/MBrush/{created.Id}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
+
+ var fetched = await getResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(fetched);
+ Assert.Equal(created.Id, fetched!.Id);
+ Assert.Equal(2, fetched.Prestations.Count);
+
+ fetched.Status = QueryStatus.Accepted;
+ var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/MBrush/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
+
+ var deleteResponse = await http.DeleteAsync($"/api/v1/billing/MBrush/{fetched.Id}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
+
+ var missingResponse = await http.GetAsync($"/api/v1/billing/MBrush/{fetched.Id}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
+ }
+
+ [Fact]
+ public async Task Billing_mbrush_route_exposes_prestation_catalog()
+ {
+ _fixture.ResetAndSeedHaircutGraph();
+ using var http = NewClient();
+
+ var response = await http.GetAsync("/api/v1/billing/MBrush/prestations", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+ var catalog = await response.Content.ReadFromJsonAsync>(TestContext.Current.CancellationToken);
+ Assert.NotNull(catalog);
+ Assert.NotEmpty(catalog!);
+ Assert.All(catalog!, item => Assert.False(string.IsNullOrWhiteSpace(item.Details)));
+ Assert.Contains(catalog!, item => item.Title == "Femme · Cheveux mi-longs"
+ && item.Details == "Coupe · Brushing · Aucune technique spécifique · Shampoing · Sans soins");
+ }
+
+ private async Task> GetPrestationIdsAsync()
+ {
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ return await db.HairPrestation
+ .OrderBy(p => p.Id)
+ .Select(p => p.Id)
+ .Take(2)
+ .ToListAsync(TestContext.Current.CancellationToken);
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api.Test/HomeControllerTests.cs b/src/Yavsc.Api.Test/HomeControllerTests.cs
new file mode 100644
index 000000000..a5706dc5f
--- /dev/null
+++ b/src/Yavsc.Api.Test/HomeControllerTests.cs
@@ -0,0 +1,69 @@
+using System.Security.Claims;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.FileProviders;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging.Abstractions;
+using Microsoft.Extensions.Options;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Controllers;
+using Yavsc.Models.Workflow;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class HomeControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public HomeControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ [Fact]
+ public async Task Index_does_not_list_activity_without_declaration()
+ {
+ _fixture.ResetAndSeedActivityGraph();
+
+ using var scope = _fixture.Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ var controller = new HomeController(
+ NullLogger.Instance,
+ localizer: null!,
+ context: db,
+ settingsOptions: Options.Create(new SiteSettings()),
+ env: new TestEnvironment());
+
+ controller.ControllerContext = new ControllerContext
+ {
+ HttpContext = new DefaultHttpContext
+ {
+ User = new ClaimsPrincipal(new ClaimsIdentity(new[]
+ {
+ new Claim("sub", "alice"),
+ new Claim(ClaimTypes.NameIdentifier, "alice")
+ }, "Bearer"))
+ }
+ };
+
+ var result = await controller.Index(id: null);
+ var view = Assert.IsType(result);
+ var model = Assert.IsAssignableFrom>(view.Model);
+
+ Assert.Contains(model, a => a.Code == "dev");
+ Assert.DoesNotContain(model, a => a.Code == "ghost");
+ }
+
+ private sealed class TestEnvironment : IWebHostEnvironment
+ {
+ public string ApplicationName { get; set; } = "Yavsc.Api.Test";
+ public IFileProvider WebRootFileProvider { get; set; } = null!;
+ public string WebRootPath { get; set; } = string.Empty;
+ public string EnvironmentName { get; set; } = "Development";
+ public string ContentRootPath { get; set; } = string.Empty;
+ public IFileProvider ContentRootFileProvider { get; set; } = null!;
+ }
+}
diff --git a/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs
new file mode 100644
index 000000000..c3e4aad2b
--- /dev/null
+++ b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs
@@ -0,0 +1,229 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using Yavsc;
+using Yavsc.Api.Test.Fixtures;
+using Yavsc.Models.Workflow;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test;
+
+[Collection("Yavsc Api")]
+public sealed class RdvQueryApiControllerTests : IClassFixture
+{
+ private readonly ApiWebServerFixture _fixture;
+
+ public RdvQueryApiControllerTests(ApiWebServerFixture fixture)
+ {
+ _fixture = fixture;
+ }
+
+ private HttpClient NewClient(string subject = "alice", string scope = "api")
+ {
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (_, _, _, _) => true
+ };
+
+ var http = new HttpClient(handler)
+ {
+ BaseAddress = new Uri(_fixture.BaseAddress)
+ };
+ http.DefaultRequestHeaders.Authorization =
+ new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
+ return http;
+ }
+
+ [Fact]
+ public async Task Billing_rdv_route_supports_crud()
+ {
+ _fixture.ResetAndSeedRdvQueryGraph();
+ using var http = NewClient();
+
+ var createPayload = new RdvQuery
+ {
+ ActivityCode = "dev",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = DateTime.UtcNow.AddDays(2),
+ Location = new Yavsc.Models.Relationship.Location
+ {
+ Address = "1 rue du Test",
+ Latitude = 48.8566,
+ Longitude = 2.3522,
+ },
+ Reason = "Second rendez-vous",
+ Status = QueryStatus.Inserted,
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
+
+ var created = await createResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(created);
+ Assert.NotEqual(0, created!.Id);
+ Assert.Equal("alice", created.ClientId);
+
+ var getResponse = await http.GetAsync($"/api/v1/billing/Rdv/{created.Id}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode);
+
+ var fetched = await getResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(fetched);
+ Assert.Equal(created.Id, fetched!.Id);
+ Assert.Equal("Second rendez-vous", fetched.Reason);
+
+ fetched.Reason = "Rendez-vous modifié";
+ var putResponse = await http.PutAsJsonAsync($"/api/v1/billing/Rdv/{fetched.Id}", fetched, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NoContent, putResponse.StatusCode);
+
+ var deleteResponse = await http.DeleteAsync($"/api/v1/billing/Rdv/{fetched.Id}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, deleteResponse.StatusCode);
+
+ var missingResponse = await http.GetAsync($"/api/v1/billing/Rdv/{fetched.Id}", TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.NotFound, missingResponse.StatusCode);
+ }
+
+ [Fact]
+ public async Task PostQuery_ignores_client_field_and_uses_authenticated_user()
+ {
+ _fixture.ResetAndSeedRdvQueryGraph();
+ using var http = NewClient(subject: "alice");
+
+ var createPayload = new
+ {
+ ActivityCode = "dev",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = DateTime.UtcNow.AddDays(2),
+ Location = new
+ {
+ Address = "2 rue du Test",
+ Latitude = 48.8567,
+ Longitude = 2.3523,
+ },
+ Reason = "Rendez-vous sans champ client",
+ Status = QueryStatus.Inserted,
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
+
+ var created = await createResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(created);
+ Assert.Equal("alice", created!.ClientId);
+ }
+
+ [Fact]
+ public async Task PostQuery_accepts_local_datetime_and_persists_as_utc()
+ {
+ _fixture.ResetAndSeedRdvQueryGraph();
+ using var http = NewClient(subject: "alice");
+
+ var localEventDate = DateTime.Now.AddDays(2);
+ var createPayload = new
+ {
+ ActivityCode = "dev",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = localEventDate,
+ Location = new
+ {
+ Address = "3 rue du Test",
+ Latitude = 48.8568,
+ Longitude = 2.3524,
+ },
+ Reason = "Rendez-vous date locale",
+ Status = QueryStatus.Inserted,
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, createResponse.StatusCode);
+
+ var created = await createResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(created);
+ Assert.Equal(DateTimeKind.Utc, created!.EventDate.Kind);
+ }
+
+ [Fact]
+ public async Task PostQuery_with_unknown_location_id_creates_location_and_succeeds()
+ {
+ _fixture.ResetAndSeedRdvQueryGraph();
+ using var http = NewClient(subject: "alice");
+
+ var createPayload = new
+ {
+ ActivityCode = "dev",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = DateTime.UtcNow.AddDays(3),
+ Location = new
+ {
+ Id = 999999L,
+ Address = "4 rue du Test",
+ Latitude = 48.8569,
+ Longitude = 2.3525,
+ },
+ Reason = "Rendez-vous id location inconnu",
+ Status = QueryStatus.Inserted,
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
+ var body = await createResponse.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
+
+ Assert.True(createResponse.StatusCode == HttpStatusCode.Created, $"Unexpected status {(int)createResponse.StatusCode} ({createResponse.StatusCode}): {body}");
+
+ var created = await createResponse.Content.ReadFromJsonAsync(TestContext.Current.CancellationToken);
+ Assert.NotNull(created);
+ Assert.NotNull(created!.Location);
+ Assert.True(created.Location.Id > 0);
+ Assert.NotEqual(999999L, created.Location.Id);
+ Assert.Equal("alice", created.ClientId);
+ }
+
+ [Fact]
+ public async Task PostQuery_without_location_returns_bad_request()
+ {
+ _fixture.ResetAndSeedRdvQueryGraph();
+ using var http = NewClient(subject: "alice");
+
+ var createPayload = new
+ {
+ ActivityCode = "dev",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Reason = "Rendez-vous sans location",
+ Status = QueryStatus.Inserted,
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
+ }
+
+ [Fact]
+ public async Task PostQuery_with_unknown_location_id_and_missing_address_returns_bad_request()
+ {
+ _fixture.ResetAndSeedRdvQueryGraph();
+ using var http = NewClient(subject: "alice");
+
+ var createPayload = new
+ {
+ ActivityCode = "dev",
+ PerformerId = "alice",
+ Consent = true,
+ EventDate = DateTime.UtcNow.AddDays(1),
+ Location = new
+ {
+ Id = 777777L,
+ Address = "",
+ Latitude = 0.0,
+ Longitude = 0.0,
+ },
+ Reason = "Rendez-vous location invalide",
+ Status = QueryStatus.Inserted,
+ };
+
+ var createResponse = await http.PostAsJsonAsync("/api/v1/billing/Rdv", createPayload, TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.BadRequest, createResponse.StatusCode);
+ }
+}
diff --git a/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj
new file mode 100644
index 000000000..760f5ce84
--- /dev/null
+++ b/src/Yavsc.Api.Test/Yavsc.Api.Test.csproj
@@ -0,0 +1,33 @@
+
+
+ net10.0
+ enable
+ enable
+ false
+ Yavsc.Api.Test
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs
index 6d91ba826..aef393868 100644
--- a/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/ActivityApiController.cs
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Yavsc.Abstract.Workflow;
using Yavsc.Server.Helpers;
using Yavsc.Models;
using Yavsc.Models.Workflow;
@@ -8,6 +9,7 @@ using Yavsc.Models.Workflow;
namespace Yavsc.Controllers
{
+ [Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/activity")]
public class ActivityApiController : Controller
@@ -26,6 +28,127 @@ namespace Yavsc.Controllers
return _context.Activities.Include(a=>a.Forms).Where( a => !a.Hidden );
}
+ [HttpGet("catalog")]
+ public async Task>> GetCatalog(
+ CancellationToken cancellationToken,
+ [FromQuery] string parentCode = null)
+ {
+ var activities = await _context.Activities
+ .AsNoTracking()
+ .Include(a => a.Forms)
+ .Include(a => a.Children)
+ .ThenInclude(c => c.Forms)
+ .Where(a => !a.Hidden && a.ParentCode == parentCode)
+ .OrderByDescending(a => a.Rate)
+ .ToListAsync(cancellationToken);
+
+ var codes = activities
+ .Select(a => a.Code)
+ .Concat(activities.SelectMany(a => (a.Children ?? new List())
+ .Where(c => !c.Hidden)
+ .Select(c => c.Code)))
+ .Where(c => !string.IsNullOrWhiteSpace(c))
+ .Distinct()
+ .ToArray();
+
+ // Some providers are brittle when translating Contains over an
+ // empty in-memory array. If there is no candidate activity code,
+ // the catalog is empty by definition.
+ if (codes.Length == 0)
+ {
+ return Ok(new List());
+ }
+
+ var performerCounts = await (
+ from ua in _context.UserActivities.AsNoTracking()
+ where !string.IsNullOrWhiteSpace(ua.DoesCode) && codes.Contains(ua.DoesCode)
+ group ua by ua.DoesCode into g
+ select new
+ {
+ Code = g.Key,
+ Count = g.Select(x => x.UserId).Distinct().Count()
+ })
+ .ToDictionaryAsync(x => x.Code, x => x.Count, cancellationToken);
+
+ var filteredActivities = activities
+ .Where(a =>
+ (TryGetPerformerCount(performerCounts, a.Code, out var ownCount) && ownCount > 0)
+ || (a.Children ?? new List())
+ .Where(c => !c.Hidden)
+ .Any(c => TryGetPerformerCount(performerCounts, c.Code, out var childCount) && childCount > 0))
+ .ToList();
+
+ return Ok(filteredActivities.Select(a => ToBrowseItem(a, performerCounts)).ToList());
+ }
+
+ [HttpGet("{id}/users")]
+ public async Task>> GetUsers(
+ [FromRoute] string id,
+ CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(id))
+ {
+ return BadRequest("Activity code is required.");
+ }
+
+ var activity = await _context.Activities
+ .AsNoTracking()
+ .SingleOrDefaultAsync(a => a.Code == id, cancellationToken);
+ if (activity is null)
+ {
+ return NotFound();
+ }
+
+ var users = await QueryDeclaredUsersAsync(id, activity.Name, cancellationToken);
+
+ return Ok(users);
+ }
+
+ [HttpGet("{id}/performers")]
+ public Task>> GetPerformers(
+ [FromRoute] string id,
+ CancellationToken cancellationToken)
+ {
+ // Backward-compatible alias kept for existing clients.
+ return GetUsers(id, cancellationToken);
+ }
+
+ private Task> QueryDeclaredUsersAsync(
+ string activityCode,
+ string activityName,
+ CancellationToken cancellationToken)
+ {
+ return (
+ from ua in _context.UserActivities.AsNoTracking()
+ join u in _context.ApplicationUser.AsNoTracking() on ua.UserId equals u.Id into users
+ from user in users.DefaultIfEmpty()
+ join p in _context.Performers.AsNoTracking() on ua.UserId equals p.PerformerId into performerProfiles
+ from performer in performerProfiles.DefaultIfEmpty()
+ where ua.DoesCode == activityCode
+ orderby user != null ? user.UserName : ua.UserId
+ select new PerformerActivity
+ {
+ PerformerId = ua.UserId,
+ HasPerformerProfile = performer != null,
+ UserName = user != null ? (user.UserName ?? string.Empty) : string.Empty,
+ Active = performer != null && performer.Active,
+ AcceptNotifications = performer != null && performer.AcceptNotifications,
+ AcceptPublicContact = performer != null && performer.AcceptPublicContact,
+ WebSite = performer != null ? (performer.WebSite ?? string.Empty) : string.Empty,
+ ActivityCode = activityCode,
+ ActivityName = activityName,
+ SettingsClassName = _context.Activities
+ .Where(a => a.Code == activityCode)
+ .Select(a => a.SettingsClassName)
+ .FirstOrDefault() ?? string.Empty,
+ ExtraActivityCount = _context.UserActivities
+ .Where(x => x.UserId == ua.UserId && x.DoesCode != activityCode)
+ .Count()
+ })
+ .Distinct()
+ .ToListAsync(cancellationToken);
+ }
+
// GET: api/ActivityApi/5
[HttpGet("{id}", Name = "GetActivity")]
public async Task GetActivity([FromRoute] string id)
@@ -144,5 +267,66 @@ namespace Yavsc.Controllers
{
return _context.Activities.Count(e => e.Code == id) > 0;
}
+
+ private static ActivityInfo ToBrowseItem(
+ Activity activity,
+ IReadOnlyDictionary performerCounts)
+ {
+ return new ActivityInfo
+ {
+ Code = activity.Code,
+ Name = activity.Name,
+ ParentCode = activity.ParentCode,
+ Description = activity.Description ?? string.Empty,
+ Photo = activity.Photo,
+ Rate = activity.Rate,
+ PerformerCount = TryGetPerformerCount(performerCounts, activity.Code, out var count) ? count : 0,
+ Forms = (activity.Forms ?? Enumerable.Empty())
+ .Select(f => new CommandFormSummary
+ {
+ Id = f.Id,
+ ActionName = f.ActionName,
+ Title = f.Title,
+ })
+ .ToList(),
+ Children = (activity.Children ?? Enumerable.Empty())
+ .Where(c => !c.Hidden)
+ .Where(c => TryGetPerformerCount(performerCounts, c.Code, out var childCount) && childCount > 0)
+ .OrderByDescending(c => c.Rate)
+ .Select(c => new ActivityInfo
+ {
+ Code = c.Code,
+ Name = c.Name,
+ ParentCode = c.ParentCode,
+ Description = c.Description ?? string.Empty,
+ Photo = c.Photo,
+ Rate = c.Rate,
+ PerformerCount = TryGetPerformerCount(performerCounts, c.Code, out var childCount) ? childCount : 0,
+ Forms = (c.Forms ?? Enumerable.Empty())
+ .Select(f => new CommandFormSummary
+ {
+ Id = f.Id,
+ ActionName = f.ActionName,
+ Title = f.Title,
+ })
+ .ToList(),
+ })
+ .ToList(),
+ };
+ }
+
+ private static bool TryGetPerformerCount(
+ IReadOnlyDictionary performerCounts,
+ string code,
+ out int count)
+ {
+ if (string.IsNullOrWhiteSpace(code))
+ {
+ count = 0;
+ return false;
+ }
+
+ return performerCounts.TryGetValue(code, out count);
+ }
}
}
diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs
index 72180fc1b..d10e166e3 100644
--- a/src/Yavsc.Api/Controllers/Business/BillingController.cs
+++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs
@@ -3,9 +3,12 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Newtonsoft.Json;
using System.Security.Claims;
+using Yavsc.Billing;
using Yavsc.Helpers;
using Yavsc.ViewModels;
using Yavsc.Models.Billing;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Workflow;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.ApiControllers
@@ -19,6 +22,7 @@ namespace Yavsc.ApiControllers
using Yavsc.ViewModels.Auth;
using Yavsc.Server.Helpers;
+ [Authorize]
[Route(Constants.APIPrefix + "/bill"), Authorize]
public class BillingController : Controller
{
@@ -99,6 +103,114 @@ namespace Yavsc.ApiControllers
return ViewComponent("Bill",new object[] { billingCode, bill, OutputFormat.Pdf, true } );
}
+ ///
+ /// Lists ongoing service commands for the authenticated performer.
+ /// This endpoint is tailored for the PostIt provider homepage flow
+ /// ("Mes demandes en cours").
+ ///
+ [HttpGet("provider/ongoing")]
+ [Produces("application/json")]
+ public IActionResult GetProviderOngoingCommands()
+ {
+ var uid = User.GetUserId();
+ if (string.IsNullOrWhiteSpace(uid))
+ {
+ return Unauthorized();
+ }
+
+ if (billingService.BillingMap.Count == 0)
+ {
+ WorkflowHelpers.ConfigureBillingService();
+ }
+
+ var allowedActivityCodes = dbContext.UserActivities
+ .AsNoTracking()
+ .Where(a => a.UserId == uid)
+ .Select(a => a.DoesCode)
+ .Distinct()
+ .ToList();
+
+ if (allowedActivityCodes.Count == 0)
+ {
+ return Ok(Array.Empty