diff --git a/.forgejo/workflows/buildAndTest.yml b/.forgejo/workflows/buildAndTest.yml
index ea58d2fef..2f14e449e 100644
--- a/.forgejo/workflows/buildAndTest.yml
+++ b/.forgejo/workflows/buildAndTest.yml
@@ -48,4 +48,4 @@ jobs:
--verbosity normal \
--filter="Category!=Platform-Android" \
--logger "xunit;LogFileName=test-results.xml" \
- && echo "✅ Success !" || echo "❌ Fail ($?)!"
+ && echo "✅ Success !" || { echo "❌ Fail ($?)!"; exit 1; }
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/.vscode/tasks.json b/.vscode/tasks.json
index fd46437ac..a30c9c065 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,
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d64fe9f7f..111c1491a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,147 @@
# 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
+
+nothing
+
+### Changed
+
+masquage non-owner côté backend de l'ACL du billet
+
+### Fixed
+
+On a maintenant le comportement attendu bout en bout:
+
+ACL chargée depuis le BlogPostDto
+noms de cercles affichés dans le dialogue ACL côté PostIt
+
+## [1.0.8-rc8] - unstable
+
+### Added
+
+nothing
+
+### Changed
+
+nothing
+
+### Fixed
+
+The PostIt publish toggle button
+
## [1.0.8-rc7] - 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/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..79145668f 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,6 +89,7 @@ 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)
@@ -92,4 +98,3 @@ showConfig:
clean:
@rm -rf generated
-.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean
diff --git a/contrib/README.md b/contrib/README.md
new file mode 100644
index 000000000..011561560
--- /dev/null
+++ b/contrib/README.md
@@ -0,0 +1,5 @@
+# Read me
+
+## Note aux icones
+
+㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩
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..f44c098bd
--- /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/MainPage.axaml b/src/PostIt/PostIt/Views/Blogs/MainPage.axaml
similarity index 91%
rename from src/PostIt/PostIt/Views/MainPage.axaml
rename to src/PostIt/PostIt/Views/Blogs/MainPage.axaml
index 0d2f5411f..871a2f541 100644
--- a/src/PostIt/PostIt/Views/MainPage.axaml
+++ b/src/PostIt/PostIt/Views/Blogs/MainPage.axaml
@@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels"
+ xmlns:postitControls="using:PostIt.Controls"
xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
@@ -24,13 +25,13 @@
-
-
-
-
-
+
+
+
+
-
-
+
+
-
+
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml.cs b/src/PostIt/PostIt/Views/Blogs/MainPage.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/MainPage.axaml.cs
rename to src/PostIt/PostIt/Views/Blogs/MainPage.axaml.cs
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 82%
rename from src/PostIt/PostIt/Views/HomePage.axaml
rename to src/PostIt/PostIt/Views/Layout/HomePage.axaml
index 16e66cc00..7f6527e3d 100644
--- a/src/PostIt/PostIt/Views/HomePage.axaml
+++ b/src/PostIt/PostIt/Views/Layout/HomePage.axaml
@@ -18,5 +18,9 @@
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 100%
rename from src/PostIt/PostIt/Views/MainView.axaml
rename to src/PostIt/PostIt/Views/Layout/MainView.axaml
diff --git a/src/PostIt/PostIt/Views/MainView.axaml.cs b/src/PostIt/PostIt/Views/Layout/MainView.axaml.cs
similarity index 100%
rename from src/PostIt/PostIt/Views/MainView.axaml.cs
rename to src/PostIt/PostIt/Views/Layout/MainView.axaml.cs
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/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
index 6d8da8faf..e23839b1f 100644
--- a/src/PostIt/PostIt/postit-settings sample.json
+++ b/src/PostIt/PostIt/postit-settings sample.json
@@ -5,7 +5,7 @@
},
"RedirectUri": "postit://callback",
"DarkMode": false,
- "ApiUrl": "https://blogs.pschneider.fr/api/v1/",
+ "ApiUrl": "https://api.pschneider.fr/api/v1/",
"Scopes": [
"openid",
"profile",
diff --git a/src/PostIt/PostIt/postit-settings.json b/src/PostIt/PostIt/postit-settings.json
index 079479515..080fed178 100644
--- a/src/PostIt/PostIt/postit-settings.json
+++ b/src/PostIt/PostIt/postit-settings.json
@@ -5,7 +5,7 @@
},
"RedirectUri": "postit://callback",
"DarkMode": true,
- "ApiUrl": "https://blogs.pschneider.fr/api/v1/",
+ "ApiUrl": "https://api.pschneider.fr/api/v1/",
"Scopes": [
"openid",
"profile",
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 0da052aeb..bdb2ccdc8 100644
--- a/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs
+++ b/src/Yavsc.Abstract/Blogspot/BlogPostDto.cs
@@ -1,4 +1,7 @@
+#nullable enable annotations
+
using Yavsc.Abstract.Identity.Security;
+using System.Text.Json.Serialization;
namespace Yavsc.Blogspot;
@@ -35,11 +38,26 @@ public class BlogPostDto : IBlogPost
return true;
}
- private List ACL { get; set; } = new List();
+ public ICollection ACL = new List();
+
+ ///
+ /// Wire-only ACL bridge for System.Text.Json: accepts the
+ /// acl/ACL payload from GET detail responses,
+ /// but is never emitted on POST/PUT from the client.
+ ///
+ [JsonPropertyName("acl")]
+ [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
+ public List? WireAcl
+ {
+ get => null;
+ set => ACL = value ?? new List();
+ }
public string[] Tags { get; set; }
+ ICollection ICircleAuthorized.ACL => this.ACL;
+
public string[] GetTags() => Tags;
- public ICircleAuthorization[] GetACL() => ACL.ToArray();
+ public CircleAuthorization[] GetACL() => ACL.ToArray();
}
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/Blogspot/PostAccessControlRulePayload.cs b/src/Yavsc.Abstract/Blogspot/PostAccessControlRulePayload.cs
index c58fca488..a42e54285 100644
--- a/src/Yavsc.Abstract/Blogspot/PostAccessControlRulePayload.cs
+++ b/src/Yavsc.Abstract/Blogspot/PostAccessControlRulePayload.cs
@@ -3,8 +3,7 @@ using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Abstract.BlogSpot;
-public class PostAccessControlRulePayload : ICircleAuthorization
+public class PostAccessControlRulePayload : CircleAuthorization
{
- public long CircleId { get; set; }
public long BlogPostId { get; set; }
}
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/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/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/CircleAuthorization.cs b/src/Yavsc.Abstract/Identity/Security/CircleAuthorization.cs
index d8b08c056..96392c7bf 100644
--- a/src/Yavsc.Abstract/Identity/Security/CircleAuthorization.cs
+++ b/src/Yavsc.Abstract/Identity/Security/CircleAuthorization.cs
@@ -11,7 +11,7 @@ namespace Yavsc.Abstract.Identity.Security;
/// UI already has the post, and the circles are looked up by id
/// against the list returned by GET /api/circle.
///
-public sealed class CircleAuthorization : ICircleAuthorization
+public class CircleAuthorization
{
public long CircleId { get; set; }
}
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/Security/ICircleAuthorization.cs b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs
deleted file mode 100644
index 9c16bd3b1..000000000
--- a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorization.cs
+++ /dev/null
@@ -1,8 +0,0 @@
-namespace Yavsc.Abstract.Identity.Security
-{
-
- public interface ICircleAuthorization
- {
- long CircleId { get; set; }
- }
-}
diff --git a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs
index 25c21961d..6b593f3c2 100644
--- a/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs
+++ b/src/Yavsc.Abstract/Identity/Security/ICircleAuthorized.cs
@@ -9,7 +9,7 @@ namespace Yavsc.Abstract.Identity.Security
bool AuthorizeCircle(long circleId);
- ICircleAuthorization [] GetACL();
+ ICollection ACL { get; } //ICircleAuthorization [] GetACL();
}
}
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.Abstract/Yavsc.Abstract.csproj b/src/Yavsc.Abstract/Yavsc.Abstract.csproj
index ee0315d0c..78ffe531e 100644
--- a/src/Yavsc.Abstract/Yavsc.Abstract.csproj
+++ b/src/Yavsc.Abstract/Yavsc.Abstract.csproj
@@ -11,7 +11,7 @@
latest
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
1.1.0-beta.1
\ 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..c3a0be103
--- /dev/null
+++ b/src/Yavsc.Api.Client/BillingApiClient.cs
@@ -0,0 +1,378 @@
+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 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
+ {
+ 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
+ {
+ 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
+ {
+ 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.Latitude.HasValue)
+ {
+ payload["Latitude"] = location.Latitude.Value;
+ }
+
+ if (location.Longitude.HasValue)
+ {
+ payload["Longitude"] = location.Longitude.Value;
+ }
+
+ return payload;
+ }
+
+ private sealed class BillingLocationResponse
+ {
+ 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/Dtos/BillingQueryDetailsDto.cs b/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs
new file mode 100644
index 000000000..6d658ce49
--- /dev/null
+++ b/src/Yavsc.Api.Client/Dtos/BillingQueryDetailsDto.cs
@@ -0,0 +1,35 @@
+using System;
+using System.Collections.Generic;
+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
+{
+ public string Address { get; set; } = string.Empty;
+ public double? Latitude { get; set; }
+ 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/Yavsc.Api.Client.csproj b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
index e1a1f8b85..6ceaab113 100644
--- a/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
+++ b/src/Yavsc.Api.Client/Yavsc.Api.Client.csproj
@@ -17,7 +17,7 @@
true
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
1.1.0-beta.1
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/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/Fixtures/ApiWebServerFixture.cs b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs
new file mode 100644
index 000000000..e2a8b3376
--- /dev/null
+++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs
@@ -0,0 +1,355 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.IdentityModel.Tokens;
+using Yavsc.Controllers;
+using Yavsc.Models;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Relationship;
+using Yavsc.Models.Workflow;
+using Yavsc.Tests.Shared;
+
+namespace Yavsc.Api.Test.Fixtures;
+
+public sealed class ApiWebServerFixture : WebHostFixture
+{
+ protected override int HttpsPort => 5104;
+
+ private static SqliteConnection? _sharedSqliteConnection;
+ private static readonly object _sqliteLock = new();
+
+ protected override WebApplication BuildApp(WebApplicationBuilder builder)
+ {
+ 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.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();
+ db.Database.EnsureCreated();
+ }
+
+ await Task.CompletedTask;
+ return app;
+ }
+
+ public string BaseAddress => Addresses.First(a => a.StartsWith("https://", StringComparison.Ordinal));
+
+ public void ResetAndSeedActivityGraph()
+ {
+ using var scope = Services.CreateScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+
+ db.Database.EnsureDeleted();
+ 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();
+ }
+
+ 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()
+ {
+ try
+ {
+ base.Dispose();
+ }
+ finally
+ {
+ lock (_sqliteLock)
+ {
+ if (_sharedSqliteConnection is not null)
+ {
+ _sharedSqliteConnection.Close();
+ _sharedSqliteConnection.Dispose();
+ _sharedSqliteConnection = null;
+ }
+ }
+ }
+ }
+}
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..668814239
--- /dev/null
+++ b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs
@@ -0,0 +1,146 @@
+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);
+ }
+}
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..197e06b11 100644
--- a/src/Yavsc.Api/Controllers/Business/BillingController.cs
+++ b/src/Yavsc.Api/Controllers/Business/BillingController.cs
@@ -19,6 +19,7 @@ namespace Yavsc.ApiControllers
using Yavsc.ViewModels.Auth;
using Yavsc.Server.Helpers;
+ [Authorize]
[Route(Constants.APIPrefix + "/bill"), Authorize]
public class BillingController : Controller
{
diff --git a/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs
index 0d7112ec1..293cca9ae 100644
--- a/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/BookQueryApiController.cs
@@ -12,6 +12,7 @@ namespace Yavsc.Controllers
using Microsoft.EntityFrameworkCore;
using Yavsc.Server.Helpers;
+ [Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/bookquery"), Authorize("Performer")]
public class BookQueryApiController : Controller
diff --git a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs
index 891909f0f..b01819f25 100644
--- a/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/EstimateApiController.cs
@@ -9,6 +9,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
+ [Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/estimate"), Authorize]
public class EstimateApiController : Controller
diff --git a/src/Yavsc.Api/Controllers/Business/EstimateTemplatesApiController.cs b/src/Yavsc.Api/Controllers/Business/EstimateTemplatesApiController.cs
index 3fc913656..748f60cc6 100644
--- a/src/Yavsc.Api/Controllers/Business/EstimateTemplatesApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/EstimateTemplatesApiController.cs
@@ -1,4 +1,5 @@
using System.Security.Claims;
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
@@ -7,6 +8,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
+ [Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/EstimateTemplatesApi")]
public class EstimateTemplatesApiController : Controller
diff --git a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs
index 91d57d9f8..3adac5b03 100644
--- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs
@@ -1,11 +1,14 @@
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Services;
+using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice;
namespace Yavsc.ApiControllers
{
+ [Authorize]
[Route(Constants.APIPrefix + "/front")]
public class FrontOfficeApiController : Controller
{
@@ -13,10 +16,10 @@ namespace Yavsc.ApiControllers
private IBillingService billing;
- public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing)
+ public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing = null)
{
dbContext = context;
- this.billing = billing;
+ this.billing = billing ?? new BillingService(context);
}
[HttpGet("profiles/{actCode}")]
@@ -34,7 +37,7 @@ namespace Yavsc.ApiControllers
if (billing == null) return BadRequest();
billing.Status = QueryStatus.Rejected;
- dbContext.SaveChanges();
+ dbContext.SaveChanges(User.GetUserId());
return Ok();
}
@@ -46,7 +49,7 @@ namespace Yavsc.ApiControllers
var billing = BillingService.GetBillable(dbContext, billingCode, queryId);
if (billing == null) return BadRequest();
billing.Status = QueryStatus.Accepted;
- dbContext.SaveChanges();
+ dbContext.SaveChanges(User.GetUserId());
return Ok();
}
}
diff --git a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs
new file mode 100644
index 000000000..7f445fdd9
--- /dev/null
+++ b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs
@@ -0,0 +1,232 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Relationship;
+using Yavsc.Server.Helpers;
+
+namespace Yavsc.Controllers;
+
+[Authorize]
+[Produces("application/json")]
+[Route(Constants.APIPrefix + "/billing/" + BillingCodes.Brush)]
+public class HairCutQueryApiController : Controller
+{
+ private readonly ApplicationDbContext _context;
+
+ public HairCutQueryApiController(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task GetQueries(CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+
+ var queries = await _context.HairCutQueries
+ .AsNoTracking()
+ .Include(q => q.Prestation)
+ .Include(q => q.Location)
+ .Include(q => q.Client)
+ .Include(q => q.PerformerProfile)
+ .Where(q => q.ClientId == uid || q.PerformerId == uid)
+ .OrderByDescending(q => q.Id)
+ .ToListAsync(cancellationToken);
+
+ return Ok(queries);
+ }
+
+ [HttpGet("prestations")]
+ public async Task GetPrestations(CancellationToken cancellationToken)
+ {
+ var prestations = await _context.HairPrestation
+ .AsNoTracking()
+ .OrderBy(p => p.Gender)
+ .ThenBy(p => p.Length)
+ .ThenBy(p => p.Tech)
+ .Select(p => ToDto(p))
+ .ToListAsync(cancellationToken);
+
+ return Ok(prestations);
+ }
+
+ [HttpGet("{id}", Name = "GetBillingHairCutQuery")]
+ public async Task GetQuery([FromRoute] long id, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+
+ var query = await _context.HairCutQueries
+ .Include(q => q.Prestation)
+ .Include(q => q.Location)
+ .Include(q => q.Client)
+ .Include(q => q.PerformerProfile)
+ .SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
+
+ if (query is null)
+ {
+ return NotFound();
+ }
+
+ if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ return Ok(query);
+ }
+
+ [HttpPost]
+ public async Task PostQuery([FromBody] HairCutQuery query, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+ query.ClientId = uid;
+
+ ModelState.Remove("Client");
+ ModelState.Remove("ClientId");
+ ModelState.Remove("UserCreated");
+ ModelState.Remove("UserModified");
+ ModelState.Remove("SelectedProfile");
+ ModelState.Remove("Prestation");
+ ModelState.Remove("PerformerProfile");
+ ModelState.Remove("Context");
+ ModelState.Remove("Regularization");
+
+ query.Prestation = await _context.HairPrestation
+ .SingleOrDefaultAsync(p => p.Id == query.PrestationId, cancellationToken);
+ if (query.Prestation is null)
+ {
+ ModelState.AddModelError("PrestationId", "Unknown hair prestation.");
+ return BadRequest(ModelState);
+ }
+
+ if (!ModelState.IsValid)
+ {
+ return BadRequest(ModelState);
+ }
+
+ query.Location = await ResolveLocationAsync(query.Location, cancellationToken);
+
+ _context.HairCutQueries.Add(query);
+
+ try
+ {
+ await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
+ }
+ catch (DbUpdateException)
+ {
+ if (QueryExists(query.Id))
+ {
+ return Conflict();
+ }
+
+ throw;
+ }
+
+ return CreatedAtRoute("GetBillingHairCutQuery", new { id = query.Id }, query);
+ }
+
+ [HttpPut("{id}")]
+ public async Task PutQuery([FromRoute] long id, [FromBody] HairCutQuery query, CancellationToken cancellationToken)
+ {
+ var existing = await _context.HairCutQueries
+ .Include(q => q.Location)
+ .SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
+
+ if (existing is null)
+ {
+ return NotFound();
+ }
+
+ var uid = User.GetUserId();
+ if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ var prestation = await _context.HairPrestation
+ .SingleOrDefaultAsync(p => p.Id == query.PrestationId, cancellationToken);
+ if (prestation is null)
+ {
+ ModelState.AddModelError("PrestationId", "Unknown hair prestation.");
+ return BadRequest(ModelState);
+ }
+
+ existing.ActivityCode = query.ActivityCode;
+ existing.PerformerId = query.PerformerId;
+ existing.Consent = query.Consent;
+ existing.EventDate = query.EventDate;
+ existing.AdditionalInfo = query.AdditionalInfo;
+ existing.Status = query.Status;
+ existing.Provisional = query.Provisional;
+ existing.PrestationId = prestation.Id;
+ existing.Prestation = prestation;
+ existing.Location = await ResolveLocationAsync(query.Location, cancellationToken);
+
+ await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
+ return NoContent();
+ }
+
+ [HttpDelete("{id}")]
+ public async Task DeleteQuery([FromRoute] long id, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+
+ var query = await _context.HairCutQueries
+ .SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
+
+ if (query is null)
+ {
+ return NotFound();
+ }
+
+ if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ _context.HairCutQueries.Remove(query);
+ await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
+
+ return Ok(query);
+ }
+
+ private async Task ResolveLocationAsync(Location candidate, CancellationToken cancellationToken)
+ {
+ if (candidate is null)
+ {
+ return null;
+ }
+
+ var existingLocation = await _context.Locations.FirstOrDefaultAsync(
+ x => x.Address == candidate.Address
+ && x.Longitude == candidate.Longitude
+ && x.Latitude == candidate.Latitude,
+ cancellationToken);
+
+ if (existingLocation is not null)
+ {
+ return existingLocation;
+ }
+
+ _context.Attach(candidate);
+ return candidate;
+ }
+
+ private bool QueryExists(long id)
+ {
+ return _context.HairCutQueries.Any(e => e.Id == id);
+ }
+
+ private static HairPrestationDto ToDto(HairPrestation prestation)
+ {
+ return new HairPrestationDto
+ {
+ Id = prestation.Id,
+ Title = prestation.GetDisplayTitle(),
+ Details = prestation.GetDisplayDetails(),
+ };
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs
new file mode 100644
index 000000000..3b33c4160
--- /dev/null
+++ b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs
@@ -0,0 +1,284 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Haircut;
+using Yavsc.Models.Relationship;
+using Yavsc.Server.Helpers;
+
+namespace Yavsc.Controllers;
+
+[Authorize]
+[Produces("application/json")]
+[Route(Constants.APIPrefix + "/billing/" + BillingCodes.MBrush)]
+public class HairMultiCutQueryApiController : Controller
+{
+ private readonly ApplicationDbContext _context;
+
+ public HairMultiCutQueryApiController(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task GetQueries(CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+
+ var queries = await _context.HairMultiCutQueries
+ .AsNoTracking()
+ .Include(q => q.Prestations)
+ .ThenInclude(p => p.Prestation)
+ .Include(q => q.Location)
+ .Include(q => q.Client)
+ .Include(q => q.PerformerProfile)
+ .Where(q => q.ClientId == uid || q.PerformerId == uid)
+ .OrderByDescending(q => q.Id)
+ .ToListAsync(cancellationToken);
+
+ return Ok(queries.Select(SanitizeForResponse).ToList());
+ }
+
+ [HttpGet("prestations")]
+ public async Task GetPrestations(CancellationToken cancellationToken)
+ {
+ var prestations = await _context.HairPrestation
+ .AsNoTracking()
+ .OrderBy(p => p.Gender)
+ .ThenBy(p => p.Length)
+ .ThenBy(p => p.Tech)
+ .Select(p => new HairPrestationDto
+ {
+ Id = p.Id,
+ Title = p.GetDisplayTitle(),
+ Details = p.GetDisplayDetails()
+ })
+ .ToListAsync(cancellationToken);
+
+ return Ok(prestations);
+ }
+
+ [HttpGet("{id}", Name = "GetBillingHairMultiCutQuery")]
+ public async Task GetQuery([FromRoute] long id, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+
+ var query = await _context.HairMultiCutQueries
+ .Include(q => q.Prestations)
+ .ThenInclude(p => p.Prestation)
+ .Include(q => q.Location)
+ .Include(q => q.Client)
+ .Include(q => q.PerformerProfile)
+ .SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
+
+ if (query is null)
+ {
+ return NotFound();
+ }
+
+ if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ return Ok(SanitizeForResponse(query));
+ }
+
+ [HttpPost]
+ public async Task PostQuery([FromBody] HairMultiCutQuery query, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+ query.ClientId = uid;
+
+ ModelState.Remove("Client");
+ ModelState.Remove("ClientId");
+ ModelState.Remove("UserCreated");
+ ModelState.Remove("UserModified");
+ ModelState.Remove("SelectedProfile");
+ ModelState.Remove("PerformerProfile");
+ ModelState.Remove("Context");
+ ModelState.Remove("Regularization");
+
+ if (query.Prestations is null || query.Prestations.Count == 0)
+ {
+ ModelState.AddModelError("Prestations", "At least one hair prestation is required.");
+ return BadRequest(ModelState);
+ }
+
+ var prestationItems = await ResolvePrestationsAsync(query.Prestations, cancellationToken);
+ if (prestationItems is null)
+ {
+ ModelState.AddModelError("Prestations", "One or more hair prestations are unknown.");
+ return BadRequest(ModelState);
+ }
+
+ if (!ModelState.IsValid)
+ {
+ return BadRequest(ModelState);
+ }
+
+ query.Prestations = prestationItems;
+ query.Location = await ResolveLocationAsync(query.Location, cancellationToken);
+ _context.HairMultiCutQueries.Add(query);
+
+ try
+ {
+ await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
+ }
+ catch (DbUpdateException)
+ {
+ if (QueryExists(query.Id))
+ {
+ return Conflict();
+ }
+
+ throw;
+ }
+
+ return CreatedAtRoute("GetBillingHairMultiCutQuery", new { id = query.Id }, SanitizeForResponse(query));
+ }
+
+ [HttpPut("{id}")]
+ public async Task PutQuery([FromRoute] long id, [FromBody] HairMultiCutQuery query, CancellationToken cancellationToken)
+ {
+ var existing = await _context.HairMultiCutQueries
+ .Include(q => q.Prestations)
+ .SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
+
+ if (existing is null)
+ {
+ return NotFound();
+ }
+
+ var uid = User.GetUserId();
+ if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ if (query.Prestations is null || query.Prestations.Count == 0)
+ {
+ ModelState.AddModelError("Prestations", "At least one hair prestation is required.");
+ return BadRequest(ModelState);
+ }
+
+ var prestationItems = await ResolvePrestationsAsync(query.Prestations, cancellationToken);
+ if (prestationItems is null)
+ {
+ ModelState.AddModelError("Prestations", "One or more hair prestations are unknown.");
+ return BadRequest(ModelState);
+ }
+
+ _context.RemoveRange(existing.Prestations);
+ existing.ActivityCode = query.ActivityCode;
+ existing.PerformerId = query.PerformerId;
+ existing.Consent = query.Consent;
+ existing.EventDate = query.EventDate;
+ existing.Status = query.Status;
+ existing.Provisional = query.Provisional;
+ existing.Prestations = prestationItems;
+ existing.Location = await ResolveLocationAsync(query.Location, cancellationToken);
+
+ await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
+ return NoContent();
+ }
+
+ [HttpDelete("{id}")]
+ public async Task DeleteQuery([FromRoute] long id, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+
+ var query = await _context.HairMultiCutQueries
+ .Include(q => q.Prestations)
+ .SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
+
+ if (query is null)
+ {
+ return NotFound();
+ }
+
+ if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ _context.RemoveRange(query.Prestations);
+ _context.HairMultiCutQueries.Remove(query);
+ await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
+
+ return Ok(SanitizeForResponse(query));
+ }
+
+ private async Task> ResolvePrestationsAsync(
+ IEnumerable requestedItems,
+ CancellationToken cancellationToken)
+ {
+ var ids = requestedItems
+ .Select(x => x.PrestationId)
+ .Where(x => x > 0)
+ .ToArray();
+
+ if (ids.Length == 0)
+ {
+ return null;
+ }
+
+ var prestations = await _context.HairPrestation
+ .Where(p => ids.Contains(p.Id))
+ .ToDictionaryAsync(p => p.Id, cancellationToken);
+
+ if (prestations.Count != ids.Distinct().Count())
+ {
+ return null;
+ }
+
+ return requestedItems
+ .Select(item => new HairPrestationCollectionItem
+ {
+ PrestationId = item.PrestationId,
+ Prestation = prestations[item.PrestationId],
+ })
+ .ToList();
+ }
+
+ private async Task ResolveLocationAsync(Location candidate, CancellationToken cancellationToken)
+ {
+ if (candidate is null)
+ {
+ return null;
+ }
+
+ var existingLocation = await _context.Locations.FirstOrDefaultAsync(
+ x => x.Address == candidate.Address
+ && x.Longitude == candidate.Longitude
+ && x.Latitude == candidate.Latitude,
+ cancellationToken);
+
+ if (existingLocation is not null)
+ {
+ return existingLocation;
+ }
+
+ _context.Attach(candidate);
+ return candidate;
+ }
+
+ private bool QueryExists(long id)
+ {
+ return _context.HairMultiCutQueries.Any(e => e.Id == id);
+ }
+
+ private static HairMultiCutQuery SanitizeForResponse(HairMultiCutQuery query)
+ {
+ if (query.Prestations is not null)
+ {
+ foreach (var item in query.Prestations)
+ {
+ item.Query = null;
+ }
+ }
+
+ return query;
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api/Controllers/Business/PaymentApiController.cs b/src/Yavsc.Api/Controllers/Business/PaymentApiController.cs
index 5f769e4ef..930eea918 100644
--- a/src/Yavsc.Api/Controllers/Business/PaymentApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/PaymentApiController.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Options;
using Newtonsoft.Json;
@@ -6,6 +7,7 @@ using Yavsc.Models;
namespace Yavsc.ApiControllers
{
+ [Authorize]
[Route(Constants.APIPrefix + "/payment")]
public class PaymentApiController : Controller
{
diff --git a/src/Yavsc.Api/Controllers/Business/PerformersApiController.cs b/src/Yavsc.Api/Controllers/Business/PerformersApiController.cs
index 2ad1ded5b..36586adb9 100644
--- a/src/Yavsc.Api/Controllers/Business/PerformersApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/PerformersApiController.cs
@@ -10,6 +10,7 @@ namespace Yavsc.Controllers
using Yavsc.Helpers;
using Yavsc.Services;
+ [Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/performers")]
public class PerformersApiController : Controller
diff --git a/src/Yavsc.Api/Controllers/Business/ProductApiController.cs b/src/Yavsc.Api/Controllers/Business/ProductApiController.cs
index d9792839b..ae3820dc9 100644
--- a/src/Yavsc.Api/Controllers/Business/ProductApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/ProductApiController.cs
@@ -7,6 +7,7 @@ using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
{
+ [Authorize]
[Produces("application/json")]
[Route(Constants.APIPrefix + "/ProductApi")]
public class ProductApiController : Controller
diff --git a/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs
new file mode 100644
index 000000000..48e3be2e1
--- /dev/null
+++ b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs
@@ -0,0 +1,220 @@
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.EntityFrameworkCore;
+using Yavsc.Models;
+using Yavsc.Models.Billing;
+using Yavsc.Models.Workflow;
+using Yavsc.Server.Helpers;
+
+namespace Yavsc.Controllers;
+
+[Authorize]
+[Produces("application/json")]
+[Route(Constants.APIPrefix + "/billing/" + BillingCodes.Rdv)]
+public class RdvQueryApiController : Controller
+{
+ private readonly ApplicationDbContext _context;
+
+ public RdvQueryApiController(ApplicationDbContext context)
+ {
+ _context = context;
+ }
+
+ [HttpGet]
+ public async Task GetQueries(CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+
+ var queries = await _context.RdvQueries
+ .AsNoTracking()
+ .Include(q => q.Location)
+ .Include(q => q.Client)
+ .Include(q => q.PerformerProfile)
+ .Where(q => q.ClientId == uid || q.PerformerId == uid)
+ .OrderByDescending(q => q.Id)
+ .ToListAsync(cancellationToken);
+
+ return Ok(queries);
+ }
+
+ [HttpGet("{id}", Name = "GetRdvQuery")]
+ public async Task GetQuery([FromRoute] long id, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+
+ var query = await _context.RdvQueries
+ .Include(q => q.Location)
+ .Include(q => q.Client)
+ .Include(q => q.PerformerProfile)
+ .SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
+
+ if (query is null)
+ {
+ return NotFound();
+ }
+
+ if (query.ClientId != uid && query.PerformerId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ return Ok(query);
+ }
+
+ [HttpPost]
+ public async Task PostQuery([FromBody] RdvQuery query, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+ // Security: the caller always posts for themselves.
+ query.ClientId = uid;
+ query.EventDate = EnsureUtc(query.EventDate);
+
+ ModelState.Remove("Client");
+ ModelState.Remove("ClientId");
+ ModelState.Remove("UserCreated");
+ ModelState.Remove("UserModified");
+ ModelState.Remove("SelectedProfile");
+ ModelState.Remove("PerformerProfile");
+ ModelState.Remove("Context");
+ ModelState.Remove("Regularization");
+
+ if (!ModelState.IsValid)
+ {
+ return BadRequest(ModelState);
+ }
+
+ if (query.Location is not null)
+ {
+ var existingLocation = await _context.Locations.FirstOrDefaultAsync(
+ x => x.Address == query.Location.Address
+ && x.Longitude == query.Location.Longitude
+ && x.Latitude == query.Location.Latitude,
+ cancellationToken);
+
+ if (existingLocation is not null)
+ {
+ query.Location = existingLocation;
+ }
+ else
+ {
+ _context.Attach(query.Location);
+ }
+ }
+
+ _context.RdvQueries.Add(query);
+
+ try
+ {
+ await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
+ }
+ catch (DbUpdateException)
+ {
+ if (QueryExists(query.Id))
+ {
+ return Conflict();
+ }
+
+ throw;
+ }
+
+ return CreatedAtRoute("GetRdvQuery", new { id = query.Id }, query);
+ }
+
+ [HttpPut("{id}")]
+ public async Task PutQuery([FromRoute] long id, [FromBody] RdvQuery query, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+ var existing = await _context.RdvQueries
+ .Include(q => q.Location)
+ .SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
+
+ if (existing is null)
+ {
+ return NotFound();
+ }
+
+ if (existing.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ existing.ActivityCode = query.ActivityCode;
+ existing.PerformerId = query.PerformerId;
+ existing.Consent = query.Consent;
+ existing.EventDate = EnsureUtc(query.EventDate);
+ existing.LocationType = query.LocationType;
+ existing.Reason = query.Reason;
+ existing.Status = query.Status;
+ existing.Provisional = query.Provisional;
+
+ if (query.Location is not null)
+ {
+ var resolvedLocation = await _context.Locations.FirstOrDefaultAsync(
+ x => x.Address == query.Location.Address
+ && x.Longitude == query.Location.Longitude
+ && x.Latitude == query.Location.Latitude,
+ cancellationToken);
+
+ existing.Location = resolvedLocation ?? query.Location;
+ if (resolvedLocation is null)
+ {
+ _context.Attach(query.Location);
+ }
+ }
+
+ try
+ {
+ await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
+ }
+ catch (DbUpdateConcurrencyException)
+ {
+ if (!QueryExists(id))
+ {
+ return NotFound();
+ }
+
+ throw;
+ }
+
+ return NoContent();
+ }
+
+ [HttpDelete("{id}")]
+ public async Task DeleteQuery([FromRoute] long id, CancellationToken cancellationToken)
+ {
+ var uid = User.GetUserId();
+
+ var query = await _context.RdvQueries
+ .SingleOrDefaultAsync(q => q.Id == id, cancellationToken);
+
+ if (query is null)
+ {
+ return NotFound();
+ }
+
+ if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ _context.RdvQueries.Remove(query);
+ await _context.SaveChangesAsync(User.GetUserId(), cancellationToken);
+
+ return Ok(query);
+ }
+
+ private bool QueryExists(long id)
+ {
+ return _context.RdvQueries.Any(e => e.Id == id);
+ }
+
+ private static DateTime EnsureUtc(DateTime value)
+ {
+ return value.Kind switch
+ {
+ DateTimeKind.Utc => value,
+ DateTimeKind.Local => value.ToUniversalTime(),
+ _ => DateTime.SpecifyKind(value, DateTimeKind.Utc)
+ };
+ }
+}
diff --git a/src/Yavsc.Api/Controllers/accounting/AccountController.cs b/src/Yavsc.Api/Controllers/accounting/AccountController.cs
index aff710137..155274cdf 100644
--- a/src/Yavsc.Api/Controllers/accounting/AccountController.cs
+++ b/src/Yavsc.Api/Controllers/accounting/AccountController.cs
@@ -2,6 +2,8 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
+using Microsoft.AspNetCore.Http;
+using ImageMagick;
using Yavsc.Models;
using Yavsc.Api.Helpers;
@@ -10,10 +12,21 @@ using System.Diagnostics;
namespace Yavsc.WebApi.Controllers
{
- [Route("~/api/account")]
+ [Route( Constants.APIPrefix + "/account")]
[Authorize("ApiScope")]
public class ApiAccountController : Controller
{
+ private const long MaxAvatarSizeBytes = 2 * 1024 * 1024;
+ private static readonly string[] AcceptedAvatarMimeTypes =
+ [
+ "image/png",
+ "image/jpeg",
+ "image/webp",
+ "image/gif",
+ "image/bmp",
+ "image/tiff",
+ ];
+
readonly ApplicationDbContext _dbContext;
private readonly ILogger _logger;
@@ -61,23 +74,102 @@ namespace Yavsc.WebApi.Controllers
return Ok(new { host = Request.ForwardedFor() });
}
-
+
///
/// Updates the avatar
///
///
- [HttpPost("~/api/set-avatar")]
+ [HttpPost("set-avatar")]
public async Task SetAvatar()
{
var user = await GetUserData(User.GetUserId());
- if (Request.Form.Files.Count!=1)
- return new BadRequestResult();
- if (!Request.Form.Files[0].ContentType.StartsWith("image/png"))
- return new BadRequestResult();
+ if (!Request.HasFormContentType)
+ {
+ return BadRequest(new
+ {
+ status = "invalid_request",
+ message = "A multipart/form-data request is required.",
+ acceptedMimeTypes = AcceptedAvatarMimeTypes,
+ maxFileSizeBytes = MaxAvatarSizeBytes,
+ outputFormat = "image/png",
+ });
+ }
- var info = user.ReceiveAvatar(Request.Form.Files[0]);
- await _dbContext.SaveChangesAsync();
- return Ok(info);
+ if (Request.Form.Files.Count != 1)
+ {
+ return BadRequest(new
+ {
+ status = "invalid_file_count",
+ message = "Exactly one file is required.",
+ acceptedMimeTypes = AcceptedAvatarMimeTypes,
+ maxFileSizeBytes = MaxAvatarSizeBytes,
+ outputFormat = "image/png",
+ });
+ }
+
+ var avatarFile = Request.Form.Files[0];
+ if (avatarFile.Length <= 0)
+ {
+ return BadRequest(new
+ {
+ status = "empty_file",
+ message = "The uploaded file is empty.",
+ acceptedMimeTypes = AcceptedAvatarMimeTypes,
+ maxFileSizeBytes = MaxAvatarSizeBytes,
+ outputFormat = "image/png",
+ });
+ }
+
+ if (avatarFile.Length > MaxAvatarSizeBytes)
+ {
+ return BadRequest(new
+ {
+ status = "file_too_large",
+ message = "Avatar is too large.",
+ acceptedMimeTypes = AcceptedAvatarMimeTypes,
+ maxFileSizeBytes = MaxAvatarSizeBytes,
+ outputFormat = "image/png",
+ });
+ }
+
+ if (!AcceptedAvatarMimeTypes.Any(m => string.Equals(m, avatarFile.ContentType, StringComparison.OrdinalIgnoreCase)))
+ {
+ return StatusCode(StatusCodes.Status415UnsupportedMediaType, new
+ {
+ status = "unsupported_media_type",
+ message = "Unsupported image format.",
+ acceptedMimeTypes = AcceptedAvatarMimeTypes,
+ maxFileSizeBytes = MaxAvatarSizeBytes,
+ outputFormat = "image/png",
+ });
+ }
+
+ try
+ {
+ var info = user.ReceiveAvatar(avatarFile);
+ await _dbContext.SaveChangesAsync();
+ return Ok(new
+ {
+ status = "uploaded",
+ message = "Avatar uploaded successfully.",
+ acceptedMimeTypes = AcceptedAvatarMimeTypes,
+ maxFileSizeBytes = MaxAvatarSizeBytes,
+ outputFormat = "image/png",
+ avatar = info,
+ });
+ }
+ catch (MagickException ex)
+ {
+ _logger.LogWarning(ex, "Avatar upload failed: invalid image data for user {UserId}", user.Id);
+ return BadRequest(new
+ {
+ status = "invalid_image_data",
+ message = "Image content could not be decoded.",
+ acceptedMimeTypes = AcceptedAvatarMimeTypes,
+ maxFileSizeBytes = MaxAvatarSizeBytes,
+ outputFormat = "image/png",
+ });
+ }
}
[HttpGet("identity")]
diff --git a/src/Yavsc.Api/Program.cs b/src/Yavsc.Api/Program.cs
index 3ed20e8d5..5269eef1a 100644
--- a/src/Yavsc.Api/Program.cs
+++ b/src/Yavsc.Api/Program.cs
@@ -20,6 +20,7 @@ using Yavsc.Helpers;
using Yavsc.Interface;
using Yavsc.Interfaces;
using Yavsc.Models;
+using Yavsc;
using Yavsc.Server.Helpers;
using Yavsc.Services;
@@ -32,6 +33,7 @@ internal class Program
var builder = WebApplication.CreateBuilder(args);
builder.AddConfiguration("api");
+ Config.SiteSetup = builder.Configuration.GetSection("Site").Get() ?? new SiteSettings();
var services = builder.Services;
@@ -72,9 +74,10 @@ internal class Program
services.AddAuthentication("Bearer")
.AddYavscJwtBearer(builder.Configuration);
+ // DbContextBuilder
services.AddDbContext(options =>
-
- options.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
+ options.UseNpgsql(builder.Configuration.GetConnectionString(
+ Yavsc.Constants.YavscConnectionStringName)));
services.AddLocalization(options =>
{
diff --git a/src/Yavsc.Api/Yavsc.Api.csproj b/src/Yavsc.Api/Yavsc.Api.csproj
index 34d773a9b..c8c30904d 100644
--- a/src/Yavsc.Api/Yavsc.Api.csproj
+++ b/src/Yavsc.Api/Yavsc.Api.csproj
@@ -7,7 +7,7 @@
true
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
1.1.0-beta.1
diff --git a/src/Yavsc.Api/appsettings-api.json b/src/Yavsc.Api/appsettings-api.json
index ac626c0d6..d8b295de0 100644
--- a/src/Yavsc.Api/appsettings-api.json
+++ b/src/Yavsc.Api/appsettings-api.json
@@ -2,7 +2,8 @@
"Site": {
"Authority": "https://localhost:5001",
"CorsAllowedOrigins": [
- "https://localhost:5003"
+ "https://localhost:5003",
+ "https://yavsc.pschneider.fr"
]
},
"Logging": {
diff --git a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs
index 4aef805fc..ab5ebcc66 100644
--- a/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs
+++ b/src/Yavsc.Blogs.Tests/BlogAclApiTests.cs
@@ -283,8 +283,8 @@ public sealed class BlogAclApiTests : IClassFixture
TestContext.Current.CancellationToken
));
Assert.Equal(JsonValueKind.Array, doc.RootElement.ValueKind);
- Assert.Equal(2, doc.RootElement.GetArrayLength());
- Assert.Equal(created.Id, doc.RootElement[0].GetProperty("id").GetInt64());
+ Assert.True(doc.RootElement.GetArrayLength() >= 1);
+ Assert.Contains(doc.RootElement.EnumerateArray(), p => p.GetProperty("id").GetInt64() == created.Id);
// detail should return the same post, with ACL and tags.
var detailResponse = await http.GetAsync(
@@ -296,7 +296,86 @@ public sealed class BlogAclApiTests : IClassFixture
));
Assert.Equal(JsonValueKind.Object, detailDoc.RootElement.ValueKind);
Assert.Equal(created.Id, detailDoc.RootElement.GetProperty("id").GetInt64());
- Assert.Equal(1, detailDoc.RootElement.GetProperty("acl").GetArrayLength());
+ Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var acl));
+ Assert.False(detailDoc.RootElement.TryGetProperty("ACL", out _));
+ Assert.Equal(JsonValueKind.Array, acl.ValueKind);
+ Assert.Equal(1, acl.GetArrayLength());
+
+ var aclEntry = acl[0];
+ Assert.Equal(JsonValueKind.Object, aclEntry.ValueKind);
+ Assert.True(aclEntry.TryGetProperty("circleId", out var circleId));
+ Assert.Equal(_fixture.CircleId, circleId.GetInt64());
+ }
+
+ [Fact]
+ public async Task Non_owner_can_read_restricted_post_but_receives_empty_acl_in_list_and_detail()
+ {
+ CleanupAcl();
+ _fixture.SeedUser(_fixture.DefaultUserLogin);
+ _fixture.SeedUser("tester");
+ _fixture.SeedCircle(_fixture.DefaultUserLogin, "test", false,
+ new[] { _fixture.DefaultUserLogin, "tester" });
+
+ using var ownerHttp = NewClient(_fixture.DefaultUserLogin);
+ using var readerHttp = NewClient("tester");
+
+ var draft = new BlogPost
+ {
+ Id = 0,
+ Title = "ACL scrub test",
+ Article = "Visible to circle member",
+ DateCreated = DateTime.UtcNow,
+ DateModified = DateTime.UtcNow
+ };
+
+ var postResponse = await ownerHttp.PostAsJsonAsync(
+ BlogUrl(),
+ draft,
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, postResponse.StatusCode);
+
+ var created = await postResponse.Content.ReadFromJsonAsync(
+ TestContext.Current.CancellationToken);
+ Assert.NotNull(created);
+ Assert.NotEqual(0, created!.Id);
+
+ var grantResponse = await ownerHttp.PostAsJsonAsync(
+ BlogAclUrl(),
+ new PostAccessControlRulePayload
+ {
+ CircleId = _fixture.CircleId,
+ BlogPostId = created.Id
+ },
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.Created, grantResponse.StatusCode);
+
+ var listResponse = await readerHttp.GetAsync(
+ BlogUrl(),
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, listResponse.StatusCode);
+
+ using var listDoc = JsonDocument.Parse(await listResponse.Content.ReadAsStringAsync(
+ TestContext.Current.CancellationToken));
+ Assert.Equal(JsonValueKind.Array, listDoc.RootElement.ValueKind);
+ foreach (var listed in listDoc.RootElement.EnumerateArray())
+ {
+ var authorId = listed.GetProperty("authorId").GetString();
+ if (string.Equals(authorId, "tester", StringComparison.Ordinal))
+ continue;
+
+ Assert.True(listed.TryGetProperty("acl", out var listedAcl));
+ Assert.Equal(0, listedAcl.GetArrayLength());
+ }
+
+ var detailResponse = await readerHttp.GetAsync(
+ $"{BlogUrl()}/{created.Id}",
+ TestContext.Current.CancellationToken);
+ Assert.Equal(HttpStatusCode.OK, detailResponse.StatusCode);
+
+ using var detailDoc = JsonDocument.Parse(await detailResponse.Content.ReadAsStringAsync(
+ TestContext.Current.CancellationToken));
+ Assert.True(detailDoc.RootElement.TryGetProperty("acl", out var detailAcl));
+ Assert.Equal(0, detailAcl.GetArrayLength());
}
}
diff --git a/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs b/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
index 6e8ae31f6..ea6837b1c 100644
--- a/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
+++ b/src/Yavsc.Blogs.Tests/Fixtures/BlogsWebServerFixture.cs
@@ -370,8 +370,6 @@ public sealed class BlogsWebServerFixture : WebHostFixture
}
}
-
-
/// Create a circle owned by
/// directly in the SQLite store and return its server-assigned
/// id.
@@ -415,5 +413,4 @@ public sealed class BlogsWebServerFixture : WebHostFixture
db.SaveChanges();
return post.Id;
}
-
}
diff --git a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj
index c16bde3f1..887648060 100644
--- a/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj
+++ b/src/Yavsc.Blogs.Tests/Yavsc.Blogs.Tests.csproj
@@ -9,7 +9,7 @@
true
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
1.1.0-beta.1
diff --git a/src/Yavsc.Blogs/Controllers/BlogApiController.cs b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
index 8c76f7eea..b757d711b 100644
--- a/src/Yavsc.Blogs/Controllers/BlogApiController.cs
+++ b/src/Yavsc.Blogs/Controllers/BlogApiController.cs
@@ -55,6 +55,14 @@ namespace Yavsc.Blogs.Controllers
[HttpPut("{id}")]
public async Task PutBlog(long id, [FromBody] Models.Blog.BlogPost blog)
{
+ // These properties are server-managed or optional graph members and
+ // should not block JSON payloads coming from API clients.
+ ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
+ ModelState.Remove(nameof(Models.Blog.BlogPost.Tags));
+ ModelState.Remove(nameof(Models.Blog.BlogPost.Comments));
+ ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated));
+ ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified));
+
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
@@ -87,6 +95,14 @@ namespace Yavsc.Blogs.Controllers
[HttpPost]
public IActionResult PostBlog([FromBody] Models.Blog.BlogPost blog)
{
+ // These properties are server-managed or optional graph members and
+ // should not block JSON payloads coming from API clients.
+ ModelState.Remove(nameof(Models.Blog.BlogPost.Author));
+ ModelState.Remove(nameof(Models.Blog.BlogPost.Tags));
+ ModelState.Remove(nameof(Models.Blog.BlogPost.Comments));
+ ModelState.Remove(nameof(Models.Blog.BlogPost.UserCreated));
+ ModelState.Remove(nameof(Models.Blog.BlogPost.UserModified));
+
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
diff --git a/src/Yavsc.Blogs/Yavsc.Blogs.csproj b/src/Yavsc.Blogs/Yavsc.Blogs.csproj
index 377f4853e..25521e8a5 100644
--- a/src/Yavsc.Blogs/Yavsc.Blogs.csproj
+++ b/src/Yavsc.Blogs/Yavsc.Blogs.csproj
@@ -8,7 +8,7 @@
true
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
1.1.0-beta.1
diff --git a/src/Yavsc.Org.Tests/NonRegression/AvatarFallbackTests.cs b/src/Yavsc.Org.Tests/NonRegression/AvatarFallbackTests.cs
new file mode 100644
index 000000000..200f37fad
--- /dev/null
+++ b/src/Yavsc.Org.Tests/NonRegression/AvatarFallbackTests.cs
@@ -0,0 +1,34 @@
+namespace Yavsc.Org.Tests.NonRegression;
+
+///
+/// Non-regression: avatar requests under /avatars must never return 404
+/// for missing files. The pipeline falls back to static defaults under
+/// /images/Users/icon_user*.png.
+///
+public class AvatarFallbackTests : IClassFixture
+{
+ private readonly TestWebApplicationFactory _factory;
+
+ public AvatarFallbackTests(TestWebApplicationFactory factory)
+ {
+ _factory = factory;
+ }
+
+ [Theory]
+ [InlineData("/avatars/user-does-not-exist.png")]
+ [InlineData("/avatars/user-does-not-exist.s.png")]
+ [InlineData("/avatars/user-does-not-exist.xs.png")]
+ public async Task Missing_avatar_file_returns_default_image_instead_of_404(string path)
+ {
+ using var client = _factory.CreateClient();
+ var ct = TestContext.Current.CancellationToken;
+
+ var response = await client.GetAsync(path, ct);
+
+ Assert.Equal(System.Net.HttpStatusCode.OK, response.StatusCode);
+ Assert.Equal("image/png", response.Content.Headers.ContentType?.MediaType);
+
+ var payload = await response.Content.ReadAsByteArrayAsync(ct);
+ Assert.True(payload.Length > 0, $"Expected a non-empty fallback image for {path}.");
+ }
+}
diff --git a/src/Yavsc.Org.Tests/NonRegression/PerformerCodeInputValidationTests.cs b/src/Yavsc.Org.Tests/NonRegression/PerformerCodeInputValidationTests.cs
new file mode 100644
index 000000000..c47bdff5a
--- /dev/null
+++ b/src/Yavsc.Org.Tests/NonRegression/PerformerCodeInputValidationTests.cs
@@ -0,0 +1,76 @@
+using System.ComponentModel.DataAnnotations;
+using Yavsc.Models.Relationship;
+using Yavsc.Models.Workflow;
+
+namespace Yavsc.Tests.NonRegression;
+
+public class PerformerCodeInputValidationTests
+{
+ [Fact]
+ public void Validate_rejects_unknown_country_code()
+ {
+ var profile = CreateBaseProfile();
+ profile.ExerciseCountryCode = "de";
+ profile.SIREN = "123456789";
+
+ var results = Validate(profile);
+
+ Assert.Contains(results, r => r.MemberNames.Contains(nameof(PerformerProfile.ExerciseCountryCode)));
+ }
+
+ [Fact]
+ public void Validate_rejects_code_not_matching_country_rule()
+ {
+ var profile = CreateBaseProfile();
+ profile.ExerciseCountryCode = "pt";
+ profile.SIREN = "ABC123";
+
+ var results = Validate(profile);
+
+ Assert.Contains(results, r => r.MemberNames.Contains(nameof(PerformerProfile.SIREN)));
+ }
+
+ [Fact]
+ public void Validate_accepts_country_specific_valid_codes()
+ {
+ var fr = CreateBaseProfile();
+ fr.ExerciseCountryCode = "fr";
+ fr.SIREN = "123456789";
+
+ var en = CreateBaseProfile();
+ en.ExerciseCountryCode = "en";
+ en.SIREN = "AB12CD34";
+
+ var pt = CreateBaseProfile();
+ pt.ExerciseCountryCode = "pt";
+ pt.SIREN = "501964843";
+
+ Assert.Empty(Validate(fr));
+ Assert.Empty(Validate(en));
+ Assert.Empty(Validate(pt));
+ }
+
+ private static PerformerProfile CreateBaseProfile()
+ {
+ return new PerformerProfile
+ {
+ PerformerId = "perf-1",
+ SIREN = "123456789",
+ ExerciseCountryCode = "fr",
+ OrganizationAddress = new Location
+ {
+ Address = "1 rue du Test",
+ Latitude = 48.8566,
+ Longitude = 2.3522,
+ },
+ };
+ }
+
+ private static List Validate(PerformerProfile profile)
+ {
+ var ctx = new ValidationContext(profile);
+ var results = new List();
+ Validator.TryValidateObject(profile, ctx, results, validateAllProperties: true);
+ return results;
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Org.Tests/NonRegression/SetActivityCountryValidationViewTests.cs b/src/Yavsc.Org.Tests/NonRegression/SetActivityCountryValidationViewTests.cs
new file mode 100644
index 000000000..23ace4589
--- /dev/null
+++ b/src/Yavsc.Org.Tests/NonRegression/SetActivityCountryValidationViewTests.cs
@@ -0,0 +1,70 @@
+namespace Yavsc.Org.Tests.NonRegression;
+
+///
+/// Guard rails for the SetActivity performer settings page:
+/// - countries are provided to the ComboBox via ViewBag.Countries
+/// - client-side SIREN validation is wired to country-specific regex rules
+/// - controller exposes the validation catalog to the view
+///
+public class SetActivityCountryValidationViewTests
+{
+ [Fact]
+ public void SetActivity_cshtml_binds_country_combo_to_ViewBag_Countries()
+ {
+ var content = File.ReadAllText(ResolveSetActivityViewPath());
+
+ Assert.Contains("asp-for=\"ExerciseCountryCode\"", content);
+ Assert.Contains("asp-items=\"ViewBag.Countries\"", content);
+ }
+
+ [Fact]
+ public void SetActivity_cshtml_contains_country_aware_siren_javascript_validation()
+ {
+ var content = File.ReadAllText(ResolveSetActivityViewPath());
+
+ Assert.Contains("$.validator.addMethod(\"sirenByCountry\"", content);
+ Assert.Contains("new RegExp(selectedRule.regex)", content);
+ Assert.Contains("const countryInput = $(\"#ExerciseCountryCode\")", content);
+ Assert.Contains("const sirenInput = $(\"#SIREN\")", content);
+ }
+
+ [Fact]
+ public void ManageController_exposes_countries_and_country_validation_rules_to_view()
+ {
+ var content = File.ReadAllText(ResolveManageControllerPath());
+
+ Assert.Contains("ViewBag.Countries = countries;", content);
+ Assert.Contains("ViewBag.CountryCodeValidationRules = PerformerCodeInputValidationCatalog.Rules;", content);
+ Assert.Contains("ModelState.Remove(nameof(PerformerProfile.ExerciseCountryCode));", content);
+ }
+
+ private static string ResolveSetActivityViewPath()
+ {
+ return ResolveFromWorkspaceRoot(
+ "src", "Yavsc.Org", "Views", "Manage", "SetActivity.cshtml");
+ }
+
+ private static string ResolveManageControllerPath()
+ {
+ return ResolveFromWorkspaceRoot(
+ "src", "Yavsc.Org", "Controllers", "Accounting", "ManageController.cs");
+ }
+
+ private static string ResolveFromWorkspaceRoot(params string[] relative)
+ {
+ var dir = AppContext.BaseDirectory;
+ for (var i = 0; i < 10 && dir is not null; i++)
+ {
+ var candidate = Path.Combine(new[] { dir }.Concat(relative).ToArray());
+ if (File.Exists(candidate))
+ {
+ return candidate;
+ }
+
+ dir = Path.GetDirectoryName(dir);
+ }
+
+ throw new FileNotFoundException(
+ "Could not locate test target from " + AppContext.BaseDirectory);
+ }
+}
diff --git a/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs b/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs
index c77a20abc..327c2db4e 100644
--- a/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs
+++ b/src/Yavsc.Org.Tests/Smoke/AccountSmokeTests.cs
@@ -1,3 +1,6 @@
+using IdentityServer8.Stores;
+using Microsoft.Extensions.DependencyInjection;
+
namespace Yavsc.Org.Tests.Smoke;
///
@@ -30,4 +33,27 @@ public class AccountSmokeTests : SmokeTestBase, IClassFixture();
+
+ var exception = await Record.ExceptionAsync(resourceStore.GetAllResourcesAsync);
+
+ Assert.Null(exception);
+ }
}
diff --git a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
index d34065953..106079202 100644
--- a/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
+++ b/src/Yavsc.Org.Tests/Yavsc.Org.Tests.csproj
@@ -11,7 +11,7 @@
$(MSBuildProjectDirectory)\test.runsettings
1.1.0.0
1.1.0.0
- 1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c
+ 1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070
1.1.0-beta.1
diff --git a/src/Yavsc.Org/Contants.cs b/src/Yavsc.Org/Contants.cs
index c4a5e8b17..246bc3ac7 100644
--- a/src/Yavsc.Org/Contants.cs
+++ b/src/Yavsc.Org/Contants.cs
@@ -14,7 +14,7 @@ public static class Constants
// 'offline_access' is handled by IdentityServer8 itself and never
// needs an explicit ApiScope row.
public static readonly string[] BuildInApiScopes = {
- "admin", "moderation", "performer", "client" };
+ "admin", "moderation", "performer", "client", "api" };
// One ApiResource per application scope. Each scope is exposed by
// exactly one resource, named after the scope ("admin" -> "admin"
@@ -29,6 +29,7 @@ public static class Constants
new ApiResourceScopeSpecification { ScopeName = "moderation", Description = "Moderation access", ResourceName = "moderation", ResourceDisplayName = "Moderation API" },
new ApiResourceScopeSpecification { ScopeName = "performer", Description = "Performer access", ResourceName = "performer", ResourceDisplayName = "Performer API" },
new ApiResourceScopeSpecification { ScopeName = "client", Description = "Client access", ResourceName = "client", ResourceDisplayName = "Client API" },
+ new ApiResourceScopeSpecification { ScopeName = "api", Description = "Core API access", ResourceName = "api", ResourceDisplayName = "Yavsc Core API" },
new ApiResourceScopeSpecification { ScopeName = "blogs", Description = "Blogs access", ResourceName = "blogs", ResourceDisplayName = "Yavsc Blogs API" }
};
}
diff --git a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs
index 05cb55b5b..3fd8a0159 100644
--- a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs
+++ b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs
@@ -25,6 +25,7 @@ using IdentityModel;
using Yavsc.Server.Helpers;
using Microsoft.AspNetCore.Mvc.Localization;
using System.Diagnostics;
+using System.Data.Common;
namespace Yavsc.Controllers
{
@@ -94,107 +95,6 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
}
- public async Task SignIn(SignInModel model, [FromForm] string button)
- {
- if (Request.Method == "POST") // "hGbkk9B94NAae#aG"
-
- {
- if (model.Provider == null || model.Provider == "LOCAL")
- {
- if (ModelState.IsValid)
- {
- var user = await _userManager.FindByNameAsync(model.UserName);
- var context = await _interaction.GetAuthorizationContextAsync(model.ReturnUrl);
- if (user != null)
- {
-
-
- var signin = await _signInManager.CheckPasswordSignInAsync(user, model.Password, true);
-
- // validate username/password against in-memory store
- if (signin.Succeeded)
- {
- await _events.RaiseAsync(new UserLoginSuccessEvent(user.UserName, user.Id, user.UserName, clientId: context?.Client.ClientId));
-
- // only set explicit expiration here if user chooses "remember me".
- // otherwise we rely upon expiration configured in cookie middleware.
- await HttpContext.SignInAsync(user, _roleManager, model.RememberMe, _dbContext);
- var authResult = await HttpContext.AuthenticateAsync();
- if (!authResult.Succeeded)
- {
- return this.Unauthorized();
- }
- String bearer = await HttpContext.GetTokenAsync("Bearer", "Bearer");
- HttpContext.Response.Cookies.Append("Bearer", bearer);
-
- if (context != null)
- {
- if (context.IsNativeClient())
- {
- // The client is native, so this change in how to
- // return the response is for better UX for the end user.
- return this.LoadingPage("Redirect", model.ReturnUrl);
- }
-
- // we can trust model.ReturnUrl since GetAuthorizationContextAsync returned non-null
- return Redirect(model.ReturnUrl);
- }
-
- // request for a local page
- if (Url.IsLocalUrl(model.ReturnUrl))
- {
- return Redirect(model.ReturnUrl);
- }
- else if (string.IsNullOrEmpty(model.ReturnUrl))
- {
- return Redirect("~/");
- }
- else
- {
- // user might have clicked on a malicious link - should be logged
- throw new Exception("invalid return URL");
- }
- }
- }
-
- await _events.RaiseAsync(new UserLoginFailureEvent(model.UserName, "invalid credentials", clientId: context?.Client.ClientId));
- ModelState.AddModelError(string.Empty, AccountOptions.InvalidCredentialsErrorMessage);
- }
- }
- else
- {
-
- // Note: the "provider" parameter corresponds to the external
- // authentication provider choosen by the user agent.
- if (string.IsNullOrEmpty(model.Provider))
- {
- _logger.LogWarning("Provider not specified");
- return BadRequest();
- }
-
- // Instruct the middleware corresponding to the requested external identity
- // provider to redirect the user agent to its own authorization endpoint.
- // Note: the authenticationScheme parameter must match the value configured in Startup.cs
-
- // Note: the "returnUrl" parameter corresponds to the endpoint the user agent
- // will be redirected to after a successful authentication and not
- // the redirect_uri of the requesting client application.
- if (string.IsNullOrEmpty(model.ReturnUrl))
- {
- _logger.LogWarning("ReturnUrl not specified");
- return BadRequest();
- }
- // Note: this still is not the redirect uri given to the third party provider, at building the challenge.
- var redirectUrl = Url.Action("ExternalLoginCallback", "Account", new { model.ReturnUrl }, protocol: "https", host: Config.Authority);
- var properties = _signInManager.ConfigureExternalAuthenticationProperties(model.Provider, redirectUrl);
- // var properties = new AuthenticationProperties{RedirectUri=ReturnUrl};
- return new ChallengeResult(model.Provider, properties);
-
- }
- }
- return View(model);
- }
-
///
/// Entry point into the login workflow
///
@@ -568,8 +468,25 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
if (ModelState.IsValid)
{
+ var existingUser = await _userManager.FindByEmailAsync(model.Email);
+ if (existingUser is not null)
+ {
+ ModelState.AddModelError(nameof(model.Email), _localizer["DuplicateEmail"]);
+ return View(model);
+ }
+
var user = new ApplicationUser { UserName = model.UserName, Email = model.Email };
- var result = await _userManager.CreateAsync(user, model.Password);
+ IdentityResult result;
+ try
+ {
+ result = await _userManager.CreateAsync(user, model.Password);
+ }
+ catch (DbUpdateException ex) when (IsDuplicateEmailViolation(ex))
+ {
+ _logger.LogWarning(ex, "Registration rejected: duplicate email '{Email}'.", model.Email);
+ ModelState.AddModelError(nameof(model.Email), _localizer["DuplicateEmail"]);
+ return View(model);
+ }
if (result.Succeeded)
{
_logger.LogInformation(3, "User created a new account with password.");
@@ -618,6 +535,21 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
return View(model);
}
+ private static bool IsDuplicateEmailViolation(Exception exception)
+ {
+ for (var current = exception; current is not null; current = current.InnerException)
+ {
+ if (current is DbException pg
+ && pg.ErrorCode == 23505)
+ // UNIQUE VIOLATION https://www.postgresql.org/docs/8.4/errcodes-appendix.html
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
[Authorize, HttpPost, ValidateAntiForgeryToken]
public async Task SendConfirationEmail()
{
diff --git a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
index 43e11cd24..e09bbb5a4 100644
--- a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
+++ b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
@@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
+using Microsoft.AspNetCore.Mvc.Rendering;
using Yavsc.Models.Workflow;
using Yavsc.Helpers;
using Yavsc.Models.Relationship;
@@ -15,6 +16,7 @@ using Yavsc.Services;
using Yavsc.ViewModels.Manage;
using Microsoft.AspNetCore.Identity.UI.Services;
using Microsoft.AspNetCore.Authorization;
+using IdentityServer8;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
@@ -523,8 +525,45 @@ namespace Yavsc.Controllers
}
[HttpGet]
- public IActionResult SetAvatar()
+ public async Task SetAvatar(
+ [FromServices] IdentityServerTools identityServerTools)
{
+ var currentUser = await GetCurrentUserAsync();
+ if (currentUser == null)
+ {
+ return Challenge();
+ }
+
+ var claims = new List
+ {
+ new("sub", currentUser.Id),
+ new("name", currentUser.UserName ?? currentUser.Email ?? currentUser.Id),
+ new("scope", "api"),
+ new("aud", "api")
+ };
+
+ // Short-lived token limited to avatar upload from this page.
+ string avatarAccessToken = string.Empty;
+ try
+ {
+ avatarAccessToken = await identityServerTools.IssueClientJwtAsync(
+ "postit",
+ 300,
+ new[] { "api" },
+ new[] { "api" },
+ claims);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "IssueClientJwtAsync failed for SetAvatar, trying direct IssueJwtAsync fallback.");
+ }
+
+ if (string.IsNullOrWhiteSpace(avatarAccessToken))
+ {
+ avatarAccessToken = await identityServerTools.IssueJwtAsync(300, claims);
+ }
+
+ ViewData["AvatarAccessToken"] = avatarAccessToken;
return View();
}
@@ -544,15 +583,19 @@ namespace Yavsc.Controllers
{
var currentProfile = _dbContext.Performers.Include(x => x.OrganizationAddress)
.First(x => x.PerformerId == uid);
+ currentProfile.ExerciseCountryCode = NormalizeCountryCodeOrDefault(currentProfile.ExerciseCountryCode);
ViewBag.Activities = _dbContext.ActivityItems(existing.Activity);
+ SetExerciseCountries(currentProfile.ExerciseCountryCode);
return View(currentProfile);
}
ViewBag.Activities = _dbContext.ActivityItems(new List());
+ SetExerciseCountries("fr");
return View(new PerformerProfile
{
PerformerId = user.Id,
Performer = user,
+ ExerciseCountryCode = "fr",
OrganizationAddress = new Location()
});
}
@@ -563,28 +606,42 @@ namespace Yavsc.Controllers
{
var user = GetCurrentUserAsync().Result;
var uid = user.Id;
+ var postedCountryCode = model.ExerciseCountryCode;
+ model.ExerciseCountryCode = NormalizeCountryCodeOrDefault(model.ExerciseCountryCode);
+
+ // Model binding validated the raw posted payload before entering
+ // the action. If country was empty, we fallback to "fr" above,
+ // so remove the stale min-length error attached to the empty value.
+ if (string.IsNullOrWhiteSpace(postedCountryCode))
+ {
+ ModelState.Remove(nameof(PerformerProfile.ExerciseCountryCode));
+ }
+
try
{
if (ModelState.IsValid)
{
-
- var exSiren = await _dbContext.ExceptionsSIREN.FirstOrDefaultAsync(
- ex => ex.SIREN == model.SIREN
- );
- if (exSiren != null)
+ var isFrenchPerformerCode = string.Equals(model.ExerciseCountryCode, "fr", StringComparison.Ordinal);
+ if (isFrenchPerformerCode)
{
- _logger.LogInformation("Exception SIREN:" + exSiren);
- }
- else
- {
- var taskCheck = await _cchecker.CheckAsync(model.SIREN);
- if (!taskCheck.success)
+ var exSiren = await _dbContext.ExceptionsSIREN.FirstOrDefaultAsync(
+ ex => ex.SIREN == model.SIREN
+ );
+ if (exSiren != null)
{
- ModelState.AddModelError(
- "SIREN",
- _SR["Invalid company number"] + " (" + taskCheck.errorCode + ")"
- );
- _logger.LogInformation($"Invalid company number: {model.SIREN}/{taskCheck.errorType}/{taskCheck.errorCode}/{taskCheck.errorMessage}" );
+ _logger.LogInformation("Exception SIREN:" + exSiren);
+ }
+ else
+ {
+ var taskCheck = await _cchecker.CheckAsync(model.SIREN);
+ if (!taskCheck.success)
+ {
+ ModelState.AddModelError(
+ "SIREN",
+ _SR["Invalid company number"] + " (" + taskCheck.errorCode + ")"
+ );
+ _logger.LogInformation($"Invalid company number: {model.SIREN}/{taskCheck.errorType}/{taskCheck.errorCode}/{taskCheck.errorMessage}" );
+ }
}
}
}
@@ -622,10 +679,35 @@ namespace Yavsc.Controllers
}
ViewBag.Activities = _dbContext.ActivityItems(new List());
ViewBag.GoogleSettings = _googleSettings;
+ SetExerciseCountries(model.ExerciseCountryCode);
model.Performer = _dbContext.Users.Single(u=>u.Id == model.PerformerId);
return View(model);
}
+ private static string NormalizeCountryCodeOrDefault(string code)
+ {
+ var normalized = PerformerCodeInputValidationCatalog.NormalizeCountryCode(code);
+ if (string.IsNullOrWhiteSpace(normalized))
+ {
+ return "fr";
+ }
+
+ return normalized;
+ }
+
+ private void SetExerciseCountries(string selectedCountryCode)
+ {
+ var selected = NormalizeCountryCodeOrDefault(selectedCountryCode);
+ var countries = new SelectList(
+ PerformerCodeInputValidationCatalog.Countries,
+ nameof(Country.Code),
+ nameof(Country.DisplayName),
+ selected);
+ ViewBag.ExerciseCountries = countries;
+ ViewBag.Countries = countries;
+ ViewBag.CountryCodeValidationRules = PerformerCodeInputValidationCatalog.Rules;
+ }
+
[HttpPost]
public async Task UnsetActivity()
{
diff --git a/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs b/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs
index 3690671c5..02dda32a1 100644
--- a/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs
+++ b/src/Yavsc.Org/Controllers/Communicating/CommentsController.cs
@@ -121,6 +121,7 @@ namespace Yavsc.Controllers
public async Task Create(Comment comment)
{
comment.UserCreated = User.GetUserId();
+ comment.UserModified = comment.UserCreated;
// AuthorId/UserCreated is set server-side after model binding;
// remove the stale binding error so a valid authenticated POST
// does not fall into the invalid branch.
@@ -129,7 +130,7 @@ namespace Yavsc.Controllers
if (ModelState.IsValid)
{
_context.Comment.Add(comment);
- await _context.SaveChangesAsync();
+ await _context.SaveChangesAsync(comment.UserCreated);
return RedirectToAction("Index");
}
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title", comment.ReceiverId);
diff --git a/src/Yavsc.Org/Controllers/Contracting/FormsController.cs b/src/Yavsc.Org/Controllers/Contracting/FormsController.cs
deleted file mode 100644
index 423cf2f41..000000000
--- a/src/Yavsc.Org/Controllers/Contracting/FormsController.cs
+++ /dev/null
@@ -1,120 +0,0 @@
-using Microsoft.AspNetCore.Mvc;
-using Microsoft.EntityFrameworkCore;
-using Yavsc.Models;
-using Yavsc.Models.Forms;
-using Yavsc.Server.Helpers;
-
-namespace Yavsc.Controllers
-{
- public class FormsController : Controller
- {
- private readonly ApplicationDbContext _context;
-
- public FormsController(ApplicationDbContext context)
- {
- _context = context;
- }
-
- // GET: Forms
- public async Task Index()
- {
- return View(await _context.Form.ToListAsync());
- }
-
- // GET: Forms/Details/5
- public async Task Details(string id)
- {
- if (id == null)
- {
- return NotFound();
- }
-
- Form form = await _context.Form.SingleAsync(m => m.Id == id);
- if (form == null)
- {
- return NotFound();
- }
-
- return View(form);
- }
-
- // GET: Forms/Create
- public IActionResult Create()
- {
- return View();
- }
-
- // POST: Forms/Create
- [HttpPost]
- [ValidateAntiForgeryToken]
- public async Task Create(Form form)
- {
- if (ModelState.IsValid)
- {
- _context.Form.Add(form);
- await _context.SaveChangesAsync(User.GetUserId());
- return RedirectToAction("Index");
- }
- return View(form);
- }
-
- // GET: Forms/Edit/5
- public async Task Edit(string id)
- {
- if (id == null)
- {
- return NotFound();
- }
-
- Form form = await _context.Form.SingleAsync(m => m.Id == id);
- if (form == null)
- {
- return NotFound();
- }
- return View(form);
- }
-
- // POST: Forms/Edit/5
- [HttpPost]
- [ValidateAntiForgeryToken]
- public async Task Edit(Form form)
- {
- if (ModelState.IsValid)
- {
- _context.Update(form);
- await _context.SaveChangesAsync(User.GetUserId());
- return RedirectToAction("Index");
- }
- return View(form);
- }
-
- // GET: Forms/Delete/5
- [ActionName("Delete")]
- public async Task Delete(string id)
- {
- if (id == null)
- {
- return NotFound();
- }
-
- Form form = await _context.Form.SingleAsync(m => m.Id == id);
- if (form == null)
- {
- return NotFound();
- }
-
- return View(form);
- }
-
- // POST: Forms/Delete/5
- [HttpPost, ActionName("Delete")]
- [ValidateAntiForgeryToken]
- public async Task DeleteConfirmed(string id)
- {
- Form form = await _context.Form.SingleAsync(m => m.Id == id);
- _context.Form.Remove(form);
- await _context.SaveChangesAsync(User.GetUserId());
- return RedirectToAction("Index");
- }
- }
-}
diff --git a/src/Yavsc.Org/Controllers/Contracting/SIRENExceptionsController.cs b/src/Yavsc.Org/Controllers/Contracting/SIRENExceptionsController.cs
index bf08484ed..ed4ee5b59 100644
--- a/src/Yavsc.Org/Controllers/Contracting/SIRENExceptionsController.cs
+++ b/src/Yavsc.Org/Controllers/Contracting/SIRENExceptionsController.cs
@@ -3,6 +3,8 @@ using Microsoft.AspNetCore.Mvc;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Server.Helpers;
+using Microsoft.EntityFrameworkCore;
+using System.Linq;
namespace Yavsc.Controllers
{
@@ -13,7 +15,7 @@ namespace Yavsc.Controllers
public SIRENExceptionsController(ApplicationDbContext context)
{
- _context = context;
+ _context = context;
}
// GET: SIRENExceptions
@@ -50,15 +52,41 @@ namespace Yavsc.Controllers
[ValidateAntiForgeryToken]
public IActionResult Create(ExceptionSIREN exceptionSIREN)
{
+ exceptionSIREN ??= new ExceptionSIREN();
+ exceptionSIREN.SIREN = NormalizeSiren(exceptionSIREN.SIREN);
+
+ if (string.IsNullOrWhiteSpace(exceptionSIREN.SIREN) || exceptionSIREN.SIREN.Length != 9 || !exceptionSIREN.SIREN.All(char.IsDigit))
+ {
+ ModelState.AddModelError(nameof(ExceptionSIREN.SIREN), "Le SIREN doit contenir exactement 9 chiffres.");
+ }
+
+ if (_context.ExceptionsSIREN.Any(e => e.SIREN == exceptionSIREN.SIREN))
+ {
+ ModelState.AddModelError(nameof(ExceptionSIREN.SIREN), "Ce SIREN est deja dans la liste des exceptions.");
+ }
+
if (ModelState.IsValid)
{
_context.ExceptionsSIREN.Add(exceptionSIREN);
- _context.SaveChanges(User.GetUserId());
- return RedirectToAction("Index");
+ try
+ {
+ _context.SaveChanges(User.GetUserId());
+ return RedirectToAction("Index");
+ }
+ catch (DbUpdateException)
+ {
+ ModelState.AddModelError(string.Empty, "Impossible d'enregistrer cette exception SIREN.");
+ }
}
return View(exceptionSIREN);
}
+ private static string NormalizeSiren(string? siren)
+ {
+ if (string.IsNullOrWhiteSpace(siren)) return string.Empty;
+ return new string(siren.Where(char.IsDigit).ToArray());
+ }
+
// GET: SIRENExceptions/Edit/5
public IActionResult Edit(string id)
{
diff --git a/src/Yavsc.Org/Controllers/HomeController.cs b/src/Yavsc.Org/Controllers/HomeController.cs
index c7aa131ff..6132b093e 100644
--- a/src/Yavsc.Org/Controllers/HomeController.cs
+++ b/src/Yavsc.Org/Controllers/HomeController.cs
@@ -63,9 +63,35 @@ namespace Yavsc.Controllers
.Where(a => a.ParentCode == id)
.OrderByDescending(a => a.Rate).ToList();
+ var candidateCodes = toShow
+ .Select(a => a.Code)
+ .Concat(toShow.SelectMany(a => (a.Children ?? new List())
+ .Where(c => !c.Hidden)
+ .Select(c => c.Code)))
+ .Where(c => !string.IsNullOrWhiteSpace(c))
+ .Distinct()
+ .ToArray();
+
+ var performerCounts = _dbContext.UserActivities
+ .Where(ua => candidateCodes.Contains(ua.DoesCode))
+ .GroupBy(ua => ua.DoesCode)
+ .Select(g => new { Code = g.Key, Count = g.Select(x => x.UserId).Distinct().Count() })
+ .ToDictionary(x => x.Code, x => x.Count);
+
+ toShow = toShow
+ .Where(a =>
+ (performerCounts.TryGetValue(a.Code, out var ownCount) && ownCount > 0)
+ || (a.Children ?? new List())
+ .Where(c => !c.Hidden)
+ .Any(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0))
+ .ToList();
+
foreach (var a in toShow)
{
- a.Children = a.Children.Where(c => !c.Hidden).ToList();
+ a.Children = (a.Children ?? new List())
+ .Where(c => !c.Hidden)
+ .Where(c => performerCounts.TryGetValue(c.Code, out var childCount) && childCount > 0)
+ .ToList();
}
return View(toShow);
}
diff --git a/src/Yavsc.Org/Directory.Packages.props b/src/Yavsc.Org/Directory.Packages.props
deleted file mode 100644
index d9925e02d..000000000
--- a/src/Yavsc.Org/Directory.Packages.props
+++ /dev/null
@@ -1,24 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/src/Yavsc.Org/Extensions/HostingExtensions.cs b/src/Yavsc.Org/Extensions/HostingExtensions.cs
index 6528b9c6a..e986620f1 100644
--- a/src/Yavsc.Org/Extensions/HostingExtensions.cs
+++ b/src/Yavsc.Org/Extensions/HostingExtensions.cs
@@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
using Microsoft.AspNetCore.Localization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
+using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -803,6 +804,7 @@ public static class HostingExtensions
// silent refresh path to work; without it IdentityServer
// refuses to issue a refresh_token.
"blogs",
+ "api",
IdentityServer8.IdentityServerConstants.StandardScopes.OpenId,
IdentityServer8.IdentityServerConstants.StandardScopes.Profile,
IdentityServer8.IdentityServerConstants.StandardScopes.OfflineAccess,
@@ -1281,10 +1283,52 @@ ADD COLUMN IF NOT EXISTS ""Moderated"" boolean NOT NULL DEFAULT FALSE;");
app.UseFileServer(Config.AvatarsOptions);
+ app.Use(async (context, next) =>
+ {
+ await next();
+
+ if (context.Response.StatusCode != StatusCodes.Status404NotFound
+ || context.Response.HasStarted
+ || !HttpMethods.IsGet(context.Request.Method)
+ || !context.Request.Path.StartsWithSegments(Constants.AvatarsPath, out var avatarFile))
+ {
+ return;
+ }
+
+ var webHostEnvironment = app.ApplicationServices.GetRequiredService();
+ var fallbackAsset = ResolveAvatarFallbackAsset(avatarFile);
+ var fallbackFile = webHostEnvironment.WebRootFileProvider.GetFileInfo(fallbackAsset.TrimStart('/'));
+ if (!fallbackFile.Exists)
+ {
+ return;
+ }
+
+ context.Response.StatusCode = StatusCodes.Status200OK;
+ context.Response.ContentType = "image/png";
+ await context.Response.SendFileAsync(fallbackFile);
+ });
+
app.UseFileServer(Config.GitOptions);
app.UseStaticFiles();
return app;
}
+ private static string ResolveAvatarFallbackAsset(PathString avatarFile)
+ {
+ var fileName = avatarFile.Value ?? string.Empty;
+
+ if (fileName.EndsWith(".xs.png", StringComparison.OrdinalIgnoreCase))
+ {
+ return "/images/Users/icon_user.xs.png";
+ }
+
+ if (fileName.EndsWith(".s.png", StringComparison.OrdinalIgnoreCase))
+ {
+ return "/images/Users/icon_user.s.png";
+ }
+
+ return Constants.DefaultAvatar;
+ }
+
}
diff --git a/src/Yavsc.Org/Migrations/20260831040104_AddPerformerCountryValidation.Designer.cs b/src/Yavsc.Org/Migrations/20260831040104_AddPerformerCountryValidation.Designer.cs
new file mode 100644
index 000000000..3348b9c4d
--- /dev/null
+++ b/src/Yavsc.Org/Migrations/20260831040104_AddPerformerCountryValidation.Designer.cs
@@ -0,0 +1,4734 @@
+//
+using System;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.EntityFrameworkCore.Infrastructure;
+using Microsoft.EntityFrameworkCore.Migrations;
+using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
+using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
+using Yavsc.Models;
+
+#nullable disable
+
+namespace Yavsc.Migrations
+{
+ [DbContext(typeof(ApplicationDbContext))]
+ [Migration("20260831040104_AddPerformerCountryValidation")]
+ partial class AddPerformerCountryValidation
+ {
+ ///
+ protected override void BuildTargetModel(ModelBuilder modelBuilder)
+ {
+#pragma warning disable 612, 618
+ modelBuilder
+ .HasAnnotation("ProductVersion", "10.0.9")
+ .HasAnnotation("Relational:MaxIdentifierLength", 63);
+
+ NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResource", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("AllowedAccessTokenSigningAlgorithms")
+ .HasColumnType("text");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("DisplayName")
+ .HasColumnType("text");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("LastAccessed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("NonEditable")
+ .HasColumnType("boolean");
+
+ b.Property("ShowInDiscoveryDocument")
+ .HasColumnType("boolean");
+
+ b.Property("Updated")
+ .HasColumnType("timestamp with time zone");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApiResources");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiResourceId")
+ .HasColumnType("integer");
+
+ b.Property("ApiResourceId1")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApiResourceId");
+
+ b.HasIndex("ApiResourceId1");
+
+ b.ToTable("ApiResourceClaims");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceProperty", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiResourceId")
+ .HasColumnType("integer");
+
+ b.Property("ApiResourceId1")
+ .HasColumnType("integer");
+
+ b.Property("Key")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApiResourceId");
+
+ b.HasIndex("ApiResourceId1");
+
+ b.ToTable("ApiResourceProperties");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceScope", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiResourceId")
+ .HasColumnType("integer");
+
+ b.Property("ApiResourceId1")
+ .HasColumnType("integer");
+
+ b.Property("Scope")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApiResourceId");
+
+ b.HasIndex("ApiResourceId1");
+
+ b.ToTable("ApiResourceScopes");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiResourceSecret", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ApiResourceId")
+ .HasColumnType("integer");
+
+ b.Property("ApiResourceId1")
+ .HasColumnType("integer");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("Expiration")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ApiResourceId");
+
+ b.HasIndex("ApiResourceId1");
+
+ b.ToTable("ApiResourceSecrets");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScope", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("DisplayName")
+ .HasColumnType("text");
+
+ b.Property("Emphasize")
+ .HasColumnType("boolean");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("Name")
+ .HasColumnType("text");
+
+ b.Property("Required")
+ .HasColumnType("boolean");
+
+ b.Property("ShowInDiscoveryDocument")
+ .HasColumnType("boolean");
+
+ b.HasKey("Id");
+
+ b.ToTable("ApiScopes");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("ScopeId")
+ .HasColumnType("integer");
+
+ b.Property("ScopeId1")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ScopeId");
+
+ b.HasIndex("ScopeId1");
+
+ b.ToTable("ApiScopeClaims");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ApiScopeProperty", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id"));
+
+ b.Property("Key")
+ .HasColumnType("text");
+
+ b.Property("ScopeId")
+ .HasColumnType("integer");
+
+ b.Property("ScopeId1")
+ .HasColumnType("integer");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ScopeId");
+
+ b.HasIndex("ScopeId1");
+
+ b.ToTable("ApiScopeProperties");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.Client", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("AbsoluteRefreshTokenLifetime")
+ .HasColumnType("integer");
+
+ b.Property("AccessTokenLifetime")
+ .HasColumnType("integer");
+
+ b.Property("AccessTokenType")
+ .HasColumnType("integer");
+
+ b.Property("AllowAccessTokensViaBrowser")
+ .HasColumnType("boolean");
+
+ b.Property("AllowOfflineAccess")
+ .HasColumnType("boolean");
+
+ b.Property("AllowPlainTextPkce")
+ .HasColumnType("boolean");
+
+ b.Property("AllowRememberConsent")
+ .HasColumnType("boolean");
+
+ b.Property("AllowedIdentityTokenSigningAlgorithms")
+ .HasColumnType("text");
+
+ b.Property("AlwaysIncludeUserClaimsInIdToken")
+ .HasColumnType("boolean");
+
+ b.Property("AlwaysSendClientClaims")
+ .HasColumnType("boolean");
+
+ b.Property("AuthorizationCodeLifetime")
+ .HasColumnType("integer");
+
+ b.Property("BackChannelLogoutSessionRequired")
+ .HasColumnType("boolean");
+
+ b.Property("BackChannelLogoutUri")
+ .HasColumnType("text");
+
+ b.Property("ClientClaimsPrefix")
+ .HasColumnType("text");
+
+ b.Property("ClientId")
+ .HasColumnType("text");
+
+ b.Property("ClientName")
+ .HasColumnType("text");
+
+ b.Property("ClientUri")
+ .HasColumnType("text");
+
+ b.Property("ConsentLifetime")
+ .HasColumnType("integer");
+
+ b.Property("Created")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("Description")
+ .HasColumnType("text");
+
+ b.Property("DeviceCodeLifetime")
+ .HasColumnType("integer");
+
+ b.Property("EnableLocalLogin")
+ .HasColumnType("boolean");
+
+ b.Property("Enabled")
+ .HasColumnType("boolean");
+
+ b.Property("FrontChannelLogoutSessionRequired")
+ .HasColumnType("boolean");
+
+ b.Property("FrontChannelLogoutUri")
+ .HasColumnType("text");
+
+ b.Property("IdentityTokenLifetime")
+ .HasColumnType("integer");
+
+ b.Property("IncludeJwtId")
+ .HasColumnType("boolean");
+
+ b.Property("LastAccessed")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("LogoUri")
+ .HasColumnType("text");
+
+ b.Property("NonEditable")
+ .HasColumnType("boolean");
+
+ b.Property("PairWiseSubjectSalt")
+ .HasColumnType("text");
+
+ b.Property("ProtocolType")
+ .HasColumnType("text");
+
+ b.Property("RefreshTokenExpiration")
+ .HasColumnType("integer");
+
+ b.Property("RefreshTokenUsage")
+ .HasColumnType("integer");
+
+ b.Property("RequireClientSecret")
+ .HasColumnType("boolean");
+
+ b.Property("RequireConsent")
+ .HasColumnType("boolean");
+
+ b.Property("RequirePkce")
+ .HasColumnType("boolean");
+
+ b.Property("RequireRequestObject")
+ .HasColumnType("boolean");
+
+ b.Property