diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index fd46437a..a30c9c06 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 ec42b81b..9bef40e3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,61 @@
# Changelog
+## [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] 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
+
+néant
+
+## [1.0.8-rc11] - unstable
+
+### Added
+
+nothing
+
+### Changed
+
+* [Yavsc.Api.Test] Mise a jour de `Microsoft.EntityFrameworkCore.Sqlite` vers `10.0.11` afin de supprimer l'alerte NU1903 liee a `SQLitePCLRaw.lib.e_sqlite3` 2.1.11.
+* [Yavsc.Org] Nettoyage de la configuration NuGet pour le restore: suppression du fichier local `Directory.Packages.props` au profit du fichier racine centralise.
+* [Yavsc.Org] Suppression de references de packages redondantes dans le projet, sans impact fonctionnel attendu.
+
+### Fixed
+
+* [Yavsc.Api.Test] Le restore n'emet plus le warning de vulnerabilite `NU1903` sur `SQLitePCLRaw.lib.e_sqlite3`.
+* [Yavsc.Org] Suppression d'une vulnerabilite de severite elevee sur AutoMapper apres publication et consommation de la nouvelle version candidate de `HigginsSoft.IdentityServer8`.
+
+## [1.0.8-rc10] - unstable
+
+### Added
+
+* [PostIt] Une page d'historique des commandes billing permet maintenant d'ouvrir une commande existante.
+* [PostIt] Une vue "Demandes en cours" en lecture seule est disponible pour le performer, filtrée sur les statuts actifs (Inserted, Accepted, InProgress).
+* [Yavsc.Org] Nouvelles entités `Country` et `PerformerCodeInputValidation` pour piloter la validation du code entreprise performer par pays.
+
+### Changed
+
+* [PostIt] La page détail billing se préremplit depuis une commande existante (Rdv, Brush, MBrush) et passe en mode mise à jour.
+* [Yavsc.Org] Le formulaire `Manage/SetActivity` inclut désormais le pays d'exercice (`fr`, `en`, `pt`) et applique la regex associée au champ `SIREN`.
+* [Yavsc.Org] La vérification externe du numéro d'entreprise est conservée uniquement pour le pays `fr`.
+
+### Fixed
+
+* [PostIt] Le flux historique n'est plus limité à une simple liste: l'action d'ouverture charge la commande cible puis navigue vers la page détail.
+* [Yavsc.Org] Le champ `SIREN` n'est plus validé avec une règle unique indépendante du pays d'exercice.
+
## [1.0.8-rc9] - unstable
### Added
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index fd408c5c..8aa0567d 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 7505c851..f1be93f9 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 b132f3f2..549348f4 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 4c00e629..364b98bd 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 00000000..fb01ede4
--- /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 151045db..79145668 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 00000000..01156156
--- /dev/null
+++ b/contrib/README.md
@@ -0,0 +1,5 @@
+# Read me
+
+## Note aux icones
+
+㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩
diff --git a/contrib/tmp/yavscBlogs.service b/contrib/tmp/yavscBlogs.service
new file mode 100644
index 00000000..718534cd
--- /dev/null
+++ b/contrib/tmp/yavscBlogs.service
@@ -0,0 +1,37 @@
+[Unit]
+Description=yavsc-Blogs
+After=syslog.target
+After=network.target
+Wants=postgresql.service
+After=postgresql.service
+
+[Service]
+RestartSec=5s
+Type=simple
+User=yavsc
+Group=yavsc
+WorkingDirectory=/srv/www/yavsc
+ExecStart=/srv/www/yavsc/Yavsc.Blogs
+Restart=always
+Environment="HOME="
+Environment="ANTHROPIC_API_KEY=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"
+Environment="ANTHROPIC_MAX_TOKENS=255"
+Environment="ASPNETCORE_Environment="
+Environment="ASPNETCORE_Kestrel__Endpoints__Http=http://localhost:Blogs_"
+Environment="ASPNETCORE_ConnectionStrings__YavscConnection=Server=localhost;Port=5432;Database=yavsc;Username=yavsc;Password=4T/X+fOnE;"
+
+Environment="ASPNETCORE_Smtp__Host=\"mercure.pschneider.f\""
+Environment="ASPNETCORE_Smtp__Port=465"
+Environment="ASPNETCORE_Smtp__SenderName=\"Paul Schneider\""
+Environment="ASPNETCORE_Smtp__SenderEmail=\"paul@pschneider.fr\""
+Environment="ASPNETCORE_Smtp__UserName=\"paul\""
+Environment="ASPNETCORE_Smtp__Password=\"j\0Dsn5=t\""
+
+CapabilityBoundingSet=CAP_NET_BIND_SERVICE
+AmbientCapabilities=CAP_NET_BIND_SERVICE
+StandardOutput=syslog
+StandardError=syslog
+SyslogIdentifier=yavscBlogs
+
+[Install]
+WantedBy=multi-user.target
diff --git a/doc/README.md b/doc/README.md
index 912a2e56..fb9bea94 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 00000000..975cd7b7
--- /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 00000000..ecc14f1d
--- /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/ActivitiesPage.axaml.cs b/src/PostIt/PostIt/Views/ActivitiesPage.axaml.cs
new file mode 100644
index 00000000..95517007
--- /dev/null
+++ b/src/PostIt/PostIt/Views/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/BillingQueriesPage.axaml b/src/PostIt/PostIt/Views/BillingQueriesPage.axaml
new file mode 100644
index 00000000..c4b333e4
--- /dev/null
+++ b/src/PostIt/PostIt/Views/BillingQueriesPage.axaml
@@ -0,0 +1,52 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/PostIt/PostIt/Views/BillingQueriesPage.axaml.cs b/src/PostIt/PostIt/Views/BillingQueriesPage.axaml.cs
new file mode 100644
index 00000000..1ca28f04
--- /dev/null
+++ b/src/PostIt/PostIt/Views/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/CommandFormsPage.axaml b/src/PostIt/PostIt/Views/CommandFormsPage.axaml
new file mode 100644
index 00000000..8fad010e
--- /dev/null
+++ b/src/PostIt/PostIt/Views/CommandFormsPage.axaml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/CommandFormsPage.axaml.cs b/src/PostIt/PostIt/Views/CommandFormsPage.axaml.cs
new file mode 100644
index 00000000..1d19715b
--- /dev/null
+++ b/src/PostIt/PostIt/Views/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/Commands/BrushPage.axaml b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml
new file mode 100644
index 00000000..9c33cecb
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Commands/BrushPage.axaml
@@ -0,0 +1,113 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 00000000..c028472f
--- /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 00000000..46280602
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Commands/RdvPage.axaml
@@ -0,0 +1,91 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 00000000..6a0e1d05
--- /dev/null
+++ b/src/PostIt/PostIt/Views/Commands/RdvPage.axaml.cs
@@ -0,0 +1,17 @@
+using Avalonia.Controls;
+using Avalonia.Markup.Xaml;
+
+namespace PostIt.Views.Commands;
+
+public partial class RdvPage : ContentPage
+{
+ public RdvPage()
+ {
+ InitializeComponent();
+ }
+
+ private void InitializeComponent()
+ {
+ AvaloniaXamlLoader.Load(this);
+ }
+}
diff --git a/src/PostIt/PostIt/Views/HomePage.axaml b/src/PostIt/PostIt/Views/HomePage.axaml
index 16e66cc0..7f6527e3 100644
--- a/src/PostIt/PostIt/Views/HomePage.axaml
+++ b/src/PostIt/PostIt/Views/HomePage.axaml
@@ -18,5 +18,9 @@
Command="{Binding OpenBlogs}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
+
diff --git a/src/PostIt/PostIt/Views/MainPage.axaml b/src/PostIt/PostIt/Views/MainPage.axaml
index 0d2f5411..41ed7ece 100644
--- a/src/PostIt/PostIt/Views/MainPage.axaml
+++ b/src/PostIt/PostIt/Views/MainPage.axaml
@@ -24,13 +24,13 @@
-
-
-
-
-
+
+
+
+
-
-
+
+
-
+
+ Text="{Binding ApiUrl, Mode=TwoWay}"/>
-
-
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/PostIt/PostIt/Views/SettingsPage.axaml.cs b/src/PostIt/PostIt/Views/SettingsPage.axaml.cs
index 14916bb3..593dcfee 100644
--- a/src/PostIt/PostIt/Views/SettingsPage.axaml.cs
+++ b/src/PostIt/PostIt/Views/SettingsPage.axaml.cs
@@ -1,12 +1,116 @@
+using System;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using Microsoft.Extensions.DependencyInjection;
+using PostIt.Services;
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();
}
-}
\ No newline at end of file
+
+ private async void ChooseAvatar_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
+ {
+ var statusVersion = Interlocked.Increment(ref _avatarStatusVersion);
+ ChooseAvatarButton.IsEnabled = false;
+ SetAvatarStatus("Sélection d'un fichier avatar...", Brushes.Gray);
+
+ var topLevel = TopLevel.GetTopLevel(this);
+ if (topLevel is null)
+ {
+ SetAvatarStatus("Impossible d'accéder à la fenêtre active.", Brushes.IndianRed);
+ 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 annulé.", Brushes.Gray);
+ ChooseAvatarButton.IsEnabled = true;
+ return;
+ }
+
+ try
+ {
+ SetAvatarStatus("Upload avatar en cours...", Brushes.Gray);
+
+ 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 à jour."
+ : message, Brushes.ForestGreen);
+
+ _ = ClearSuccessStatusLaterAsync(statusVersion);
+ }
+ catch (Exception ex)
+ {
+ SetAvatarStatus($"Échec upload avatar: {ex.Message}", Brushes.IndianRed);
+ 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;
+
+ if (AvatarUploadStatusText.Foreground == Brushes.ForestGreen)
+ {
+ SetAvatarStatus($"Avatar prêt. Formats supportés: {AcceptedAvatarFormats}. Taille max: {MaxAvatarSizeMegabytes} MB.", Brushes.Gray);
+ }
+ }
+
+ private void SetAvatarStatus(string message, IBrush color)
+ {
+ AvatarUploadStatusText.Foreground = color;
+ AvatarUploadStatusText.Text = message;
+ }
+
+ 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/SignaturePage.axaml.cs b/src/PostIt/PostIt/Views/SignaturePage.axaml.cs
index da6b8b7b..b6e9bc12 100644
--- a/src/PostIt/PostIt/Views/SignaturePage.axaml.cs
+++ b/src/PostIt/PostIt/Views/SignaturePage.axaml.cs
@@ -62,9 +62,16 @@ public partial class SignaturePage : ContentPage
var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return;
+ var pending = _control.PendingStroke;
var strokes = _control.Strokes;
+ int sealedCount = strokes.Count - pending.Count;
+ if (sealedCount < 0)
+ {
+ sealedCount = 0;
+ }
+
int i = 0;
- while (i < strokes.Count)
+ while (i < sealedCount)
{
int k = strokes[i];
if (k <= 0) break;
@@ -87,5 +94,27 @@ public partial class SignaturePage : ContentPage
poly.Points = pts;
InkLayer.Children.Add(poly);
}
+
+ if (pending.Count > 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/Yavsc.Abstract/HairCut/HairPrestationDto.cs b/src/Yavsc.Abstract/HairCut/HairPrestationDto.cs
new file mode 100644
index 00000000..0c4b80e2
--- /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/Workflow/ActivityInfo.cs b/src/Yavsc.Abstract/Workflow/ActivityInfo.cs
new file mode 100644
index 00000000..c0f543f4
--- /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 00000000..865690e0
--- /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 00000000..d782d0aa
--- /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/IPerformerProfile.cs b/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs
index e53583a5..4314dd37 100644
--- a/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs
+++ b/src/Yavsc.Abstract/Workflow/IPerformerProfile.cs
@@ -3,6 +3,7 @@ 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 00000000..a5ee3b25
--- /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 00000000..84e9c1cc
--- /dev/null
+++ b/src/Yavsc.Abstract/Workflow/PerformerCodeInputValidation.cs
@@ -0,0 +1,86 @@
+using System;
+using System.Collections.Generic;
+using System.ComponentModel.DataAnnotations;
+using System.ComponentModel.DataAnnotations.Schema;
+
+namespace Yavsc.Models.Workflow
+{
+ ///
+ /// Validation rule for performer business code input by country.
+ ///
+ public class PerformerCodeInputValidation
+ {
+ [Key]
+ public long Id { get; set; }
+
+ [Required]
+ [MaxLength(2)]
+ [MinLength(2)]
+ public string CountryCode { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(256)]
+ public string RegularExpression { get; set; } = string.Empty;
+
+ [Required]
+ [MaxLength(128)]
+ public string ErrorMessage { get; set; } = string.Empty;
+
+ [ForeignKey(nameof(CountryCode))]
+ public Country Country { get; set; }
+ }
+
+ public static class PerformerCodeInputValidationCatalog
+ {
+ public static readonly IReadOnlyList Countries = new List
+ {
+ new() { Code = "fr", DisplayName = "France" },
+ new() { Code = "en", DisplayName = "England" },
+ new() { Code = "pt", DisplayName = "Portugal" },
+ };
+
+ public static readonly IReadOnlyList Rules = new List
+ {
+ new()
+ {
+ Id = 1,
+ CountryCode = "fr",
+ RegularExpression = "^[0-9]{9,14}$",
+ ErrorMessage = "Le code FR doit contenir entre 9 et 14 chiffres."
+ },
+ new()
+ {
+ Id = 2,
+ CountryCode = "en",
+ RegularExpression = "^[A-Za-z0-9]{8,14}$",
+ ErrorMessage = "Le code EN doit contenir entre 8 et 14 caracteres alphanumeriques."
+ },
+ new()
+ {
+ Id = 3,
+ CountryCode = "pt",
+ RegularExpression = "^[0-9]{9}$",
+ ErrorMessage = "Le code PT doit contenir exactement 9 chiffres."
+ },
+ };
+
+ public static string NormalizeCountryCode(string? countryCode)
+ {
+ return (countryCode ?? string.Empty).Trim().ToLowerInvariant();
+ }
+
+ public static PerformerCodeInputValidation? GetRule(string? countryCode)
+ {
+ var normalized = NormalizeCountryCode(countryCode);
+ foreach (var rule in Rules)
+ {
+ if (string.Equals(rule.CountryCode, normalized, StringComparison.Ordinal))
+ {
+ return rule;
+ }
+ }
+
+ return null;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/Yavsc.Api.Client/ActivityApiClient.cs b/src/Yavsc.Api.Client/ActivityApiClient.cs
new file mode 100644
index 00000000..52fad1a4
--- /dev/null
+++ b/src/Yavsc.Api.Client/ActivityApiClient.cs
@@ -0,0 +1,79 @@
+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 Uri _baseAddress;
+ private readonly Uri _avatarBaseAddress;
+
+ public ActivityApiClient(IYavscApiClient api, string businessBaseAddress, string? avatarBaseAddress = null)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ if (string.IsNullOrEmpty(businessBaseAddress))
+ throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
+
+ _baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
+ _avatarBaseAddress = string.IsNullOrWhiteSpace(avatarBaseAddress)
+ ? _baseAddress
+ : new Uri(avatarBaseAddress, UriKind.Absolute);
+ }
+
+ 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(_avatarBaseAddress, "/");
+
+ 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(_baseAddress, relativePath).ToString();
+}
diff --git a/src/Yavsc.Api.Client/BillingApiClient.cs b/src/Yavsc.Api.Client/BillingApiClient.cs
new file mode 100644
index 00000000..35a6722d
--- /dev/null
+++ b/src/Yavsc.Api.Client/BillingApiClient.cs
@@ -0,0 +1,364 @@
+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 Uri _baseAddress;
+
+ public BillingApiClient(IYavscApiClient api, string businessBaseAddress)
+ {
+ _api = api ?? throw new ArgumentNullException(nameof(api));
+ if (string.IsNullOrWhiteSpace(businessBaseAddress))
+ throw new ArgumentException("Base address is required.", nameof(businessBaseAddress));
+
+ _baseAddress = new Uri(businessBaseAddress, UriKind.Absolute);
+ }
+
+ 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(_baseAddress, relativePath).ToString();
+
+ 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 00000000..6d658ce4
--- /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 00000000..0102b691
--- /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.Test/ActivityApiControllerTests.cs b/src/Yavsc.Api.Test/ActivityApiControllerTests.cs
new file mode 100644
index 00000000..0525e349
--- /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 00000000..2c670522
--- /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 00000000..c2edf9ea
--- /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 00000000..ca4ae70f
--- /dev/null
+++ b/src/Yavsc.Api.Test/Fixtures/ApiWebServerFixture.cs
@@ -0,0 +1,343 @@
+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,
+ 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,
+ 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,
+ 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/HairCutQueryApiControllerTests.cs b/src/Yavsc.Api.Test/HairCutQueryApiControllerTests.cs
new file mode 100644
index 00000000..b4b7e3e4
--- /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 00000000..8d940b8b
--- /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 00000000..a5706dc5
--- /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 00000000..8fe7e959
--- /dev/null
+++ b/src/Yavsc.Api.Test/RdvQueryApiControllerTests.cs
@@ -0,0 +1,85 @@
+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);
+ }
+}
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 00000000..760f5ce8
--- /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 6d91ba82..a8d28806 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,119 @@ 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();
+
+ 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 =>
+ (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();
+
+ 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 +259,52 @@ 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,
+ Photo = activity.Photo,
+ Rate = activity.Rate,
+ PerformerCount = performerCounts.TryGetValue(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 => performerCounts.TryGetValue(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,
+ Photo = c.Photo,
+ Rate = c.Rate,
+ PerformerCount = performerCounts.TryGetValue(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(),
+ };
+ }
}
}
diff --git a/src/Yavsc.Api/Controllers/Business/BillingController.cs b/src/Yavsc.Api/Controllers/Business/BillingController.cs
index 72180fc1..197e06b1 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 0d7112ec..293cca9a 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 891909f0..b01819f2 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 3fc91365..748f60cc 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 91d57d9f..a4ecbf2f 100644
--- a/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs
+++ b/src/Yavsc.Api/Controllers/Business/FrontOfficeApiController.cs
@@ -1,3 +1,4 @@
+using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Yavsc.Helpers;
using Yavsc.Models;
@@ -6,6 +7,7 @@ using Yavsc.ViewModels.FrontOffice;
namespace Yavsc.ApiControllers
{
+ [Authorize]
[Route(Constants.APIPrefix + "/front")]
public class FrontOfficeApiController : Controller
{
diff --git a/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs
new file mode 100644
index 00000000..6c7c1ba6
--- /dev/null
+++ b/src/Yavsc.Api/Controllers/Business/HairCutQueryApiController.cs
@@ -0,0 +1,234 @@
+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();
+ if (string.IsNullOrWhiteSpace(query.ClientId))
+ {
+ query.ClientId = uid;
+ }
+
+ ModelState.Remove("ClientId");
+ ModelState.Remove("Prestation");
+
+ if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ ModelState.AddModelError("ClientId", "You can only create your own HairCutQuery");
+ return BadRequest(ModelState);
+ }
+
+ 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 00000000..f850b3f4
--- /dev/null
+++ b/src/Yavsc.Api/Controllers/Business/HairMultiCutQueryApiController.cs
@@ -0,0 +1,286 @@
+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();
+ if (string.IsNullOrWhiteSpace(query.ClientId))
+ {
+ query.ClientId = uid;
+ }
+
+ ModelState.Remove("ClientId");
+
+ if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ ModelState.AddModelError("ClientId", "You can only create your own HairMultiCutQuery");
+ return BadRequest(ModelState);
+ }
+
+ 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 5f769e4e..930eea91 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 2ad1ded5..36586adb 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 d9792839..ae3820dc 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 00000000..fcfac2b2
--- /dev/null
+++ b/src/Yavsc.Api/Controllers/Business/RdvQueryApiController.cs
@@ -0,0 +1,189 @@
+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();
+ if (string.IsNullOrWhiteSpace(query.ClientId))
+ {
+ query.ClientId = uid;
+ }
+
+ ModelState.Remove("ClientId");
+
+ if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ ModelState.AddModelError("ClientId", "You can only create your own RdvQuery");
+ return BadRequest(ModelState);
+ }
+
+ 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)
+ {
+ if (!ModelState.IsValid)
+ {
+ return BadRequest(ModelState);
+ }
+
+ if (id != query.Id)
+ {
+ return BadRequest();
+ }
+
+ var uid = User.GetUserId();
+ if (query.ClientId != uid && !User.IsInRole(Constants.AdminGroupName))
+ {
+ return Forbid();
+ }
+
+ _context.Entry(query).State = EntityState.Modified;
+
+ 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);
+ }
+}
diff --git a/src/Yavsc.Api/Controllers/accounting/AccountController.cs b/src/Yavsc.Api/Controllers/accounting/AccountController.cs
index aff71013..155274cd 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 3ed20e8d..5269eef1 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/appsettings-api.json b/src/Yavsc.Api/appsettings-api.json
index ac626c0d..d8b295de 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.Org.Tests/NonRegression/AvatarFallbackTests.cs b/src/Yavsc.Org.Tests/NonRegression/AvatarFallbackTests.cs
new file mode 100644
index 00000000..200f37fa
--- /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 00000000..c47bdff5
--- /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 00000000..23ace458
--- /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 c77a20ab..327c2db4 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/Contants.cs b/src/Yavsc.Org/Contants.cs
index c4a5e8b1..246bc3ac 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 05cb55b5..77e0e32a 100644
--- a/src/Yavsc.Org/Controllers/Accounting/AccountController.cs
+++ b/src/Yavsc.Org/Controllers/Accounting/AccountController.cs
@@ -94,107 +94,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
///
diff --git a/src/Yavsc.Org/Controllers/Accounting/ManageController.cs b/src/Yavsc.Org/Controllers/Accounting/ManageController.cs
index 43e11cd2..e09bbb5a 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/Contracting/FormsController.cs b/src/Yavsc.Org/Controllers/Contracting/FormsController.cs
deleted file mode 100644
index 423cf2f4..00000000
--- 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 bf08484e..ed4ee5b5 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 c7aa131f..6132b093 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 d9925e02..00000000
--- 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 6528b9c6..e986620f 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 00000000..3348b9c4
--- /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("SlidingRefreshTokenLifetime")
+ .HasColumnType("integer");
+
+ b.Property("UpdateAccessTokenClaimsOnRefresh")
+ .HasColumnType("boolean");
+
+ b.Property("Updated")
+ .HasColumnType("timestamp with time zone");
+
+ b.Property("UserCodeType")
+ .HasColumnType("text");
+
+ b.Property("UserSsoLifetime")
+ .HasColumnType("integer");
+
+ b.HasKey("Id");
+
+ b.ToTable("Clients");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientClaim", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("Type")
+ .HasColumnType("text");
+
+ b.Property("Value")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.ToTable("ClientClaims");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientCorsOrigin", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("Origin")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientCorsOrigins");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientGrantType", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("GrantType")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.ToTable("ClientGrantTypes");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientIdPRestriction", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("Provider")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientIdPRestrictions");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientPostLogoutRedirectUri", b =>
+ {
+ b.Property("Id")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer");
+
+ NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id"));
+
+ b.Property("ClientId")
+ .HasColumnType("integer");
+
+ b.Property("ClientId1")
+ .HasColumnType("integer");
+
+ b.Property("PostLogoutRedirectUri")
+ .HasColumnType("text");
+
+ b.HasKey("Id");
+
+ b.HasIndex("ClientId");
+
+ b.HasIndex("ClientId1");
+
+ b.ToTable("ClientPostLogoutRedirectUris");
+ });
+
+ modelBuilder.Entity("IdentityServer8.EntityFramework.Entities.ClientProperty", b =>
+ {
+ b.Property