Compare commits

..

No commits in common. "main" and "1.0.8-rc7" have entirely different histories.

169 changed files with 741 additions and 12374 deletions

2
.vscode/tasks.json vendored
View file

@ -7,7 +7,7 @@
"fileLocation": ["relative", "${workspaceFolder}"], "fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet", "source": "dotnet",
"pattern": { "pattern": {
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$", "regexp": "^\\s+(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.+): (.*)$",
"file": 1, "file": 1,
"line": 2, "line": 2,
"column": 3, "column": 3,

View file

@ -1,92 +1,5 @@
# Changelog # 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
nothing
### Changed
masquage non-owner côté backend de l'ACL du billet
### Fixed
On a maintenant le comportement attendu bout en bout:
ACL chargée depuis le BlogPostDto
noms de cercles affichés dans le dialogue ACL côté PostIt
## [1.0.8-rc8] - unstable
### Added
nothing
### Changed
nothing
### Fixed
The PostIt publish toggle button
## [1.0.8-rc7] - unstable ## [1.0.8-rc7] - unstable
### Added ### Added

View file

@ -49,19 +49,6 @@ Les tests sont répartis en :
item « Tests d'intégration smoke par BC ». item « Tests d'intégration smoke par BC ».
- `src/PostIt.Tests/` — tests unitaires du client desktop PostIt. - `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
Le `CHANGELOG.md` est un document de changement de version Le `CHANGELOG.md` est un document de changement de version
@ -74,8 +61,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 : À noter : la **parité du numéro de patch** porte une signification de canal :
- **patch pair** (ex. `1.0.0`, `1.0.2`) → **preview** - **patch pair** (ex. `1.0.0`, `1.0.2`) → **stable**
- **patch impair** (ex. `1.0.1`, `1.0.3`) → **stable** - **patch impair** (ex. `1.0.1`, `1.0.3`) → **preview**
- **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable** - **suffixe** (ex. `1.0.0-rc1`, `1.0.0-alpha`) → **instable**
Cette convention est partagée avec le dépôt Cette convention est partagée avec le dépôt

View file

@ -1,31 +1,15 @@
<Project> <Project>
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" /> <PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="8.1.0-alpha.171" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" /> <PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" /> <PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" /> <PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="AsciiDocSharp" Version="0.1.0" />
<PackageVersion Include="AsciiDocSharp.Converters.Html" Version="0.1.0" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.6.2" />
<PackageVersion Include="Google.Apis.Compute.v1" Version="1.74.0.4138" />
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" />
@ -33,24 +17,14 @@
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
<PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.2.1" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.2.1" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" /> <PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="xunit.v3" Version="3.2.2" /> <PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.v3.common" Version="3.2.2" /> <PackageVersion Include="xunit.v3.common" Version="3.2.2" />
<PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" /> <PackageVersion Include="xunit.v3.extensibility.core" Version="3.2.2" />
<PackageVersion Include="YamlDotNet" Version="18.1.0" /> <PackageVersion Include="YamlDotNet" Version="18.1.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -28,10 +28,6 @@ sous [`doc/`](./doc/). Voir l'[index de la documentation](./doc/README.md)
pour le sommaire complet. La racine de l'architecture est pour le sommaire complet. La racine de l'architecture est
[Architecture.md](./doc/Architecture.md). [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 # Construction et déploiement

View file

@ -68,7 +68,7 @@ Trois principes non négociables traversent tous les jalons :
> >
> Chaque jalon a un **critère de sortie** vérifiable. > Chaque jalon a un **critère de sortie** vérifiable.
### Jalon 0 — Fondations techniques ### Jalon 0 — Fondations techniques *(en cours)*
> Cible : pouvoir parler du domaine sans se battre avec le runtime. > 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 *(en cours)* ### Jalon 1 — Prestation signée de bout en bout
> Cible : un projet client/fournisseur aboutit à un **devis signé par les deux parties**, traçable, avec notifications. > Cible : un projet client/fournisseur aboutit à un **devis signé par les deux parties**, traçable, avec notifications.

View file

@ -1,24 +0,0 @@
# parametres de déploiement au Makefile
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=yavsc
POSTGRES_USER=yavsc
POSTGRES_PASSWORD=<your-password-here>
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="<your-smtp-password-here>"
DESTDIR=/srv/www/yavsc

View file

@ -1,4 +1,4 @@
APP_PROJECT_NAMES=Org Blogs Api APP_PROJECT_NAMES=Org Blogs
SLNDIR=.. SLNDIR=..
include $(SLNDIR)/.env include $(SLNDIR)/.env
@ -9,11 +9,9 @@ generated/:
generated/yavscOrg.service: generated/yavscOrg.service:
generated/yavscBlogs.service: generated/yavscBlogs.service:
generated/yavscApi.service:
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@cat template.service | APP_NAME="$*" \ @cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \ HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$*_$(HTTP_PORT)" \ HTTP_PORT="$*_$(HTTP_PORT)" \
BASEAPPDIR="$(BASEAPPDIR)" \ BASEAPPDIR="$(BASEAPPDIR)" \
@ -35,12 +33,11 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
@echo Created service file: $@ @echo Created service file: $@
copy-services: copy-service-Org copy-service-Blogs copy-service-Api copy-services: copy-service-Org copy-service-Blogs
copy-service-Org: /etc/systemd/system/yavscOrg.service copy-service-Org: /etc/systemd/system/yavscOrg.service
copy-service-Blogs: /etc/systemd/system/yavscBlogs.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 build_publish_Api stop-services copy-binaries: build_publish_Org build_publish_Blogs stop-services
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \ do LCAPI=$$(echo $${project}|tr [:upper:] [:lower:]) ; \
echo "$${project} -> $${LCAPI}" ; \ echo "$${project} -> $${LCAPI}" ; \
@ -63,8 +60,6 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv
build_publish_%: clean_publish_dir_% build_publish_%: clean_publish_dir_%
@ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj @ASPNETCORE_ENV=$(CONFIGURATION) dotnet publish $(SLNDIR)/src/Yavsc.$*/Yavsc.$*.csproj
build_publish: build_publish_Org build_publish_Blogs build_publish_Api
clean_publish_dir_%: clean_publish_dir_%:
@rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish @rm -rf $(SLNDIR)/src/Yavsc.$*/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish
@ -89,7 +84,6 @@ stop-services:
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(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.Blogs/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
$(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
showConfig: showConfig:
@echo CONFIGURATION: $(CONFIGURATION) @echo CONFIGURATION: $(CONFIGURATION)
@ -98,3 +92,4 @@ showConfig:
clean: clean:
@rm -rf generated @rm -rf generated
.PHONY: build_publish mep showConfig copy-service-Org copy-service-Blogs reinstall clean

View file

@ -1,5 +0,0 @@
# Read me
## Note aux icones
㝉®🅬⛒⛑🩎🩺🞫🞮🞕🞖🞆🔴🔵🔲🖂🔧🔩🔐🔌💾💼💬💭👿👾🏷🎯🏹🌍🎎💩

View file

@ -1,37 +0,0 @@
[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

View file

@ -18,9 +18,6 @@ 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/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) | | [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é | | [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 ## Roadmap & design exploration

View file

@ -1,20 +0,0 @@
# 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.

View file

@ -1,101 +0,0 @@
# 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: <projet/fichier/fonction>
Objectif: <resultat observable>
Contraintes: <style, archi, perimetre>
Verification: <tests exacts a lancer>
Sortie attendue: <fichiers modifies + risques>
```
## 4 scenarios de reference
## 1) Explorer un bounded context
Intention:
- Comprendre ou implementer un changement dans un BC sans regression laterale.
Prompt minimal:
```text
Explore le BC <nom> avec profondeur medium.
Retour: composants touches, points d'entree, tests existants et risques.
```
Preuves attendues:
- Carte des fichiers a modifier.
- Test(s) smoke/mandatory proposes.
## 2) Ajouter un smoke test
Intention:
- Couvrir rapidement un endpoint ou une route publique.
Prompt minimal:
```text
Propose un smoke test pour <route/endpoint> dans le projet de test approprie.
Respecte les conventions de doc/testing.md.
```
Preuves attendues:
- Fichier test cree/modifie.
- Commande precise pour executer le test cible.
## 3) Corriger une regression backend API
Intention:
- Corriger un bug sans casser un flux voisin.
Prompt minimal:
```text
Planifie puis implemente un fix de <symptome> dans <projet>.
Ajoute/ajuste un test NonRegression rouge puis vert.
```
Preuves attendues:
- Explication cause racine.
- Test non-regression associe.
- Commande d'execution et resultat attendu.
## 4) Tracer un flux PostIt/OIDC
Intention:
- Localiser une cassure d'authentification entre client et serveur.
Prompt minimal:
```text
Cartographie le flux OIDC PostIt: entrypoints, callback, stockage token,
refresh. Donne points de rupture probables et tests/verification proposes.
```
Preuves attendues:
- Liste ordonnee des etapes du flux.
- Fichiers critiques.
- Hypotheses testables.
## Anti-patterns a eviter
- Prompt sans objectif verifiable.
- Demande trop large sans perimetre de fichiers.
- Validation basee uniquement sur "ca semble correct".
- Pas de lien entre changement et niveau de test.
## Gate PR minimale (agent-assiste)
Avant validation:
- Impact architecture explicite.
- Rationale de choix agent explicite.
- Test(s) executes et justifies.
- Risques residuels documentes.

View file

@ -1,73 +0,0 @@
# Onboarding guide: agents IA + architecture + tests
Ce guide est optimise pour accelerer la prise en main des agents IA
(Copilot, Plan, Explore) dans Yavsc, avec une verification rapide
par les tests.
## Resultat attendu
A la fin du parcours, un contributeur doit pouvoir:
- Identifier les projets impactes par une modification.
- Choisir l'agent adapte a l'intention de travail.
- Produire une proposition de changement verifiable par les tests.
## Parcours en 3 modules
## Module A - Comprendre le terrain (30-45 min)
Objectif: acquerir une lecture fiable de l'architecture.
1. Lire [README.md](../README.md) puis [Architecture.md](Architecture.md).
2. Lire [architecture/decoupage-organisation.md](architecture/decoupage-organisation.md).
3. Selon le domaine:
- Backend/API: [architecture/workflow-multi-parties.md](architecture/workflow-multi-parties.md)
- PostIt: [architecture/postit.md](architecture/postit.md) puis [architecture/postit-oidc.md](architecture/postit-oidc.md)
Definition of done:
- Expliquer en 5 phrases quelles couches sont touchees.
- Citer le ou les points d'entree applicatifs a verifier.
## Module B - Boucle tests rapide (20-30 min)
Objectif: verifier rapidement sans lancer toute la suite.
1. Lire [testing.md](testing.md).
2. Lancer les smoke tests d'abord, puis mandatory selon le projet.
3. N'elargir au test complet que si le scope depasse le BC touche.
Definition of done:
- Fournir la commande test executee.
- Expliquer pourquoi ce niveau de test est suffisant.
## Module C - Usage agentique en production (30-40 min)
Objectif: utiliser les agents comme accelerateurs, pas comme boites noires.
1. Plan: decomposer la tache en etapes verifiables.
2. Explore: collecter le contexte code/doc precise.
3. Copilot: implementer localement et verifier.
Regles:
- Toujours donner un contexte explicite (fichier, but, contrainte).
- Demander des preuves observables (fichiers modifies, tests, risques).
- Refuser toute sortie non verifiable.
Definition of done:
- Une tache simple est livree avec:
- Plan
- Changement local
- Preuve par test
## Routine continue (sans echeance fixe)
Rituels recommandes:
- Hebdo: revue des prompts qui ont bien fonctionne.
- Mensuel: mise a jour du present guide et du playbook.
- A chaque incident: ajouter un anti-pattern dans le playbook.
## Check-list de validation
- Le changement indique son impact architecture.
- Le choix de l'agent est justifie.
- La preuve test est incluse.
- Les risques residuels sont explicitement listes.

View file

@ -8,6 +8,8 @@
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase> <AvaloniaVersionBase>12.1.1</AvaloniaVersionBase>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<!-- Avalonia packages -->
<!-- Important: keep version in sync! -->
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Themes.Fluent" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Desktop" Version="$(AvaloniaVersionBase)" />
@ -19,12 +21,16 @@
<PackageVersion Include="Material.Avalonia" Version="3.19.0" /> <PackageVersion Include="Material.Avalonia" Version="3.19.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Browser" Version="1.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" /> <PackageVersion Include="Xamarin.AndroidX.Core.SplashScreen" Version="1.2.0" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" />
<PackageVersion Include="Xamarin.UITest" Version="4.4.2" /> <PackageVersion Include="Xamarin.UITest" Version="4.4.2" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.11" />

View file

@ -1,5 +1,4 @@
using Android.App; using Android.App;
using Android;
using Android.Runtime; using Android.Runtime;
using Avalonia; using Avalonia;
using Avalonia.Android; using Avalonia.Android;
@ -10,9 +9,6 @@ using Avalonia.Controls;
using Avalonia.Styling; using Avalonia.Styling;
using Yavsc.Api.Client; using Yavsc.Api.Client;
[assembly: UsesPermission(Manifest.Permission.AccessFineLocation)]
[assembly: UsesPermission(Manifest.Permission.AccessCoarseLocation)]
namespace PostIt.Android namespace PostIt.Android
{ {
[Application] [Application]

View file

@ -57,17 +57,6 @@ public class MainActivity : AvaloniaMainActivity
} }
public override void OnRequestPermissionsResult(int requestCode, string[]? permissions, Permission[]? grantResults)
{
if (PostIt.Android.Services.AndroidCurrentLocationProvider
.HandlePermissionResult(requestCode, grantResults))
{
return;
}
base.OnRequestPermissionsResult(requestCode, permissions, grantResults);
}
internal static class AndroidOidcCallbackSink internal static class AndroidOidcCallbackSink
{ {
private static System.Threading.Tasks.TaskCompletionSource<string>? _pending; private static System.Threading.Tasks.TaskCompletionSource<string>? _pending;

View file

@ -14,12 +14,11 @@ internal static class PlatformBootstrap
{ {
internal static void InitPlatform() internal static void InitPlatform()
{ {
Platform.CreateBrowser = () => Platform.CreateBrowser = () =>
{ {
var activity = MainActivity.Current; var activity = MainActivity.Current;
return activity is null ? null : new AndroidSystemBrowser(activity); return activity is null ? null : new AndroidSystemBrowser(activity);
}; };
Platform.TryGetCurrentLocationAsync = AndroidCurrentLocationProvider.TryGetCurrentLocationAsync;
} }
} }

View file

@ -11,7 +11,7 @@
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,130 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Android;
using Android.App;
using Android.Content.PM;
using Android.Locations;
using AndroidX.Core.App;
using AndroidX.Core.Content;
using PostIt.Services;
namespace PostIt.Android.Services;
internal static class AndroidCurrentLocationProvider
{
public static async Task<CurrentLocationResult> TryGetCurrentLocationAsync(CancellationToken cancellationToken)
{
var activity = MainActivity.Current;
if (activity is null)
{
return CurrentLocationResult.Unavailable("L'activité Android n'est pas encore prête.");
}
var permissionGranted = await LocationPermissionBroker.EnsureGrantedAsync(activity, cancellationToken).ConfigureAwait(false);
if (!permissionGranted)
{
return CurrentLocationResult.PermissionDenied();
}
var locationManager = activity.GetSystemService(global::Android.Content.Context.LocationService) as LocationManager;
if (locationManager is null)
{
return CurrentLocationResult.Unavailable("Le service de localisation Android est indisponible.");
}
var location = locationManager.GetProviders(enabledOnly: true)?
.Select(provider => locationManager.GetLastKnownLocation(provider))
.Where(candidate => candidate is not null)
.OrderByDescending(candidate => candidate!.Time)
.ThenBy(candidate => candidate!.Accuracy)
.FirstOrDefault();
if (location is null)
{
return CurrentLocationResult.Unavailable("Aucune position n'est disponible. Activez la localisation du système puis réessayez.");
}
return CurrentLocationResult.Success(location.Latitude, location.Longitude);
}
public static bool HandlePermissionResult(int requestCode, Permission[]? grantResults)
=> LocationPermissionBroker.HandleResult(requestCode, grantResults);
private static class LocationPermissionBroker
{
private const int RequestCode = 4042;
private static readonly string[] RequestedPermissions =
{
Manifest.Permission.AccessFineLocation,
Manifest.Permission.AccessCoarseLocation,
};
private static readonly object SyncRoot = new();
private static TaskCompletionSource<bool>? _pendingRequest;
public static Task<bool> EnsureGrantedAsync(Activity activity, CancellationToken cancellationToken)
{
if (HasLocationPermission(activity))
{
return Task.FromResult(true);
}
lock (SyncRoot)
{
if (_pendingRequest is null)
{
_pendingRequest = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
ActivityCompat.RequestPermissions(activity, RequestedPermissions, RequestCode);
}
if (!cancellationToken.CanBeCanceled)
{
return _pendingRequest.Task;
}
return WaitAsync(_pendingRequest.Task, cancellationToken);
}
}
public static bool HandleResult(int requestCode, Permission[]? grantResults)
{
if (requestCode != RequestCode)
{
return false;
}
var granted = grantResults is { Length: > 0 } && grantResults.All(result => result == Permission.Granted);
TaskCompletionSource<bool>? pendingRequest;
lock (SyncRoot)
{
pendingRequest = _pendingRequest;
_pendingRequest = null;
}
pendingRequest?.TrySetResult(granted);
return true;
}
private static bool HasLocationPermission(Activity activity)
{
return ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessFineLocation) == Permission.Granted
|| ContextCompat.CheckSelfPermission(activity, Manifest.Permission.AccessCoarseLocation) == Permission.Granted;
}
private static async Task<bool> WaitAsync(Task<bool> task, CancellationToken cancellationToken)
{
using var registration = cancellationToken.Register(() =>
{
lock (SyncRoot)
{
_pendingRequest?.TrySetCanceled(cancellationToken);
_pendingRequest = null;
}
});
return await task.ConfigureAwait(false);
}
}
}

View file

@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View file

@ -1,151 +0,0 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class ActivitiesPageViewModelTests
{
[Fact]
public void ActivityApiClient_uses_avatar_authority_when_provided()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(
api,
"https://api.pschneider.fr/api/v1/",
"https://yavsc.pschneider.fr/");
var url = client.BuildAvatarXsUrl("paul");
Assert.Equal("https://yavsc.pschneider.fr/avatars/paul.xs.png", url);
}
[Fact]
public async Task ActivityApiClient_uses_business_absolute_paths()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
var billingClient = new BillingApiClient(api, "https://business.example/api/v1/");
await client.GetCatalogAsync("brush", TestContext.Current.CancellationToken);
await client.GetUsersAsync("brush-pro", TestContext.Current.CancellationToken);
await billingClient.CreateAsync("Rdv", new { Foo = "Bar" }, TestContext.Current.CancellationToken);
await billingClient.GetQuerySummariesAsync("Rdv", TestContext.Current.CancellationToken);
Assert.Equal("https://business.example/api/v1/activity/catalog?parentCode=brush", api.Paths[0]);
Assert.Equal("https://business.example/api/v1/activity/brush-pro/users", api.Paths[1]);
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[2]);
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths[3]);
}
[Fact]
public async Task RefreshAsync_loads_first_activity_then_specialization_performers()
{
var api = new StubActivityApi();
var client = new ActivityApiClient(api, "https://business.example/api/v1/");
var billingClient = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new ActivitiesPageViewModel(client, billingClient);
await vm.RefreshAsync();
Assert.Equal("brush", vm.SelectedActivity?.Code);
Assert.Single(vm.Specializations);
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
Assert.Equal("https://business.example/avatars/Alice.xs.png", vm.Performers[0].AvatarXsUrl);
Assert.True(vm.Performers[0].HasPerformerProfile);
Assert.True(vm.Performers[0].IsPerformerActive);
Assert.Equal("Actif", vm.Performers[0].PerformerStatusBadgeLabel);
Assert.Equal("Pas d'autre activité", vm.Performers[0].ExtraActivityLabel);
await vm.ShowSpecializationAsync(vm.Specializations[0]);
Assert.Equal("brush-pro", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Bob", vm.Performers[0].UserName);
Assert.Equal("https://business.example/avatars/Bob.xs.png", vm.Performers[0].AvatarXsUrl);
Assert.True(vm.Performers[0].HasPerformerProfile);
Assert.False(vm.Performers[0].IsPerformerActive);
Assert.Equal("Inactif", vm.Performers[0].PerformerStatusBadgeLabel);
Assert.Equal("Autres spécialisations: 2", vm.Performers[0].ExtraActivityLabel);
Assert.Contains("brush pro", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
await vm.ShowSpecializationAsync(null);
Assert.Equal("brush", vm.CurrentActivity?.Code);
Assert.Single(vm.Performers);
Assert.Equal("Alice", vm.Performers[0].UserName);
}
private sealed class StubActivityApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<ActivityInfo>))
{
var activities = new List<ActivityInfo>
{
new()
{
Code = "brush",
Name = "Brush",
Description = "Coiffure à domicile",
PerformerCount = 1,
Forms = new List<CommandFormSummary>
{
new() { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" }
},
Children = new List<ActivityInfo>
{
new()
{
Code = "brush-pro",
Name = "Brush Pro",
Description = "Spécialisation premium",
ParentCode = "brush",
PerformerCount = 1,
Forms = new List<CommandFormSummary>
{
new() { Id = 2, ActionName = "Rdv", Title = "Rendez-vous premium" }
}
}
}
}
};
return Task.FromResult((T)(object)activities);
}
if (typeof(T) == typeof(List<PerformerActivity>))
{
var performers = path.EndsWith("brush-pro/users", StringComparison.Ordinal)
? new List<PerformerActivity>
{
new() { PerformerId = "pro-2", HasPerformerProfile = true, Active = false, UserName = "Bob", ActivityCode = "brush-pro", ActivityName = "Brush Pro", ExtraActivityCount = 2 }
}
: new List<PerformerActivity>
{
new() { PerformerId = "pro-1", HasPerformerProfile = true, Active = true, UserName = "Alice", ActivityCode = "brush", ActivityName = "Brush", ExtraActivityCount = 0 }
};
return Task.FromResult((T)(object)performers);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -67,7 +67,7 @@ public class BearerScopeTests
Scopes = userScopes, Scopes = userScopes,
RedirectUri = "postit://callback", RedirectUri = "postit://callback",
}, },
ApiUrl = "https://example.invalid/api/v1/", BusinessApiUrl = "https://example.invalid/api/v1/",
}; };
var tokensPath = Path.Combine( var tokensPath = Path.Combine(
@ -266,7 +266,7 @@ public class BearerScopeTests
// private HttpClient is independent, so we resolve the // private HttpClient is independent, so we resolve the
// absolute URI ourselves from Settings.BusinessApiUrl — // absolute URI ourselves from Settings.BusinessApiUrl —
// the same URL BlogApiClient would have set as BaseAddress. // the same URL BlogApiClient would have set as BaseAddress.
var absolute = new Uri(new Uri(Settings.ApiUrl), path); var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute); using var req = new HttpRequestMessage(method, absolute);
req.Headers.Authorization = req.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken); new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _accessToken);

View file

@ -1,274 +0,0 @@
using System.Text.Json;
using PostIt.Helpers;
using PostIt.Services;
using PostIt.ViewModels;
using PostIt.ViewModels.Commands;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Haircut;
namespace PostIt.Tests;
public class BillingCommandPageViewModelTests
{
[Fact]
public async Task SubmitAsync_posts_rdv_payload_to_selected_billing_route()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
vm!.EventDate = DateTime.Parse("2026-09-02 14:30");
vm!.Reason = "Point de cadrage";
vm!.Address = "1 rue du Test";
vm!.Latitude = 48.8566;
vm!.Longitude = 2.3522;
vm!.Consent = true;
await vm.SubmitCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.LastPath);
Assert.NotNull(api.LastBody);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
Assert.Equal("dev", json.RootElement.GetProperty("ActivityCode").GetString());
Assert.Equal("perf-1", json.RootElement.GetProperty("PerformerId").GetString());
Assert.Equal("Point de cadrage", json.RootElement.GetProperty("Reason").GetString());
Assert.Equal((int)QueryStatus.Inserted, json.RootElement.GetProperty("Status").GetInt32());
}
[Fact]
public async Task SubmitAsync_refuses_unsupported_billing_code()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 13, ActionName = "Book", Title = "Réservation" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "book", Name = "Book" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
client);
Assert.Null(vm);
}
[Fact]
public async Task SubmitAsync_allows_missing_coordinates_and_omits_them_from_payload()
{
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
vm!.EventDate = DateTime.Parse("2026-09-02 14:30");
vm!.Reason = "Point de cadrage";
vm!.Address = "1 rue du Test";
vm!.Latitude = null;
vm!.Longitude = null;
vm!.Consent = true;
await vm.SubmitCommand.ExecuteAsync(null);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
var location = json.RootElement.GetProperty("Location");
Assert.Equal("1 rue du Test", location.GetProperty("Address").GetString());
Assert.False(location.TryGetProperty("Latitude", out _));
Assert.False(location.TryGetProperty("Longitude", out _));
}
[Fact]
public async Task UseCurrentLocationAsync_prefills_coordinates_from_platform_provider()
{
var original = Platform.TryGetCurrentLocationAsync;
try
{
Platform.TryGetCurrentLocationAsync = _ => Task.FromResult(CurrentLocationResult.Success(48.8566, 2.3522));
var api = new RecordingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 12, ActionName = "Rdv", Title = "Rendez-vous" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
client) as RdvViewModel;
await vm!.UseCurrentLocationCommand.ExecuteAsync(null);
Assert.Equal(48.8566, vm!.Latitude);
Assert.Equal(2.3522, vm!.Longitude);
}
finally
{
Platform.TryGetCurrentLocationAsync = original;
}
}
[Fact]
public async Task InitializeAsync_loads_prestations_for_brush_and_submit_posts_selected_prestation()
{
var api = new RecordingApi
{
HairPrestations = new List<HairPrestationDto>
{
new() { Id = 10, Title = "Femme · Cheveux mi-longs", Details = "Coupe · Brushing" },
new() { Id = 11, Title = "Homme · Cheveux courts", Details = "Coupe · Coiffage" },
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "brush", Name = "Brush" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
client) as BrushViewModel;
vm!.EventDate = DateTime.Parse("2026-09-02 14:30");
vm!.Address = "1 rue du Test";
vm!.Latitude = 48.8566;
vm!.Longitude = 2.3522;
vm!.Consent = true;
vm!.AdditionalInfo = "Prévoir shampoing";
await vm.InitializeAsync();
vm.SelectedPrestation = vm.AvailablePrestations[1];
await vm.SubmitCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/billing/Brush", api.LastPath);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
Assert.Equal(11, json.RootElement.GetProperty("PrestationId").GetInt32());
Assert.Equal("Prévoir shampoing", json.RootElement.GetProperty("AdditionalInfo").GetString());
}
[Fact]
public async Task InitializeAsync_loads_prestations_for_mbrush_and_submit_posts_selected_prestations()
{
var api = new RecordingApi
{
HairPrestations = new List<HairPrestationDto>
{
new() { Id = 21, Title = "Femme · Cheveux longs", Details = "Coupe · Couleur" },
new() { Id = 22, Title = "Enfant · Cheveux courts", Details = "Coupe · Sans technique" },
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 14, ActionName = "MBrush", Title = "Coupe groupée" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "mbrush", Name = "MBrush" },
new ActivityUserDisplayItem { PerformerId = "perf-3", UserName = "Cara" },
client) as MBrushViewModel;
vm!.EventDate = DateTime.Parse("2026-09-03 10:00");
vm!.Address = "2 rue du Test";
vm!.Latitude = 48.8567;
vm!.Longitude = 2.3523;
vm!.Consent = true;
await vm.InitializeAsync();
vm!.MultiPrestations[0].IsSelected = true;
vm!.MultiPrestations[1].IsSelected = true;
await vm!.SubmitCommand.ExecuteAsync(null);
Assert.Equal("https://business.example/api/v1/billing/MBrush", api.LastPath);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
var prestations = json.RootElement.GetProperty("Prestations");
Assert.Equal(2, prestations.GetArrayLength());
Assert.Equal(21, prestations[0].GetProperty("PrestationId").GetInt32());
Assert.Equal(22, prestations[1].GetProperty("PrestationId").GetInt32());
}
[Fact]
public async Task InitializeAsync_with_existing_brush_query_prefills_and_submit_updates_query()
{
var api = new RecordingApi
{
HairPrestations = new List<HairPrestationDto>
{
new() { Id = 30, Title = "Femme · Cheveux longs", Details = "Coupe · Brushing" },
new() { Id = 31, Title = "Homme · Cheveux courts", Details = "Coupe" },
}
};
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm =
new CommandFormSummary { Id = 13, ActionName = "Brush", Title = "Coupe" }
.CreateCommandPageViewModel(
new ActivityInfo { Code = "brush", Name = "Brush" },
new ActivityUserDisplayItem { PerformerId = "perf-2", UserName = "Bob" },
client) as BrushViewModel;
await vm!.InitializeAsync(new BillingQueryDetailsDto
{
Id = 77,
BillingCode = "Brush",
ActivityCode = "brush",
PerformerId = "perf-2",
ClientId = "cli-1",
EventDate = new DateTime(2026, 9, 2, 14, 30, 0, DateTimeKind.Utc),
Consent = true,
Status = QueryStatus.Accepted,
PrestationId = 30,
AdditionalInfo = "Ancienne note",
Location = new BillingLocationDto
{
Address = "1 rue du Test",
Latitude = 48.8566,
Longitude = 2.3522,
}
});
vm!.SelectedPrestation = vm!.AvailablePrestations[1];
vm!.AdditionalInfo = "Note mise à jour";
await vm!.SubmitCommand.ExecuteAsync(null);
Assert.Equal(HttpMethod.Put, api.LastMethod);
Assert.Equal("https://business.example/api/v1/billing/Brush/77", api.LastPath);
Assert.True(vm.IsEditingExisting);
Assert.Equal("Mettre à jour la commande", vm.SubmitLabel);
using var json = JsonDocument.Parse(JsonSerializer.Serialize(api.LastBody));
Assert.Equal(77, json.RootElement.GetProperty("Id").GetInt32());
Assert.Equal(31, json.RootElement.GetProperty("PrestationId").GetInt32());
Assert.Equal("Note mise à jour", json.RootElement.GetProperty("AdditionalInfo").GetString());
Assert.Equal((int)QueryStatus.Accepted, json.RootElement.GetProperty("Status").GetInt32());
}
private sealed class RecordingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public HttpMethod? LastMethod { get; private set; }
public string? LastPath { get; private set; }
public object? LastBody { get; private set; }
public List<HairPrestationDto>? HairPrestations { get; init; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
if (typeof(T) == typeof(List<HairPrestationDto>))
{
return Task.FromResult((T)(object)(HairPrestations ?? new List<HairPrestationDto>()));
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -1,134 +0,0 @@
using System.Net.Http;
using PostIt.ViewModels;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Tests;
public class BillingQueriesPageViewModelTests
{
[Fact]
public async Task RefreshAsync_filters_queries_by_selected_activity_and_performer()
{
var api = new StubBillingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingQueriesPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" },
client);
await vm.InitializeAsync();
Assert.Equal("https://business.example/api/v1/billing/Rdv", api.Paths.Single());
Assert.Equal(3, vm.Queries.Count);
Assert.Contains(vm.Queries, q => q.Description == "Rendez-vous #1");
Assert.Contains("3 commande", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task RefreshAsync_in_readonly_ongoing_mode_keeps_only_ongoing_statuses_and_disables_open()
{
var api = new StubBillingApi();
var client = new BillingApiClient(api, "https://business.example/api/v1/");
var vm = new BillingQueriesPageViewModel(
new ActivityInfo { Code = "dev", Name = "Développement" },
new ActivityUserDisplayItem { PerformerId = "perf-1", UserName = "Alice" },
new CommandFormSummary { Id = 1, ActionName = "Rdv", Title = "Rendez-vous" },
client,
isReadOnly: true,
ongoingOnly: true);
await vm.InitializeAsync();
Assert.Equal(2, vm.Queries.Count);
Assert.All(vm.Queries, q => Assert.DoesNotContain("Rejected", q.StatusLabel, StringComparison.OrdinalIgnoreCase));
Assert.Contains("lecture seule", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
Assert.False(vm.CanOpenDetails);
vm.SelectedQuery = vm.Queries[0];
Assert.False(vm.OpenSelectedQueryCommand.CanExecute(null));
}
private sealed class StubBillingApi : IYavscApiClient
{
public HttpClient Http { get; } = new();
public List<string> Paths { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
if (typeof(T) == typeof(List<BillingQuerySummaryDto>))
{
var data = new List<BillingQuerySummaryDto>
{
new()
{
Id = 11,
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Inserted,
Description = "Rendez-vous #1",
Reason = "Point de cadrage",
EventDate = new DateTime(2026, 9, 1, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 12,
ActivityCode = "other",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Accepted,
Description = "Autre activité",
EventDate = new DateTime(2026, 9, 2, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 13,
ActivityCode = "dev",
PerformerId = "perf-2",
ClientId = "cli-1",
Status = QueryStatus.Accepted,
Description = "Autre performer",
EventDate = new DateTime(2026, 9, 3, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 14,
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.InProgress,
Description = "En cours",
EventDate = new DateTime(2026, 9, 4, 10, 0, 0, DateTimeKind.Utc),
},
new()
{
Id = 15,
ActivityCode = "dev",
PerformerId = "perf-1",
ClientId = "cli-1",
Status = QueryStatus.Rejected,
Description = "Rejetée",
EventDate = new DateTime(2026, 9, 5, 10, 0, 0, DateTimeKind.Utc),
}
};
return Task.FromResult((T)(object)data);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Paths.Add(path);
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -166,51 +166,4 @@ public class BlogPostAuthorDtoTests
Assert.True(root.TryGetProperty("userName", out _)); Assert.True(root.TryGetProperty("userName", out _));
Assert.True(root.TryGetProperty("avatar", out _)); Assert.True(root.TryGetProperty("avatar", out _));
} }
[Fact]
public void BlogPostDto_deserialises_acl_from_detail_payload()
{
// Detail payload shape emitted by BlogApiController.GetBlog:
// ACL entries are included under "acl"/"ACL".
var json = """
{
"id": 99,
"title": "ACL test",
"authorId": "u-alice",
"acl": [
{ "circleId": 12, "blogPostId": 99 },
{ "circleId": 34, "blogPostId": 99 }
]
}
""";
var post = JsonSerializer.Deserialize<BlogPostDto>(json, CaseInsensitiveJson);
Assert.NotNull(post);
var acl = post!.GetACL();
Assert.Equal(2, acl.Length);
Assert.Contains(acl, a => a.CircleId == 12);
Assert.Contains(acl, a => a.CircleId == 34);
}
[Fact]
public void BlogPostDto_does_not_emit_acl_when_serialized_for_write()
{
var post = new BlogPostDto
{
Id = 77,
Title = "Write payload"
};
post.AuthorizeCircle(11);
// The client should not send ACL through POST/PUT blog payloads.
// ACL mutations have their own dedicated /blogacl endpoint.
var json = JsonSerializer.Serialize(post,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
Assert.False(root.TryGetProperty("acl", out _));
Assert.False(root.TryGetProperty("wireAcl", out _));
}
} }

View file

@ -79,7 +79,7 @@ public class MainPageSaveTests
// whose Title is exactly what the user typed. The bug // whose Title is exactly what the user typed. The bug
// fails this assertion with Title == string.Empty. // fails this assertion with Title == string.Empty.
Assert.NotEmpty(recorder.Calls); Assert.NotEmpty(recorder.Calls);
var (method, path, body) = recorder.Calls[1]; var (method, path, body) = recorder.FirstCall;
Assert.Equal(HttpMethod.Post, method); Assert.Equal(HttpMethod.Post, method);
Assert.Equal("blogspot", path); Assert.Equal("blogspot", path);
var sent = Assert.IsType<BlogPostDto>(body); var sent = Assert.IsType<BlogPostDto>(body);

View file

@ -9,7 +9,6 @@ using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos;
using Yavsc.Blogspot; using Yavsc.Blogspot;
namespace PostIt.Tests; namespace PostIt.Tests;
@ -95,7 +94,7 @@ public class PostAclDialogTests
HttpMethod method, string path, object? body = null, HttpMethod method, string path, object? body = null,
CancellationToken ct = default) CancellationToken ct = default)
{ {
var absolute = new Uri(new Uri(Settings.ApiUrl), path); var absolute = new Uri(new Uri(Settings.BusinessApiUrl), path);
using var req = new HttpRequestMessage(method, absolute); using var req = new HttpRequestMessage(method, absolute);
using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult(); using var resp = _http.SendAsync(req, ct).GetAwaiter().GetResult();
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
@ -123,8 +122,8 @@ public class PostAclDialogTests
var handler = new CountingHttpHandler(); var handler = new CountingHttpHandler();
var settings = new Settings(); var settings = new Settings();
var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler); var api = new TestableYavscApiClient(settings, new TokenStore(System.IO.Path.GetTempFileName()), handler);
var aclClient = new BlogAclApiClient(api, settings.ApiUrl); var aclClient = new BlogAclApiClient(api, settings.BusinessApiUrl);
var circleClient = new CircleApiClient(api, settings.ApiUrl); var circleClient = new CircleApiClient(api, settings.BusinessApiUrl);
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddSingleton(settings); services.AddSingleton(settings);
@ -191,8 +190,9 @@ public class PostAclDialogTests
await Task.Delay(20); await Task.Delay(20);
} }
// Assert: one GET went out (for /circle) from LoadAsync. // Assert: exactly two GETs went out (one to /blogacl,
Assert.Equal(1, handler.RequestCount); // one to /circle), both from the LoadAsync call.
Assert.Equal(2, handler.RequestCount);
// And the VM's idempotency gate has flipped. // And the VM's idempotency gate has flipped.
Assert.True(vm.Loaded); Assert.True(vm.Loaded);
@ -218,58 +218,7 @@ public class PostAclDialogTests
await vm.LoadAsync(); await vm.LoadAsync();
// Assert: the second call short-circuited on _loaded. // Assert: the second call short-circuited on _loaded.
Assert.Equal(1, handler.RequestCount); Assert.Equal(2, handler.RequestCount);
Assert.True(vm.Loaded); Assert.True(vm.Loaded);
} }
[Fact]
public async Task LoadAsync_keeps_acl_from_blogpostdto_and_only_loads_circles()
{
var post = new BlogPostDto { Id = 42, Title = "ACL hydration" };
post.AuthorizeCircle(12);
post.AuthorizeCircle(34);
var api = new StubAclApiClient();
var aclClient = new BlogAclApiClient(api, "http://localhost/");
var circleClient = new CircleApiClient(api, "http://localhost/");
var vm = new PostAclDialogViewModel(post, aclClient, circleClient);
await vm.LoadAsync();
Assert.Equal(1, api.CallCount);
Assert.Equal(2, vm.AclEntries.Count);
Assert.Contains(vm.AclEntries, a => a.CircleId == 12);
Assert.Contains(vm.AclEntries, a => a.CircleId == 34);
}
private sealed class StubAclApiClient : IYavscApiClient
{
public HttpClient Http { get; } = new();
public int CallCount { get; private set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
CallCount++;
if (typeof(T) == typeof(List<CircleDto>))
{
var circles = new List<CircleDto>
{
new() { Id = 12, Name = "A", OwnerId = "owner", Public = false },
new() { Id = 34, Name = "B", OwnerId = "owner", Public = false },
};
return Task.FromResult((T)(object)circles);
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
CallCount++;
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
} }

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -55,21 +55,6 @@ public class PostItViewModelTests
Assert.Equal("Hello", posts[0].Title); Assert.Equal("Hello", posts[0].Title);
} }
[Fact]
public async Task TogglePublishCommand_uses_the_current_checked_state_without_inverting_it()
{
var api = new RecordingPublishApi();
var blog = new BlogApiClient(api, "http://localhost/");
var viewModel = new MainViewModel(blog);
viewModel.SelectedPost = new BlogPostDto { Id = 42, IsPublished = false };
await viewModel.SetPublishStateAsync(true);
Assert.True(api.LastPublishValue);
Assert.True(viewModel.DraftIsPublished);
}
/// <summary>Test fake that always throws if the API is invoked.</summary> /// <summary>Test fake that always throws if the API is invoked.</summary>
private sealed class ThrowingYavscApiClient : YavscApiClient private sealed class ThrowingYavscApiClient : YavscApiClient
{ {
@ -118,34 +103,4 @@ public class PostItViewModelTests
return Task.FromResult(default(T)!); return Task.FromResult(default(T)!);
} }
} }
private sealed class RecordingPublishApi : IYavscApiClient
{
public bool LastPublishValue { get; private set; }
public HttpClient Http { get; } = new();
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
if (method == HttpMethod.Put && path.Contains("/publish", StringComparison.OrdinalIgnoreCase))
{
var publish = body?.GetType().GetProperty("publish")?.GetValue(body) is bool value && value;
LastPublishValue = publish;
}
return Task.FromResult(default(T)!);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
if (method == HttpMethod.Put && path.Contains("/publish", StringComparison.OrdinalIgnoreCase))
{
var publish = body?.GetType().GetProperty("publish")?.GetValue(body) is bool value && value;
LastPublishValue = publish;
}
return Task.CompletedTask;
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
} }

View file

@ -89,7 +89,7 @@ public class SettingsLoadTests
settings.Authentication.RedirectUri = settings.Authentication.RedirectUri =
global::AuthenticationSettings.DesktopRedirectUri; global::AuthenticationSettings.DesktopRedirectUri;
settings.ApiUrl = flip settings.BusinessApiUrl = flip
? "https://a.example.test/api/v1/" ? "https://a.example.test/api/v1/"
: "https://b.example.test/api/v1/"; : "https://b.example.test/api/v1/";

View file

@ -94,26 +94,6 @@ public class SignaturePadControlTests
Assert.NotEqual(first.Strokes, third.Strokes); Assert.NotEqual(first.Strokes, third.Strokes);
} }
[Fact]
public void PendingStroke_is_exposed_only_while_capturing()
{
var pad = new SignaturePadControl();
Assert.Empty(pad.PendingStroke);
pad.BeginCaptureForTest();
pad.AppendPointForTest(1_000, 2_000);
pad.AppendPointForTest(3_000, 4_000);
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.PendingStroke);
Assert.Equal(new[] { 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
pad.SealStrokeForTest();
Assert.Empty(pad.PendingStroke);
Assert.Equal(new[] { 2, 1_000, 2_000, 3_000, 4_000 }, pad.Strokes);
}
[Fact] [Fact]
public void Clear_empties_buffer_and_raises_redraw() public void Clear_empties_buffer_and_raises_redraw()
{ {

View file

@ -58,7 +58,7 @@ public class YavscApiClientTests
// calls CallAsync("posts", ...) directly (bypassing // calls CallAsync("posts", ...) directly (bypassing
// BlogApiClient, which is the only thing that would set // BlogApiClient, which is the only thing that would set
// it in production). Mirror prod here. // it in production). Mirror prod here.
reloaded.Http.BaseAddress = new Uri(settings.ApiUrl); reloaded.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
var posts = await reloaded.CallAsync<List<StubApiServer.Post>>( var posts = await reloaded.CallAsync<List<StubApiServer.Post>>(
HttpMethod.Get, "posts", TestContext.Current.CancellationToken); HttpMethod.Get, "posts", TestContext.Current.CancellationToken);
@ -118,7 +118,7 @@ public class YavscApiClientTests
RedirectUri = "postit://callback", RedirectUri = "postit://callback",
Scopes = new[] { "openid" }, Scopes = new[] { "openid" },
}, },
ApiUrl = "https://127.0.0.1:5003/api/v1", BusinessApiUrl = "https://127.0.0.1:5003/api/v1",
}; };
var client = new YavscApiClient(settings, new TokenStore(Path.Combine( var client = new YavscApiClient(settings, new TokenStore(Path.Combine(
Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json"))); Path.GetTempPath(), $"postit-tests-noop-{Guid.NewGuid():N}.json")));
@ -162,7 +162,7 @@ public class YavscApiClientTests
RedirectUri = authority.LoopbackRedirectUri, RedirectUri = authority.LoopbackRedirectUri,
Scopes = new[] { "openid", "profile", "blog" } Scopes = new[] { "openid", "profile", "blog" }
}, },
ApiUrl = apiBaseUrl BusinessApiUrl = apiBaseUrl
}; };
private static async Task<YavscApiClient> LoginAndPersistAsync( private static async Task<YavscApiClient> LoginAndPersistAsync(
@ -175,7 +175,7 @@ public class YavscApiClientTests
// directly (bypassing BlogApiClient) rely on the same // directly (bypassing BlogApiClient) rely on the same
// BaseAddress the production chain sets in BlogApiClient's // BaseAddress the production chain sets in BlogApiClient's
// ctor. Mirror that here so "posts" resolves to the stub. // ctor. Mirror that here so "posts" resolves to the stub.
client.Http.BaseAddress = new Uri(settings.ApiUrl); client.Http.BaseAddress = new Uri(settings.BusinessApiUrl);
// Force the API client to use the test browser by routing the // Force the API client to use the test browser by routing the
// LoginInteractiveAsync call through a small wrapper. // LoginInteractiveAsync call through a small wrapper.

View file

@ -86,6 +86,11 @@ private void ConfigureRootView(MainView rootView)
rootView.NavRoot.PopToRootAsync(); rootView.NavRoot.PopToRootAsync();
}; };
sessionStatus.LoginSucceeded += async () =>
{
await PushMainPageAsync();
};
rootView.SessionBanner.DataContext = sessionStatus; rootView.SessionBanner.DataContext = sessionStatus;
} }
@ -135,6 +140,9 @@ private void ConfigureRootView(MainView rootView)
var homePage = provider.GetRequiredService<HomePageViewModel>(); var homePage = provider.GetRequiredService<HomePageViewModel>();
var app = (App)Current!; var app = (App)Current!;
await app.PushPageAsync(homePage); await app.PushPageAsync(homePage);
if (!refreshed) return;
await PushMainPageAsync().ConfigureAwait(true);
} }
/// <summary> /// <summary>
@ -145,7 +153,7 @@ private void ConfigureRootView(MainView rootView)
/// (interactive login from the banner). Pulled out as a helper so /// (interactive login from the banner). Pulled out as a helper so
/// the two callers can't drift apart. /// the two callers can't drift apart.
/// </summary> /// </summary>
public static async Task PushBlogsPageAsync() public static async Task PushMainPageAsync()
{ {
var app = (App)Current!; var app = (App)Current!;
var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>(); var mainVm = app.ServiceProvider!.GetRequiredService<MainViewModel>();

View file

@ -68,38 +68,21 @@ public class SignaturePadControl : TemplatedControl
public event EventHandler? RedrawRequested; public event EventHandler? RedrawRequested;
private readonly List<int> _strokes = new(capacity: 256); private readonly List<int> _strokes = new(capacity: 256);
private InputElement? _wiredCaptureArea;
private int _pendingPoints; // number of (x, y) pairs awaiting a length prefix private int _pendingPoints; // number of (x, y) pairs awaiting a length prefix
private bool _capturing; private bool _capturing;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e) protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{ {
base.OnApplyTemplate(e); base.OnApplyTemplate(e);
RewireCaptureArea();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) if (CaptureArea is { } previous)
{
base.OnPropertyChanged(change);
if (change.Property == CaptureAreaProperty)
{
RewireCaptureArea();
}
}
private void RewireCaptureArea()
{
if (_wiredCaptureArea is { } previous)
{ {
previous.PointerPressed -= OnCapturePressed; previous.PointerPressed -= OnCapturePressed;
previous.PointerMoved -= OnCaptureMoved; previous.PointerMoved -= OnCaptureMoved;
previous.PointerReleased -= OnCaptureReleased; previous.PointerReleased -= OnCaptureReleased;
} }
_wiredCaptureArea = CaptureArea; if (CaptureArea is { } area)
if (_wiredCaptureArea is { } area)
{ {
area.PointerPressed += OnCapturePressed; area.PointerPressed += OnCapturePressed;
area.PointerMoved += OnCaptureMoved; area.PointerMoved += OnCaptureMoved;
@ -114,14 +97,12 @@ public class SignaturePadControl : TemplatedControl
_capturing = true; _capturing = true;
_pendingPoints = 0; _pendingPoints = 0;
AppendPoint(e.GetPosition(CaptureArea)); AppendPoint(e.GetPosition(CaptureArea));
RedrawRequested?.Invoke(this, EventArgs.Empty);
} }
private void OnCaptureMoved(object? sender, PointerEventArgs e) private void OnCaptureMoved(object? sender, PointerEventArgs e)
{ {
if (!_capturing) return; if (!_capturing) return;
AppendPoint(e.GetPosition(CaptureArea)); AppendPoint(e.GetPosition(CaptureArea));
RedrawRequested?.Invoke(this, EventArgs.Empty);
} }
private void OnCaptureReleased(object? sender, PointerReleasedEventArgs e) private void OnCaptureReleased(object? sender, PointerReleasedEventArgs e)
@ -188,16 +169,6 @@ public class SignaturePadControl : TemplatedControl
/// </summary> /// </summary>
public SignaturePadData Snapshot() => new(_strokes.ToArray()); public SignaturePadData Snapshot() => new(_strokes.ToArray());
/// <summary>
/// Copy of the current in-progress stroke, without the length
/// prefix used for sealed strokes. The view can render this as a
/// live preview while the user is still drawing.
/// </summary>
internal IReadOnlyList<int> PendingStroke
=> _capturing && _pendingPoints > 0
? _strokes.GetRange(_strokes.Count - 2 * _pendingPoints, 2 * _pendingPoints)
: Array.Empty<int>();
// --- Test-only surface (visible to PostIt.Tests) ------------------- // --- Test-only surface (visible to PostIt.Tests) -------------------
/// <summary> /// <summary>
@ -212,17 +183,6 @@ public class SignaturePadControl : TemplatedControl
_pendingPoints++; _pendingPoints++;
} }
/// <summary>
/// Test hook: mark the control as actively capturing so tests
/// can exercise the live-preview path without synthetic pointer
/// events.
/// </summary>
internal void BeginCaptureForTest()
{
_capturing = true;
_pendingPoints = 0;
}
/// <summary> /// <summary>
/// Test hook: seal the currently-pending stroke with a length /// Test hook: seal the currently-pending stroke with a length
/// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at /// prefix. Mirrors what <see cref="OnCaptureReleased"/> does at

View file

@ -1,50 +0,0 @@
using System;
using PostIt.ViewModels;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.Helpers;
public static class FormHelpers
{
public static BillingCommandPageViewModel?
CreateCommandPageViewModel(
this CommandFormSummary form,
ActivityInfo activity,
ActivityUserDisplayItem performer,
BillingApiClient billingClient)
{
string namespacePrefix = typeof(PostIt.ViewModels.Commands.RdvViewModel).Namespace + ".";
string formVMName = form.ActionName + "ViewModel";
string formOnActivityVMName = activity.Code + formVMName + "ViewModel";
var vmType = Type.GetType(namespacePrefix +formOnActivityVMName);
if (vmType == null)
{
vmType = Type.GetType(namespacePrefix + formVMName);
}
if (vmType == null)
{
Console.Error.WriteLine(
$"! Cannot find type '{formOnActivityVMName}' or '{formVMName}'");
return null;
}
if (!typeof(BillingCommandPageViewModel).IsAssignableFrom(vmType))
{
Console.Error.WriteLine($"! The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel");
return null;
}
var vm = Activator.CreateInstance(vmType, activity, performer, form, billingClient);
if (vm == null)
{
throw new InvalidOperationException($"Cannot create instance of '{formOnActivityVMName}' or '{formVMName}'");
}
return vm as BillingCommandPageViewModel ?? throw new InvalidOperationException($"The type '{formOnActivityVMName}' or '{formVMName}' is not a BillingCommandPageViewModel");
}
}

View file

@ -1,34 +0,0 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using Avalonia.Platform;
namespace PostIt.Helpers;
public static class ImageHelper
{
private static readonly HttpClient HttpClient = new();
public static Bitmap LoadFromResource(Uri resourceUri)
{
return new Bitmap(AssetLoader.Open(resourceUri));
}
public static async Task<Bitmap?> LoadFromWeb(Uri url)
{
try
{
var response = await HttpClient.GetAsync(url).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var data = await response.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
return new Bitmap(new MemoryStream(data));
}
catch (HttpRequestException ex)
{
Console.WriteLine($"An error occurred while downloading image '{url}': {ex.Message}");
return null;
}
}
}

View file

@ -3,7 +3,6 @@ using Microsoft.Extensions.DependencyInjection;
using PostIt.Services; using PostIt.Services;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Commands;
using Yavsc.Api.Client; using Yavsc.Api.Client;
namespace PostIt.Helpers; namespace PostIt.Helpers;
@ -24,11 +23,6 @@ public static class ServiceCollectionHelpers
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
var activityClient = new ActivityApiClient(
api,
settings.ApiUrl,
settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
// Vues // Vues
@ -51,11 +45,6 @@ public static class ServiceCollectionHelpers
services.AddSingleton<HomePage>(); services.AddSingleton<HomePage>();
services.AddSingleton<SignaturePage>(); services.AddSingleton<SignaturePage>();
services.AddSingleton<CirclesPage>(); services.AddSingleton<CirclesPage>();
services.AddSingleton<ActivitiesPage>();
services.AddTransient<CommandFormsPage>();
services.AddTransient<RdvPage>();
services.AddTransient<BrushPage>();
services.AddTransient<BillingQueriesPage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
services.AddSingleton<YavscApiClient>(api); services.AddSingleton<YavscApiClient>(api);
@ -63,14 +52,10 @@ public static class ServiceCollectionHelpers
services.AddSingleton(circleClient); services.AddSingleton(circleClient);
services.AddSingleton(blogAclClient); services.AddSingleton(blogAclClient);
services.AddSingleton(userSearchClient); services.AddSingleton(userSearchClient);
services.AddSingleton(activityClient);
services.AddSingleton(billingClient);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();
services.AddSingleton<SignaturePageViewModel>(); services.AddSingleton<SignaturePageViewModel>();
services.AddSingleton<CirclesPageViewModel>(); services.AddSingleton<CirclesPageViewModel>();
services.AddSingleton<ActivitiesPageViewModel>();
services.AddTransient<SelectableHairPrestationItem>();
// Dialogs (modal-light pages): the ViewLocator resolves // Dialogs (modal-light pages): the ViewLocator resolves
// them when a caller pushes a PostAclDialogViewModel or // them when a caller pushes a PostAclDialogViewModel or

View file

@ -5,7 +5,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,28 +0,0 @@
namespace PostIt.Services;
public sealed class CurrentLocationResult
{
private CurrentLocationResult(bool isSuccess, bool isPermissionDenied, double? latitude, double? longitude, string message)
{
IsSuccess = isSuccess;
IsPermissionDenied = isPermissionDenied;
Latitude = latitude;
Longitude = longitude;
Message = message;
}
public bool IsSuccess { get; }
public bool IsPermissionDenied { get; }
public double? Latitude { get; }
public double? Longitude { get; }
public string Message { get; }
public static CurrentLocationResult Success(double latitude, double longitude, string? message = null)
=> new(true, false, latitude, longitude, message ?? "Position récupérée.");
public static CurrentLocationResult PermissionDenied(string? message = null)
=> new(false, true, null, null, message ?? "La géolocalisation n'est pas autorisée.");
public static CurrentLocationResult Unavailable(string? message = null)
=> new(false, false, null, null, message ?? "La géolocalisation n'est pas disponible sur cette plateforme.");
}

View file

@ -1,7 +1,4 @@
using System;
using IdentityModel.OidcClient.Browser; using IdentityModel.OidcClient.Browser;
using System.Threading;
using System.Threading.Tasks;
namespace PostIt.Services; namespace PostIt.Services;
@ -40,12 +37,4 @@ public static class Platform
/// </summary> /// </summary>
public static System.Func<IBrowser?>? CreateBrowser { get; set; } = public static System.Func<IBrowser?>? CreateBrowser { get; set; } =
() => new CustomSchemeBrowser(CustomScheme); () => new CustomSchemeBrowser(CustomScheme);
/// <summary>
/// Optional platform hook used by the shared billing form to request a
/// current device position. Platforms that do not expose a native
/// location provider can leave the default delegate in place.
/// </summary>
public static Func<CancellationToken, Task<CurrentLocationResult>> TryGetCurrentLocationAsync { get; set; } =
_ => Task.FromResult(CurrentLocationResult.Unavailable());
} }

View file

@ -1,5 +1,4 @@
using System; using System;
using System.IO;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Net.Http.Headers; using System.Net.Http.Headers;
@ -352,77 +351,6 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
_store.Save(_tokens); _store.Save(_tokens);
} }
/// <summary>
/// Upload a user avatar to the Yavsc API. The server expects a
/// single multipart file named <c>file</c> and validates the image
/// content type before persisting it.
/// </summary>
public async Task<string> SetAvatarAsync(
Stream imageStream,
string fileName,
string? contentType = null,
CancellationToken ct = default)
{
if (imageStream is null)
throw new ArgumentNullException(nameof(imageStream));
if (string.IsNullOrWhiteSpace(fileName))
throw new ArgumentException("A file name is required.", nameof(fileName));
var endpoint = new Uri(new Uri(Settings.ApiUrl.TrimEnd('/') + "/", UriKind.Absolute), "account/set-avatar");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var attemptUpload = async () =>
{
if (imageStream.CanSeek)
imageStream.Position = 0;
using var content = new MultipartFormDataContent();
using var fileContent = new StreamContent(imageStream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(contentType) ? "application/octet-stream" : contentType);
content.Add(fileContent, "file", fileName);
using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
{
Content = content,
};
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await attemptUpload().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await attemptUpload().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
var payload = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(payload))
return "Avatar mis à jour.";
try
{
using var json = JsonDocument.Parse(payload);
if (json.RootElement.TryGetProperty("message", out var msgEl))
{
var message = msgEl.GetString();
if (!string.IsNullOrWhiteSpace(message))
return message;
}
}
catch (JsonException)
{
// Keep a user-friendly fallback when the API payload is not JSON.
}
return "Avatar mis à jour.";
}
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
_store.Clear(); _store.Clear();

View file

@ -18,11 +18,11 @@ public partial class AuthenticationSettings : ObservableObject
/// </summary> /// </summary>
public const string AndroidRedirectUri = "android://postit-signin"; public const string AndroidRedirectUri = "android://postit-signin";
public const string DefaultAuthority = "https://yavsc.pschneider.fr"; public static string DefaultAuthority { get; internal set; } = "https://yavsc.pschneider.fr";
public const string DefaultClientId = "postit"; public static string DefaultClientId { get; internal set; } = "postit";
public static readonly string[] DefaultScopes = { "blogs" }; public static string[] DefaultScopes { get; set; } = { "blogs"} ;
[ObservableProperty] [ObservableProperty]
public partial string Authority { get; set; } public partial string Authority { get; set; }

View file

@ -4,9 +4,7 @@ using Avalonia.Controls;
using Avalonia.Controls.Templates; using Avalonia.Controls.Templates;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using PostIt.ViewModels; using PostIt.ViewModels;
using PostIt.ViewModels.Commands;
using PostIt.Views; using PostIt.Views;
using PostIt.Views.Commands;
namespace PostIt; namespace PostIt;
@ -41,10 +39,6 @@ public class ViewLocator : IDataTemplate
MainViewModel => services.GetRequiredService<MainPage>(), MainViewModel => services.GetRequiredService<MainPage>(),
Settings => services.GetRequiredService<SettingsPage>(), Settings => services.GetRequiredService<SettingsPage>(),
HomePageViewModel => services.GetRequiredService<HomePage>(), HomePageViewModel => services.GetRequiredService<HomePage>(),
ActivitiesPageViewModel => services.GetRequiredService<ActivitiesPage>(),
CommandFormsPageViewModel => services.GetRequiredService<CommandFormsPage>(),
BrushViewModel => services.GetRequiredService<BrushPage>(),
RdvViewModel => services.GetRequiredService<RdvPage>(),
SignaturePageViewModel => services.GetRequiredService<SignaturePage>(), SignaturePageViewModel => services.GetRequiredService<SignaturePage>(),
AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(), AddCircleMemberDialogViewModel => services.GetRequiredService<AddCircleMemberDialog>(),
CirclesPageViewModel => services.GetRequiredService<CirclesPage>(), CirclesPageViewModel => services.GetRequiredService<CirclesPage>(),

View file

@ -1,275 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class ActivitiesPageViewModel : ViewModelBase
{
private readonly ActivityApiClient _client;
private readonly BillingApiClient _billingClient;
private bool _syncingSelection;
[ObservableProperty]
public partial ObservableCollection<ActivityInfo> Activities { get; set; } = new();
[ObservableProperty]
public partial ActivityInfo? SelectedActivity { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityInfo> Specializations { get; set; } = new();
[ObservableProperty]
public partial ActivityInfo? SelectedSpecialization { get; set; }
[ObservableProperty]
public partial ObservableCollection<ActivityUserDisplayItem> Performers { get; set; } = new();
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenCommandFormsCommand))]
public partial ActivityUserDisplayItem? SelectedPerformer { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Choisissez une activité.";
public ActivityInfo? CurrentActivity => SelectedSpecialization ?? SelectedActivity;
public string SelectedActivityLabel => SelectedActivity?.Name ?? "(aucune activité)";
public string CurrentActivityLabel => CurrentActivity?.Name ?? "(aucune)";
public int CurrentFormCount => CurrentActivity?.Forms?.Count ?? 0;
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public ActivitiesPageViewModel(ActivityApiClient client, BillingApiClient billingClient)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
}
partial void OnSelectedActivityChanged(ActivityInfo? value)
{
if (_syncingSelection) return;
_ = ShowActivitySafeAsync(value);
}
partial void OnSelectedSpecializationChanged(ActivityInfo? value)
{
if (_syncingSelection) return;
_ = ShowSpecializationSafeAsync(value);
}
private async Task ShowActivitySafeAsync(ActivityInfo? value)
{
try
{
await ShowActivityAsync(value);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
}
private async Task ShowSpecializationSafeAsync(ActivityInfo? value)
{
try
{
await ShowSpecializationAsync(value);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur: {ex.Message}";
}
}
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var list = await _client.GetCatalogAsync();
Activities = new ObservableCollection<ActivityInfo>(list ?? new());
var first = Activities.FirstOrDefault();
await ShowActivityAsync(first);
if (first is null)
{
StatusMessage = "Aucune activité disponible.";
}
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Activities = new ObservableCollection<ActivityInfo>();
Specializations = new ObservableCollection<ActivityInfo>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Activities = new ObservableCollection<ActivityInfo>();
Specializations = new ObservableCollection<ActivityInfo>();
Performers = new ObservableCollection<ActivityUserDisplayItem>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public async Task ShowActivityAsync(ActivityInfo? activity)
{
_syncingSelection = true;
try
{
SelectedActivity = activity;
SelectedSpecialization = null;
}
finally
{
_syncingSelection = false;
}
OnPropertyChanged(nameof(CurrentActivity));
OnPropertyChanged(nameof(SelectedActivityLabel));
OnPropertyChanged(nameof(CurrentActivityLabel));
OnPropertyChanged(nameof(CurrentFormCount));
Specializations = new ObservableCollection<ActivityInfo>(activity?.Children ?? new());
if (activity is null)
{
Performers = new ObservableCollection<ActivityUserDisplayItem>();
SelectedPerformer = null;
return;
}
await LoadPerformersAsync(activity);
}
public async Task ShowSpecializationAsync(ActivityInfo? specialization)
{
_syncingSelection = true;
try
{
SelectedSpecialization = specialization;
}
finally
{
_syncingSelection = false;
}
OnPropertyChanged(nameof(CurrentActivity));
OnPropertyChanged(nameof(CurrentActivityLabel));
OnPropertyChanged(nameof(CurrentFormCount));
if (specialization is null)
{
if (SelectedActivity is not null)
{
await LoadPerformersAsync(SelectedActivity);
}
return;
}
await LoadPerformersAsync(specialization);
}
private async Task LoadPerformersAsync(ActivityInfo activity)
{
IsBusy = true;
try
{
var list = await _client.GetUsersAsync(activity.Code);
var items = (list ?? new())
.Select(dto => ActivityUserDisplayItem.FromDto(dto, _client.BuildAvatarXsUrl(dto.UserName)))
.ToList();
await Task.WhenAll(items.Select(async item =>
{
if (string.IsNullOrWhiteSpace(item.AvatarXsUrl))
{
return;
}
if (!Uri.TryCreate(item.AvatarXsUrl, UriKind.Absolute, out var avatarUri))
{
return;
}
item.AvatarImage = await ImageHelper.LoadFromWeb(avatarUri);
}));
Performers = new ObservableCollection<ActivityUserDisplayItem>(items);
SelectedPerformer = null;
StatusMessage = $"{activity.Name} · {Performers.Count} utilisateur(s)";
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Performers = new ObservableCollection<ActivityUserDisplayItem>();
SelectedPerformer = null;
StatusMessage = "Accès refusé pour les activités (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Performers = new ObservableCollection<ActivityUserDisplayItem>();
SelectedPerformer = null;
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
OpenCommandFormsCommand.NotifyCanExecuteChanged();
}
}
private bool CanOpenCommandForms()
=> SelectedPerformer is not null && CurrentActivity?.Forms?.Count > 0;
[RelayCommand(CanExecute = nameof(CanOpenCommandForms))]
private async Task OpenCommandFormsAsync()
{
if (SelectedPerformer is null || CurrentActivity is null)
{
StatusMessage = "Sélectionnez un utilisateur et une activité avec formulaire.";
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new CommandFormsPageViewModel(CurrentActivity, SelectedPerformer, _billingClient);
await app.PushPageAsync(vm);
}
}

View file

@ -1,50 +0,0 @@
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
namespace PostIt.ViewModels;
public sealed partial class ActivityUserDisplayItem : ObservableObject
{
public string PerformerId { get; init; } = string.Empty;
public string AvatarXsUrl { get; init; } = string.Empty;
public bool HasPerformerProfile { get; init; }
public string PerformerBadgeLabel { get; init; } = "Profil pro";
public bool IsPerformerActive { get; init; }
public string PerformerStatusBadgeLabel { get; init; } = "Inactif";
public string PerformerStatusBadgeBackground { get; init; } = "#FDECEA";
public string PerformerStatusBadgeBorder { get; init; } = "#C62828";
public string PerformerStatusBadgeForeground { get; init; } = "#8E0000";
public string UserName { get; init; } = string.Empty;
public string AvatarFallbackLabel { get; init; } = "?";
public string WebSite { get; init; } = string.Empty;
public int ExtraActivityCount { get; init; }
public string ExtraActivityLabel { get; init; } = "Pas d'autre activité";
[ObservableProperty]
public partial Bitmap? AvatarImage { get; set; }
public static ActivityUserDisplayItem FromDto(PerformerActivity dto, string avatarXsUrl)
{
return new ActivityUserDisplayItem
{
PerformerId = dto.PerformerId,
AvatarXsUrl = avatarXsUrl,
HasPerformerProfile = dto.HasPerformerProfile,
UserName = dto.UserName,
AvatarFallbackLabel = string.IsNullOrWhiteSpace(dto.UserName)
? "?"
: dto.UserName.Trim()[0].ToString().ToUpperInvariant(),
WebSite = dto.WebSite,
IsPerformerActive = dto.Active,
PerformerStatusBadgeLabel = dto.Active ? "Actif" : "Inactif",
PerformerStatusBadgeBackground = dto.Active ? "#E6F7EC" : "#FDECEA",
PerformerStatusBadgeBorder = dto.Active ? "#2E7D32" : "#C62828",
PerformerStatusBadgeForeground = dto.Active ? "#1B5E20" : "#8E0000",
ExtraActivityCount = dto.ExtraActivityCount,
ExtraActivityLabel = dto.ExtraActivityCount == 0
? "Pas d'autre activité"
: $"Autres spécialisations: {dto.ExtraActivityCount}"
};
}
}

View file

@ -1,173 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc;
using Yavsc.Api.Client;
using Yavsc.Abstract.Workflow;
namespace PostIt.ViewModels;
public partial class BillingQueriesPageViewModel : ViewModelBase
{
private readonly BillingApiClient _billingClient;
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; }
public bool IsReadOnly { get; }
public bool OngoingOnly { get; }
[ObservableProperty]
public partial ObservableCollection<BillingQueryDisplayItem> Queries { get; set; } = new();
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedQueryCommand))]
public partial BillingQueryDisplayItem? SelectedQuery { get; set; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Chargement des commandes...";
public string Title => IsReadOnly
? $"Demandes en cours ({Form.Title})"
: $"Commandes {Form.Title}";
public string ContextLabel => $"{Performer.UserName} · {Activity.Name}";
public bool CanOpenDetails => !IsReadOnly;
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public BillingQueriesPageViewModel(
ActivityInfo activity,
ActivityUserDisplayItem performer,
CommandFormSummary form,
BillingApiClient billingClient,
bool isReadOnly = false,
bool ongoingOnly = false)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
IsReadOnly = isReadOnly;
OngoingOnly = ongoingOnly;
}
public Task InitializeAsync() => RefreshAsync();
private bool CanOpenSelectedQuery() => !IsReadOnly && SelectedQuery is not null;
[RelayCommand]
public async Task RefreshAsync()
{
IsBusy = true;
try
{
var list = await _billingClient.GetQuerySummariesAsync(Form.ActionName).ConfigureAwait(true);
var filtered = (list ?? new())
.Where(q => q.ActivityCode == Activity.Code && q.PerformerId == Performer.PerformerId)
.Where(q => !OngoingOnly || IsOngoingStatus(q.Status))
.OrderByDescending(q => q.EventDate ?? DateTime.MinValue)
.ThenByDescending(q => q.Id)
.Select(BillingQueryDisplayItem.FromDto)
.ToList();
Queries = new ObservableCollection<BillingQueryDisplayItem>(filtered);
StatusMessage = BuildLoadedStatusMessage(filtered.Count);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
Queries = new ObservableCollection<BillingQueryDisplayItem>();
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
Queries = new ObservableCollection<BillingQueryDisplayItem>();
StatusMessage = $"Erreur: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanOpenSelectedQuery))]
public async Task OpenSelectedQueryAsync()
{
if (IsReadOnly)
{
StatusMessage = "Mode lecture seule: l'ouverture en modification est désactivée.";
return;
}
if (SelectedQuery is null)
{
StatusMessage = "Sélectionnez une commande.";
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
IsBusy = true;
try
{
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var vm = Form.CreateCommandPageViewModel(Activity, Performer, _billingClient);
await vm!.InitializeAsync(details).ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'ouverture: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
private string BuildLoadedStatusMessage(int count)
{
if (count == 0)
{
return OngoingOnly
? "Aucune demande en cours pour ce formulaire."
: "Aucune commande trouvée pour ce formulaire.";
}
if (OngoingOnly)
{
return $"{count} demande(s) en cours chargée(s) (lecture seule).";
}
return $"{count} commande(s) chargée(s).";
}
private static bool IsOngoingStatus(QueryStatus status)
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress;
}

View file

@ -1,35 +0,0 @@
using System;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public sealed class BillingQueryDisplayItem
{
public long Id { get; init; }
public string Description { get; init; } = string.Empty;
public string Summary { get; init; } = string.Empty;
public string StatusLabel { get; init; } = string.Empty;
public string EventDateLabel { get; init; } = string.Empty;
public string BillingCode { get; init; } = string.Empty;
public static BillingQueryDisplayItem FromDto(BillingQuerySummaryDto dto)
{
var summary = !string.IsNullOrWhiteSpace(dto.Reason)
? dto.Reason
: !string.IsNullOrWhiteSpace(dto.AdditionalInfo)
? dto.AdditionalInfo
: dto.Description;
return new BillingQueryDisplayItem
{
Id = dto.Id,
Description = string.IsNullOrWhiteSpace(dto.Description)
? $"Commande #{dto.Id}"
: dto.Description,
Summary = summary,
StatusLabel = dto.Status.ToString(),
EventDateLabel = dto.EventDate?.ToLocalTime().ToString("g") ?? "Date non précisée",
BillingCode = dto.BillingCode,
};
}
}

View file

@ -1,135 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
public partial class CommandFormsPageViewModel : ViewModelBase
{
private readonly BillingApiClient _billingClient;
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
[ObservableProperty]
public partial ObservableCollection<CommandFormSummary> Forms { get; set; }
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(OpenSelectedFormCommand)), NotifyCanExecuteChangedFor(nameof(OpenQueriesCommand)), NotifyCanExecuteChangedFor(nameof(OpenOngoingQueriesCommand))]
public partial CommandFormSummary? SelectedForm { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
public string Title => $"Formulaires pour {Performer.UserName}";
public string ContextLabel => $"{Activity.Name} · {Forms.Count} formulaire(s)";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public CommandFormsPageViewModel(
ActivityInfo activity,
ActivityUserDisplayItem performer,
BillingApiClient billingClient)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
Forms = new ObservableCollection<CommandFormSummary>((activity.Forms ?? new())
.OrderBy(f => f.Title)
.ThenBy(f => f.ActionName));
SelectedForm = Forms.FirstOrDefault();
StatusMessage = Forms.Count == 0
? "Aucun formulaire n'est disponible pour cette activité."
: "Choisissez le formulaire à utiliser.";
}
private bool CanOpenSelectedForm() => SelectedForm is not null;
private bool CanOpenQueries() => SelectedForm is not null;
private bool CanOpenOngoingQueries() => SelectedForm is not null;
[RelayCommand(CanExecute = nameof(CanOpenSelectedForm))]
private async Task OpenSelectedFormAsync()
{
if (SelectedForm is null)
{
StatusMessage = "Sélectionnez un formulaire.";
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = SelectedForm.CreateCommandPageViewModel(
Activity, Performer, _billingClient);
await vm!.InitializeAsync();
await app.PushPageAsync(vm);
}
[RelayCommand(CanExecute = nameof(CanOpenQueries))]
private async Task OpenQueriesAsync()
{
if (SelectedForm is null)
{
StatusMessage = "Sélectionnez un formulaire.";
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new BillingQueriesPageViewModel(Activity, Performer, SelectedForm, _billingClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
[RelayCommand(CanExecute = nameof(CanOpenOngoingQueries))]
private async Task OpenOngoingQueriesAsync()
{
if (SelectedForm is null)
{
StatusMessage = "Sélectionnez un formulaire.";
return;
}
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new BillingQueriesPageViewModel(
Activity,
Performer,
SelectedForm,
_billingClient,
isReadOnly: true,
ongoingOnly: true);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
}

View file

@ -1,108 +0,0 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Yavsc;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.ViewModels;
public abstract partial class BillingCommandPageViewModel : RemoteViewModelBase
{
protected readonly BillingApiClient _billingClient;
public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; }
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; }
[ObservableProperty]
public partial string Reason { get; set; } = string.Empty;
[ObservableProperty]
public partial bool Consent { get; set; } = true;
[ObservableProperty]
public partial string AdditionalInfo { get; set; } = string.Empty;
[ObservableProperty]
public partial long? ExistingQueryId { get; set; }
[ObservableProperty]
public partial QueryStatus CommandStatus { get; set; } = QueryStatus.Inserted;
public bool CanUseCurrentLocation => IsSupported && !IsBusy;
public string Title => Form.Title;
public string PerformerLabel => Performer.UserName;
public string ActivityLabel => Activity.Name;
public virtual bool IsSupported => true;
public string BillingRoute => $"/billing/{Form.ActionName}";
public bool IsEditingExisting => ExistingQueryId.HasValue;
public string SubmitLabel => IsEditingExisting ? "Mettre à jour la commande" : "Poster la commande";
public virtual string SupportMessage => $"Le formulaire {Form.ActionName} n'est pas encore pris en charge dans PostIt.";
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public BillingCommandPageViewModel(
ActivityInfo activity,
ActivityUserDisplayItem performer,
CommandFormSummary form,
BillingApiClient billingClient)
{
Activity = activity ?? throw new ArgumentNullException(nameof(activity));
Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
StatusMessage = SupportMessage;
}
partial void OnExistingQueryIdChanged(long? value)
{
OnPropertyChanged(nameof(IsEditingExisting));
OnPropertyChanged(nameof(SubmitLabel));
}
partial void OnIsBusyChanged(bool value)
{
OnPropertyChanged(nameof(CanUseCurrentLocation));
}
public async Task InitializeAsync(BillingQueryDetailsDto? existingQuery = null)
{
await LoadAsync();
if (existingQuery is not null)
{
ApplyExistingQuery(existingQuery);
return;
}
}
protected abstract void ApplyExistingQuery(BillingQueryDetailsDto existingQuery);
[RelayCommand]
protected abstract Task SubmitAsync();
}

View file

@ -1,165 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
using Yavsc.Models.Haircut;
namespace PostIt.ViewModels.Commands;
public partial class BrushViewModel : RdvViewModel
{
public override string SupportMessage => "Choisissez une prestation coiffure puis postez la commande.";
[ObservableProperty]
public partial ObservableCollection<HairPrestationDto> AvailablePrestations { get; set; } = new();
[ObservableProperty]
public partial HairPrestationDto? SelectedPrestation { get; set; }
public BrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
}
public override async Task LoadAsync()
{
var prestations = await _billingClient.GetHairPrestationsAsync(Form.ActionName);
AvailablePrestations = new ObservableCollection<HairPrestationDto>
(prestations ?? new List<HairPrestationDto>());
if (SelectedPrestation is null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault();
}
}
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
base.ApplyExistingQuery(existingQuery);
if (existingQuery.PrestationId is not null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault(x => x.Id == existingQuery.PrestationId.Value);
}
IsBusy = true;
try
{
if (SelectedPrestation is null)
{
SelectedPrestation = AvailablePrestations.FirstOrDefault();
}
StatusMessage = AvailablePrestations.Count == 0
? "Aucune prestation coiffure disponible."
: SupportMessage;
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au catalogue de prestations (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors du chargement des prestations: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
protected override async Task SubmitAsync()
{
if (!Consent)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
if (SelectedPrestation is null)
{
StatusMessage = "Sélectionnez une prestation coiffure.";
return;
}
IsBusy = true;
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = EventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
}
};
payload.PrestationId = SelectedPrestation.Id;
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = (DateTime?)EventDate,
Location = locationPayload,
PrestationId = SelectedPrestation.Id,
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? null : AdditionalInfo.Trim(),
Status = payload.Status,
}).ConfigureAwait(true);
}
StatusMessage = IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.";
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi de la commande: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,130 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.ViewModels.Commands;
public partial class MBrushViewModel : BrushViewModel
{
public override string SupportMessage => "Choisissez une ou plusieurs prestations coiffure puis postez la commande.";
[ObservableProperty]
public partial ObservableCollection<SelectableHairPrestationItem> MultiPrestations { get; set; } = new();
public MBrushViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
}
public override async Task LoadAsync()
{
await base.LoadAsync().ConfigureAwait(true);
MultiPrestations = new ObservableCollection<SelectableHairPrestationItem>(
AvailablePrestations.Select(SelectableHairPrestationItem.FromDto));
}
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
base.ApplyExistingQuery(existingQuery);
var selectedIds = existingQuery.PrestationIds is null
? new HashSet<long>()
: new HashSet<long>(existingQuery.PrestationIds);
foreach (var item in MultiPrestations)
{
item.IsSelected = selectedIds.Contains(item.Id);
}
}
protected override async Task SubmitAsync()
{
if (!Consent)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
var selectedPrestations = MultiPrestations.Where(x => x.IsSelected).ToList();
if (selectedPrestations.Count == 0)
{
StatusMessage = "Sélectionnez au moins une prestation coiffure.";
return;
}
IsBusy = true;
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = EventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
},
PrestationIds = selectedPrestations.Select(x => x.Id).ToList(),
};
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = EventDate,
Location = locationPayload,
Prestations = selectedPrestations.Select(x => new { PrestationId = x.Id }).ToList(),
Status = payload.Status,
}).ConfigureAwait(true);
}
StatusMessage = IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.";
}
catch (HttpRequestException ex)
when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi de la commande: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
}

View file

@ -1,211 +0,0 @@
using System;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Services;
using Yavsc.Abstract.Workflow;
using Yavsc.Api.Client;
namespace PostIt.ViewModels.Commands;
public partial class RdvViewModel : BillingCommandPageViewModel
{
public override string SupportMessage => "Complétez les informations du rendez-vous puis postez la commande.";
[ObservableProperty]
public partial string Address { get; set; } = string.Empty;
[ObservableProperty]
public partial double? Latitude { get; set; }
[ObservableProperty]
public partial double? Longitude { get; set; }
[ObservableProperty]
public partial DateTime EventDate { get; set; }
public RdvViewModel(ActivityInfo activity, ActivityUserDisplayItem performer, CommandFormSummary form, BillingApiClient billingClient)
: base(activity, performer, form, billingClient)
{
EventDate = DateTime.Now.AddDays(1);
}
protected override void ApplyExistingQuery(BillingQueryDetailsDto existingQuery)
{
ExistingQueryId = existingQuery.Id;
CommandStatus = existingQuery.Status;
Consent = existingQuery.Consent;
Reason = existingQuery.Reason ?? string.Empty;
AdditionalInfo = existingQuery.AdditionalInfo ?? string.Empty;
if (existingQuery.EventDate is not null)
{
EventDate = existingQuery.EventDate.Value
.ToLocalTime();
}
if (existingQuery.Location is not null)
{
Address = existingQuery.Location.Address ?? string.Empty;
Latitude = existingQuery.Location.Latitude;
Longitude = existingQuery.Location.Longitude;
}
StatusMessage = $"Commande #{existingQuery.Id} chargée.";
}
[RelayCommand(CanExecute = nameof(CanUseCurrentLocation))]
private async Task UseCurrentLocationAsync()
{
if (!CanUseCurrentLocation)
{
return;
}
IsBusy = true;
try
{
var result = await Platform.TryGetCurrentLocationAsync(default).ConfigureAwait(true);
if (!result.IsSuccess || !result.Latitude.HasValue || !result.Longitude.HasValue)
{
StatusMessage = result.Message;
return;
}
Latitude = result.Latitude.Value;
Longitude = result.Longitude.Value;
StatusMessage = string.IsNullOrWhiteSpace(Address)
? "Position récupérée. Complétez l'adresse puis envoyez la commande."
: result.Message;
}
catch (OperationCanceledException)
{
StatusMessage = "La récupération de la position a été annulée.";
}
catch (Exception ex)
{
StatusMessage = $"Impossible de récupérer la position: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
protected static object BuildLocationPayload(string address, double? latitude, double? longitude)
{
if (latitude.HasValue && longitude.HasValue)
{
return new
{
Address = address,
Latitude = latitude.Value,
Longitude = longitude.Value,
};
}
return new
{
Address = address,
};
}
protected override async Task SubmitAsync()
{
if (!IsSupported)
{
StatusMessage = SupportMessage;
return;
}
if (!Consent)
{
StatusMessage = "Le consentement est requis pour poster la commande.";
return;
}
if (string.IsNullOrWhiteSpace(Address))
{
StatusMessage = "L'adresse du rendez-vous est requise.";
return;
}
if (string.IsNullOrWhiteSpace(Reason))
{
StatusMessage = "Le motif du rendez-vous est requis.";
return;
}
IsBusy = true;
try
{
var address = Address.Trim();
var locationPayload = BuildLocationPayload(address, Latitude, Longitude);
var payload = new BillingQueryDetailsDto
{
Id = ExistingQueryId ?? 0,
BillingCode = Form.ActionName,
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent = Consent,
EventDate = EventDate,
Status = CommandStatus,
Reason = Reason.Trim(),
AdditionalInfo = string.IsNullOrWhiteSpace(AdditionalInfo) ? string.Empty : AdditionalInfo.Trim(),
Location = new BillingLocationDto
{
Address = address,
Latitude = Latitude,
Longitude = Longitude,
}
};
if (IsEditingExisting)
{
await _billingClient.UpdateAsync(Form.ActionName, ExistingQueryId!.Value, payload).ConfigureAwait(true);
}
else
{
await _billingClient.CreateAsync(Form.ActionName, new
{
ActivityCode = Activity.Code,
PerformerId = Performer.PerformerId,
Consent,
EventDate = EventDate,
Location = locationPayload,
Reason = payload.Reason,
Status = payload.Status,
}).ConfigureAwait(true);
}
StatusMessage = IsEditingExisting
? $"Commande #{ExistingQueryId} mise à jour sur {BillingRoute} pour {Performer.UserName}."
: $"Commande transmise sur {BillingRoute} pour {Performer.UserName}.";
}
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
StatusMessage = "Accès refusé au billing (scope 'api'). Déconnectez puis reconnectez-vous.";
}
catch (Exception ex)
{
StatusMessage = $"Erreur lors de l'envoi: {ex.Message}";
}
finally
{
IsBusy = false;
}
}
public override Task LoadAsync()
{
return Task.CompletedTask;
}
}

View file

@ -1,9 +1,4 @@
using System;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers;
using PostIt.Services; using PostIt.Services;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -29,25 +24,8 @@ public class HomePageViewModel : ViewModelBase
Settings = settings; Settings = settings;
SessionStatus = sessionStatus; SessionStatus = sessionStatus;
OpenActivities = new AsyncRelayCommand(OpenActivitiesAsync);
} }
public IAsyncRelayCommand OpenBlogs { get; } = new AsyncRelayCommand(App.PushBlogsPageAsync); public RelayCommand OpenBlogs { get; set; } = new RelayCommand(() => App.PushMainPageAsync());
public IAsyncRelayCommand OpenActivities { get; }
private async Task OpenActivitiesAsync()
{
var app = (App?)Application.Current;
var vm = app?.ServiceProvider?.GetRequiredService<ActivitiesPageViewModel>();
if (app is null || vm is null)
{
throw new InvalidOperationException("Activities page is not available.");
}
await vm.RefreshAsync();
await app.PushPageAsync(vm);
}
/// <summary> /// <summary>
/// Avalonia designer constructor. Builds a self-contained VM /// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can /// with a freshly-constructed Settings so the XAML preview can

View file

@ -190,7 +190,8 @@ public partial class MainViewModel : ViewModelBase
/// overload; the dedicated endpoint keeps the wire /// overload; the dedicated endpoint keeps the wire
/// contract clean.</para> /// contract clean.</para>
/// </summary> /// </summary>
public async Task SetPublishStateAsync(bool publish) [RelayCommand]
internal async Task TogglePublishAsync()
{ {
if (SelectedPost is null || SelectedPost.Id == 0) if (SelectedPost is null || SelectedPost.Id == 0)
{ {
@ -200,29 +201,20 @@ public partial class MainViewModel : ViewModelBase
await ExecuteAsync(async () => await ExecuteAsync(async () =>
{ {
// The checkbox updates DraftIsPublished before the command is var desired = !DraftIsPublished;
// executed. Using the current bound value avoids the await BlogClient!.SetPublishAsync(SelectedPost.Id, desired);
// double-toggle bug in which the UI has already flipped the DraftIsPublished = desired;
// state and the command flips it again.
await BlogClient!.SetPublishAsync(SelectedPost.Id, publish);
DraftIsPublished = publish;
// Mirror into the selected post so a subsequent // Mirror into the selected post so a subsequent
// RefreshPostsAsync() doesn't blow away the // RefreshPostsAsync() doesn't blow away the
// locally flipped state until the round-trip // locally flipped state until the round-trip
// re-hydrates it. // re-hydrates it.
SelectedPost.IsPublished = publish; SelectedPost.IsPublished = desired;
StatusMessage = publish StatusMessage = desired
? $"Billet {SelectedPost.Id} publié." ? $"Billet {SelectedPost.Id} publié."
: $"Billet {SelectedPost.Id} remis en brouillon."; : $"Billet {SelectedPost.Id} remis en brouillon.";
}); });
} }
[RelayCommand]
internal async Task TogglePublishAsync()
{
await SetPublishStateAsync(DraftIsPublished);
}
/// <summary> /// <summary>
/// DEV ONLY: open the signature capture page. The production /// DEV ONLY: open the signature capture page. The production
/// entry point is a SignalR push from Yavsc.Org ("devis /// entry point is a SignalR push from Yavsc.Org ("devis
@ -250,23 +242,7 @@ public partial class MainViewModel : ViewModelBase
StatusMessage = "Select an existing post before managing ACL."; StatusMessage = "Select an existing post before managing ACL.";
return; return;
} }
await ((App)App.Current!).PushPageAsync(GetACLViewModel(SelectedPost)).ConfigureAwait(true);
var postForAcl = SelectedPost;
try
{
var detailed = await BlogClient!.GetPostAsync(SelectedPost.Id).ConfigureAwait(true);
if (detailed is not null)
{
postForAcl = detailed;
SelectedPost = detailed;
}
}
catch
{
// Keep the dialog usable even if the detail refresh fails.
}
await ((App)App.Current!).PushPageAsync(GetACLViewModel(postForAcl)).ConfigureAwait(true);
} }
[RelayCommand] [RelayCommand]

View file

@ -1,8 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Net;
using System.Threading.Tasks; using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
@ -10,17 +8,9 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Api.Client.Dtos; using Yavsc.Api.Client.Dtos;
using Yavsc.Abstract.BlogSpot; using Yavsc.Abstract.BlogSpot;
using Yavsc.Abstract.Identity.Security;
using System.Net.Http;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public sealed class PostAclEntry
{
public long CircleId { get; init; }
public string CircleName { get; init; } = string.Empty;
}
/// <summary> /// <summary>
/// View model for the "Gérer l'ACL" modal of a single blog post. /// View model for the "Gérer l'ACL" modal of a single blog post.
/// ///
@ -51,7 +41,7 @@ public partial class PostAclDialogViewModel : ViewModelBase
MyCircles { get; set; } = new(); MyCircles { get; set; } = new();
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<PostAclEntry> public partial ObservableCollection<PostAccessControlRulePayload>
AclEntries { get; set; } = new(); AclEntries { get; set; } = new();
[ObservableProperty] [ObservableProperty]
@ -87,9 +77,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
Post = post ?? throw new ArgumentNullException(nameof(post)); Post = post ?? throw new ArgumentNullException(nameof(post));
_aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient)); _aclClient = aclClient ?? throw new ArgumentNullException(nameof(aclClient));
_circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient)); _circleClient = circleClient ?? throw new ArgumentNullException(nameof(circleClient));
AclEntries = new ObservableCollection<PostAclEntry>(post.GetACL().Select(a => ToAclEntry(a.CircleId)));
SelectedCircleToAdd = null;
} }
public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); } public override bool CanNavigateNext { get => throw new NotImplementedException(); protected set => throw new NotImplementedException(); }
@ -103,17 +90,16 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true; IsBusy = true;
try try
{ {
// Load circles for the picker. ACL entries come from the // Load circles and ACL entries in parallel — both are
// BlogPostDto detail payload (source of truth for initial state). // independent reads on the same host. The caller's uid
// is implicit in both endpoints.
var circlesTask = _circleClient.GetMyCirclesAsync(); var circlesTask = _circleClient.GetMyCirclesAsync();
await Task.WhenAll(circlesTask); var aclTask = _aclClient.GetMyAclAsync();
await Task.WhenAll(circlesTask, aclTask);
var circles = circlesTask.Result ?? new List<CircleDto>(); var circles = circlesTask.Result ?? new List<CircleDto>();
MyCircles = new ObservableCollection<CircleDto>(circles); MyCircles = new ObservableCollection<CircleDto>(circles);
// Resolve labels now that circles are available.
AclEntries = new ObservableCollection<PostAclEntry>(AclEntries.Select(a => ToAclEntry(a.CircleId)));
StatusMessage = $"{AclEntries.Count} autorisation(s)"; StatusMessage = $"{AclEntries.Count} autorisation(s)";
_loaded = true; _loaded = true;
@ -140,20 +126,14 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = true; IsBusy = true;
try try
{ {
if (AclEntries.Any(a => a.CircleId == SelectedCircleToAdd.Id)) var created = await _aclClient.GrantAsync(new Yavsc.Abstract.BlogSpot.PostAccessControlRulePayload
{
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
return;
}
var created = await _aclClient.GrantAsync(new PostAccessControlRulePayload
{ {
CircleId = SelectedCircleToAdd.Id, CircleId = SelectedCircleToAdd.Id,
BlogPostId = Post.Id BlogPostId = Post.Id
}); });
if (created is not null) if (created is not null)
{ {
AclEntries.Add(ToAclEntry(created.CircleId)); AclEntries.Add(created);
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé"; StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » autorisé";
} }
else else
@ -161,13 +141,6 @@ public partial class PostAclDialogViewModel : ViewModelBase
StatusMessage = "Autorisation refusée par le serveur"; StatusMessage = "Autorisation refusée par le serveur";
} }
} }
catch (HttpRequestException ex) when (ex.StatusCode == HttpStatusCode.Conflict)
{
// Conflict means the link already exists in backend. Resync
// from the dedicated ACL API so the UI reflects server truth.
await ReloadAclEntriesFromServerAsync();
StatusMessage = $"Cercle « {SelectedCircleToAdd.Name} » déjà autorisé";
}
catch (Exception ex) catch (Exception ex)
{ {
StatusMessage = $"Erreur: {ex.Message}"; StatusMessage = $"Erreur: {ex.Message}";
@ -179,16 +152,14 @@ public partial class PostAclDialogViewModel : ViewModelBase
} }
[RelayCommand] [RelayCommand]
public async Task RevokeAsync(PostAclEntry? acl) public async Task RevokeAsync(PostAccessControlRulePayload? acl)
{ {
if (acl is null) return; if (acl is null) return;
IsBusy = true; IsBusy = true;
try try
{ {
await _aclClient.RevokeAsync(acl.CircleId); await _aclClient.RevokeAsync(acl.CircleId);
var existing = AclEntries.FirstOrDefault(e => e.CircleId == acl.CircleId); AclEntries.Remove(acl);
if (existing is not null)
AclEntries.Remove(existing);
StatusMessage = "Autorisation révoquée"; StatusMessage = "Autorisation révoquée";
} }
catch (Exception ex) catch (Exception ex)
@ -200,26 +171,4 @@ public partial class PostAclDialogViewModel : ViewModelBase
IsBusy = false; IsBusy = false;
} }
} }
private async Task ReloadAclEntriesFromServerAsync()
{
var allAcl = await _aclClient.GetMyAclAsync();
var currentPostAcl = (allAcl ?? new List<PostAccessControlRulePayload>())
.Where(a => a.BlogPostId == Post.Id)
.Select(a => ToAclEntry(a.CircleId))
.GroupBy(a => a.CircleId)
.Select(g => g.First())
.ToList();
AclEntries = new ObservableCollection<PostAclEntry>(currentPostAcl);
}
private PostAclEntry ToAclEntry(long circleId)
{
var circleName = MyCircles.FirstOrDefault(c => c.Id == circleId)?.Name;
return new PostAclEntry
{
CircleId = circleId,
CircleName = string.IsNullOrWhiteSpace(circleName) ? $"Cercle #{circleId}" : circleName
};
}
} }

View file

@ -1,10 +0,0 @@
using System.Threading.Tasks;
namespace PostIt.ViewModels;
public abstract class RemoteViewModelBase : ViewModelBase
{
public abstract Task LoadAsync();
}

View file

@ -1,22 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
using Yavsc.Models.Haircut;
namespace PostIt.ViewModels;
public partial class SelectableHairPrestationItem : ObservableObject
{
public long Id { get; init; }
public string Title { get; init; } = string.Empty;
public string Details { get; init; } = string.Empty;
[ObservableProperty]
public partial bool IsSelected { get; set; }
public static SelectableHairPrestationItem FromDto(HairPrestationDto dto)
=> new()
{
Id = dto.Id,
Title = dto.Title,
Details = dto.Details,
};
}

View file

@ -26,7 +26,7 @@ public partial class Settings : ViewModelBase
public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/"; public partial string BlogsApiUrl { get; set; } = "https://blogs.pschneider.fr/api/v1/";
[ObservableProperty] [ObservableProperty]
public partial string ApiUrl { get; set; } = "https://api.pschneider.fr/api/v1/"; public partial string BusinessApiUrl { get; set; } = "https://business.pschneider.fr/api/v1/";
[ObservableProperty] [ObservableProperty]
public partial string SearchText { get; set; } = string.Empty; public partial string SearchText { get; set; } = string.Empty;
@ -45,7 +45,7 @@ public partial class Settings : ViewModelBase
partial void OnDarkModeChanged(bool value) => MarkDirty(); partial void OnDarkModeChanged(bool value) => MarkDirty();
partial void OnBlogsApiUrlChanged(string value) => MarkDirty(); partial void OnBlogsApiUrlChanged(string value) => MarkDirty();
partial void OnApiUrlChanged(string value) => MarkDirty(); partial void OnBusinessApiUrlChanged(string value) => MarkDirty();
partial void OnSearchTextChanged(string value) => MarkDirty(); partial void OnSearchTextChanged(string value) => MarkDirty();
/// <summary> /// <summary>
@ -154,10 +154,7 @@ public partial class Settings : ViewModelBase
{ {
"openid", // OIDC: required for the id_token "openid", // OIDC: required for the id_token
"profile", // OIDC: standard profile claims "profile", // OIDC: standard profile claims
"offline_access", // OIDC: required to receive a refresh_token "offline_access" // OIDC: required to receive a refresh_token
"blogs",
"api"
}; };
/// <summary> /// <summary>
@ -300,15 +297,8 @@ public partial class Settings : ViewModelBase
// → our overridden dispatcher-safe marshaller below. // → our overridden dispatcher-safe marshaller below.
else lock (_mutationGate) else lock (_mutationGate)
{ {
var legacyApiUrl = TryReadLegacyApiUrl(json);
this.Authentication = settings.Authentication; this.Authentication = settings.Authentication;
this.DarkMode = settings.DarkMode; this.DarkMode = settings.DarkMode;
this.BlogsApiUrl = !string.IsNullOrWhiteSpace(settings.BlogsApiUrl)
? settings.BlogsApiUrl
: legacyApiUrl ?? this.BlogsApiUrl;
this.ApiUrl = !string.IsNullOrWhiteSpace(settings.ApiUrl)
? settings.ApiUrl
: this.ApiUrl;
this.SearchText = settings.SearchText ?? string.Empty; this.SearchText = settings.SearchText ?? string.Empty;
if (!(settings.Authentication is null)) if (!(settings.Authentication is null))
{ {
@ -324,7 +314,7 @@ public partial class Settings : ViewModelBase
settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes; settings.Authentication.Scopes = AuthenticationSettings.DefaultScopes;
} }
else else
this.Authentication.Scopes = settings.Authentication.Scopes; this.Authentication.Scopes = settings.Authentication.Scopes;
} }
} }
// A disk load (or an embedded-resource fallback) is the // A disk load (or an embedded-resource fallback) is the
@ -353,26 +343,6 @@ public partial class Settings : ViewModelBase
} }
} }
private static string? TryReadLegacyApiUrl(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("ApiUrl", out var apiUrl)
&& apiUrl.ValueKind == JsonValueKind.String)
{
return apiUrl.GetString();
}
}
catch
{
// Ignore legacy payload parse errors: normal deserialization
// already reports actionable diagnostics to the caller.
}
return null;
}
private void UseDefaultSettings() private void UseDefaultSettings()
{ {
this.Authentication = new AuthenticationSettings this.Authentication = new AuthenticationSettings
@ -383,8 +353,6 @@ public partial class Settings : ViewModelBase
Scopes = AuthenticationSettings.DefaultScopes Scopes = AuthenticationSettings.DefaultScopes
}; };
this.DarkMode = false; this.DarkMode = false;
this.BlogsApiUrl = "https://blogs.pschneider.fr/api/v1/";
this.ApiUrl = "https://api.pschneider.fr/api/v1/";
this.SearchText = string.Empty; this.SearchText = string.Empty;
} }

View file

@ -1,8 +1,8 @@
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
public abstract class ViewModelBase : ObservableObject public abstract partial class ViewModelBase : ObservableObject
{ {
/// <summary> /// <summary>
/// Gets if the user can navigate to the next page /// Gets if the user can navigate to the next page

View file

@ -1,147 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:wf="using:Yavsc.Abstract.Workflow"
x:Class="PostIt.Views.ActivitiesPage"
x:DataType="vm:ActivitiesPageViewModel"
Header="Activités">
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
<Grid.Styles>
<Style Selector="Border.user-badge">
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="6" />
<Setter Property="Padding" Value="5,0" />
<Setter Property="MinHeight" Value="16" />
</Style>
<Style Selector="TextBlock.user-badge-text">
<Setter Property="FontSize" Value="9" />
<Setter Property="FontWeight" Value="SemiBold" />
<Setter Property="VerticalAlignment" Value="Center" />
</Style>
</Grid.Styles>
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="8">
<Button Content="🗘 Rafraîchir" Command="{Binding RefreshCommand}" />
<TextBlock Text="📑 Catalogue des activités" FontWeight="Bold" VerticalAlignment="Center" />
</StackPanel>
<Grid Grid.Row="1" Margin="0,12,0,12" ColumnDefinitions="*,16,*,16,*">
<Grid Grid.Column="0" RowDefinitions="Auto,*">
<TextBlock Grid.Row="0" Text="Activités" FontWeight="Bold" Margin="0,0,0,8" />
<ListBox Grid.Row="1"
ItemsSource="{Binding Activities}"
SelectedItem="{Binding SelectedActivity, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityInfo">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding PerformerCount, StringFormat='Utilisateurs: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<Grid Grid.Column="2" RowDefinitions="Auto,Auto,*">
<TextBlock Grid.Row="0" Text="Spécialisations" FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Grid.Row="1"
Text="{Binding SelectedActivityLabel, StringFormat='Pour : {0}'}"
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
<ListBox Grid.Row="2"
ItemsSource="{Binding Specializations}"
SelectedItem="{Binding SelectedSpecialization, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:ActivityInfo">
<StackPanel Spacing="2" Margin="0,0,0,8">
<TextBlock Text="{Binding Name}" FontWeight="Bold" />
<TextBlock Text="{Binding Description}" TextWrapping="Wrap" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding PerformerCount, StringFormat='Utilisateurs: {0}'}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
<Grid Grid.Column="4" RowDefinitions="Auto,Auto,*,Auto">
<TextBlock Grid.Row="0" Text="Utilisateurs" FontWeight="Bold" Margin="0,0,0,8" />
<TextBlock Grid.Row="1"
Text="{Binding CurrentActivityLabel, StringFormat='Activité affichée : {0}'}"
FontSize="11" Opacity="0.7" Margin="0,0,0,8" />
<ListBox Grid.Row="2"
ItemsSource="{Binding Performers}"
SelectedItem="{Binding SelectedPerformer, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:ActivityUserDisplayItem">
<StackPanel Spacing="2" Margin="0,0,0,8">
<StackPanel Orientation="Horizontal" Spacing="6" VerticalAlignment="Center">
<Border Width="24"
Height="24"
CornerRadius="12"
ClipToBounds="True"
Background="#E9ECEF">
<Grid>
<TextBlock Text="{Binding AvatarFallbackLabel}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
FontWeight="SemiBold"
Foreground="#495057" />
<Image Source="{Binding AvatarImage}"
Width="24"
Height="24"
Stretch="UniformToFill" />
</Grid>
</Border>
<TextBlock Text="{Binding UserName}" FontWeight="Bold" />
<Border IsVisible="{Binding HasPerformerProfile}"
Classes="user-badge"
Background="#D9F5E5"
BorderBrush="#2E7D32"
>
<TextBlock Text="{Binding PerformerBadgeLabel}"
Classes="user-badge-text"
Foreground="#1B5E20"
/>
</Border>
<Border IsVisible="{Binding HasPerformerProfile}"
Classes="user-badge"
Background="{Binding PerformerStatusBadgeBackground}"
BorderBrush="{Binding PerformerStatusBadgeBorder}"
>
<TextBlock Text="{Binding PerformerStatusBadgeLabel}"
Classes="user-badge-text"
Foreground="{Binding PerformerStatusBadgeForeground}"
/>
</Border>
</StackPanel>
<TextBlock Text="{Binding WebSite}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding ExtraActivityLabel}"
FontSize="11" Opacity="0.6" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Button Grid.Row="3"
Margin="0,8,0,0"
Content="Voir les formulaires"
Command="{Binding OpenCommandFormsCommand}" />
</Grid>
</Grid>
<Grid Grid.Row="2" ColumnDefinitions="*,Auto">
<TextBox Grid.Column="0"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -1,17 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class ActivitiesPage : ContentPage
{
public ActivitiesPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -1,52 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
x:Class="PostIt.Views.BillingQueriesPage"
x:DataType="vm:BillingQueriesPageViewModel"
Header="Commandes billing">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="2">
<TextBlock Text="{Binding Title}" FontSize="18" FontWeight="Bold" />
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8" Margin="0,12,0,12">
<Button Content="Rafraîchir" Command="{Binding RefreshCommand}" />
<Button Content="Ouvrir la commande"
Command="{Binding OpenSelectedQueryCommand}"
IsVisible="{Binding CanOpenDetails}" />
</StackPanel>
<ListBox Grid.Row="2" ItemsSource="{Binding Queries}" SelectedItem="{Binding SelectedQuery, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:BillingQueryDisplayItem">
<Border BorderThickness="0,0,0,1" BorderBrush="#22000000" Padding="0,0,0,10" Margin="0,0,0,10">
<StackPanel Spacing="3">
<TextBlock Text="{Binding Description}" FontWeight="Bold" />
<TextBlock Text="{Binding Summary}" TextWrapping="Wrap" FontSize="12" Opacity="0.8" />
<StackPanel Orientation="Horizontal" Spacing="8">
<TextBlock Text="{Binding EventDateLabel}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding StatusLabel}" FontSize="11" Opacity="0.7" />
<TextBlock Text="{Binding BillingCode}" FontSize="11" Opacity="0.6" />
</StackPanel>
</StackPanel>
</Border>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="3" ColumnDefinitions="*,Auto" Margin="0,12,0,0">
<TextBox Grid.Column="0"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="1" Width="120" IsIndeterminate="True" IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -1,17 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class BillingQueriesPage : ContentPage
{
public BillingQueriesPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -1,56 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:wf="using:Yavsc.Abstract.Workflow"
x:Class="PostIt.Views.CommandFormsPage"
x:DataType="vm:CommandFormsPageViewModel"
Header="Formulaires">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<TextBlock Grid.Row="0"
Text="{Binding Title}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Grid.Row="1"
Margin="0,4,0,12"
Text="{Binding ContextLabel}"
Opacity="0.75" />
<ListBox Grid.Row="2"
ItemsSource="{Binding Forms}"
SelectedItem="{Binding SelectedForm, Mode=TwoWay}">
<ListBox.ItemTemplate>
<DataTemplate x:DataType="wf:CommandFormSummary">
<StackPanel Spacing="2" Margin="0,0,0,10">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding ActionName, StringFormat='Route billing : /billing/{0}'}"
FontSize="11"
Opacity="0.7" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<Grid Grid.Row="3" Margin="0,12,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,12,*">
<Button Grid.Column="0"
Content="Ouvrir le formulaire"
Command="{Binding OpenSelectedFormCommand}" />
<Button Grid.Column="2"
Content="Voir les commandes"
Command="{Binding OpenQueriesCommand}" />
<Button Grid.Column="4"
Content="Demandes en cours (lecture seule)"
Command="{Binding OpenOngoingQueriesCommand}" />
<TextBox Grid.Column="6"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
</Grid>
</Grid>
</ContentPage>

View file

@ -1,17 +0,0 @@
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views;
public partial class CommandFormsPage : ContentPage
{
public CommandFormsPage()
{
InitializeComponent();
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
}

View file

@ -1,113 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:cvm="using:PostIt.ViewModels.Commands"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="PostIt.Views.Commands.BrushPage"
x:DataType="cvm:BrushViewModel"
Header="Commande billing">
<ScrollViewer>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
Text="{Binding Title}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
Margin="0,4,0,12"
Text="{Binding SupportMessage}"
TextWrapping="Wrap"
Opacity="0.8" />
<TextBlock Grid.Row="2" Text="Utilisateur" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PerformerLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="3" Text="Activité" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding ActivityLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="4" Text="Date" VerticalAlignment="Center" Margin="0,0,12,8" />
<DatePicker Grid.Row="4" Grid.Column="1" DayFormat="ddd dd" SelectedDate="{Binding EventDate, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
<Button Grid.Row="7"
Grid.ColumnSpan="2"
HorizontalAlignment="Left"
Margin="0,0,0,8"
Content="Utiliser ma position"
Command="{Binding UseCurrentLocationCommand}" />
<TextBlock Grid.Row="8" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding Latitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="9" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Longitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="10"
Text="Prestation"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<ComboBox Grid.Row="10"
Grid.Column="1"
ItemsSource="{Binding AvailablePrestations}"
SelectedItem="{Binding SelectedPrestation, Mode=TwoWay}"
Margin="0,0,0,8">
<ComboBox.ItemTemplate>
<DataTemplate>
<StackPanel Spacing="2">
<TextBlock Text="{Binding Title}" FontWeight="Bold" />
<TextBlock Text="{Binding Details}" FontSize="11" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Grid.Row="12"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="12"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8" />
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande"
IsChecked="{Binding Consent, Mode=TwoWay}"
Margin="0,4,0,12" />
<Grid Grid.Row="14" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0"
Content="{Binding SubmitLabel}"
Command="{Binding SubmitCommand}"
IsEnabled="{Binding IsSupported}" />
<TextBox Grid.Column="2"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ScrollViewer>
</ContentPage>

View file

@ -1,13 +0,0 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
namespace PostIt.Views.Commands;
public partial class BrushPage : ContentPage
{
public BrushPage()
{
InitializeComponent();
}
}

View file

@ -1,91 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:cvm="using:PostIt.ViewModels.Commands"
x:Class="PostIt.Views.Commands.RdvPage"
x:DataType="cvm:RdvViewModel"
Header="Commande billing">
<ScrollViewer>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto" ColumnDefinitions="Auto,*" Margin="12">
<TextBlock Grid.Row="0" Grid.ColumnSpan="2"
Text="{Binding Title}"
FontSize="18"
FontWeight="Bold" />
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
Margin="0,4,0,12"
Text="{Binding SupportMessage}"
TextWrapping="Wrap"
Opacity="0.8" />
<TextBlock Grid.Row="2" Text="Utilisateur" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="2" Grid.Column="1" Text="{Binding PerformerLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="3" Text="Activité" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="3" Grid.Column="1" Text="{Binding ActivityLabel}" IsReadOnly="True" Margin="0,0,0,8" />
<TextBlock Grid.Row="4" Text="Date" VerticalAlignment="Center" Margin="0,0,12,8" />
<DatePicker Grid.Row="4" Grid.Column="1" DayFormat="ddd dd" SelectedDate="{Binding EventDate, Mode=TwoWay}" Margin="0,0,0,8" />
<TextBlock Grid.Row="5"
Text="Motif"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="5"
Grid.Column="1"
Text="{Binding Reason, Mode=TwoWay}"
Margin="0,0,0,8" />
<TextBlock Grid.Row="6" Text="Adresse" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="6" Grid.Column="1" Text="{Binding Address, Mode=TwoWay}" Margin="0,0,0,8" />
<Button Grid.Row="7"
Grid.ColumnSpan="2"
HorizontalAlignment="Left"
Margin="0,0,0,8"
Content="Utiliser ma position"
Command="{Binding UseCurrentLocationCommand}" />
<TextBlock Grid.Row="8" Text="Latitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="8" Grid.Column="1" Text="{Binding Latitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="9" Text="Longitude" VerticalAlignment="Center" Margin="0,0,12,8" />
<TextBox Grid.Row="9" Grid.Column="1" Text="{Binding Longitude, Mode=TwoWay}" PlaceholderText="Optionnel" Margin="0,0,0,8" />
<TextBlock Grid.Row="12"
Text="Informations"
VerticalAlignment="Center"
Margin="0,0,12,8" />
<TextBox Grid.Row="12"
Grid.Column="1"
Text="{Binding AdditionalInfo, Mode=TwoWay}"
Margin="0,0,0,8" />
<CheckBox Grid.Row="13" Grid.ColumnSpan="2"
Content="Je consens à la création de cette commande"
IsChecked="{Binding Consent, Mode=TwoWay}"
Margin="0,4,0,12" />
<Grid Grid.Row="14" Grid.ColumnSpan="2" ColumnDefinitions="Auto,12,*,Auto">
<Button Grid.Column="0"
Content="{Binding SubmitLabel}"
Command="{Binding SubmitCommand}"
IsEnabled="{Binding IsSupported}" />
<TextBox Grid.Column="2"
Text="{Binding StatusMessage}"
IsReadOnly="True"
AcceptsReturn="True"
TextWrapping="Wrap"
BorderThickness="0"
BorderBrush="Transparent"
Padding="0"
Background="Transparent"
VerticalAlignment="Center" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ScrollViewer>
</ContentPage>

View file

@ -1,17 +0,0 @@
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);
}
}

View file

@ -18,9 +18,5 @@
Command="{Binding OpenBlogs}" Command="{Binding OpenBlogs}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Parcourir les activités"
Command="{Binding OpenActivities}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel> </StackPanel>
</ContentPage> </ContentPage>

View file

@ -24,13 +24,13 @@
<RowDefinition Height="Auto" /> <RowDefinition Height="Auto" />
</Grid.RowDefinitions> </Grid.RowDefinitions>
<Border Grid.Row="0" <StackPanel Grid.Row="0" Spacing="12"
HorizontalAlignment="Stretch" HorizontalAlignment="Stretch"
VerticalAlignment="Top" VerticalAlignment="Top">
Padding="0">
<WrapPanel Orientation="Horizontal"> <StackPanel Orientation="Horizontal" Spacing="8">
<Button Command="{Binding RefreshAsync}" Content="🗘 Refresh" /> <Button Command="{Binding RefreshAsync}" Content="Refresh" />
<Button Command="{Binding SearchAsync}" Content="🔍 Filter" /> <Button Command="{Binding SearchAsync}" Content="Filter" />
<Button Command="{Binding SaveAsync}" Content="Save" /> <Button Command="{Binding SaveAsync}" Content="Save" />
<Button Command="{Binding DeleteAsync}" Content="Delete" /> <Button Command="{Binding DeleteAsync}" Content="Delete" />
<Button x:Name="ManageAclButton" <Button x:Name="ManageAclButton"
@ -62,8 +62,8 @@
Command="{Binding OpenSignatureDevAsync}" Command="{Binding OpenSignatureDevAsync}"
Content="[DEV] Signature" Content="[DEV] Signature"
ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" /> ToolTip.Tip="DEV ONLY — to remove when SignalR handler lands" />
</WrapPanel> </StackPanel>
</Border> </StackPanel>
<Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8"> <Border Grid.Row="1" BorderBrush="Gray" BorderThickness="1" Padding="8">
<ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}" <ListBox ItemsSource="{Binding FilteredPosts}" SelectedItem="{Binding SelectedPost, Mode=TwoWay}"

View file

@ -3,7 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="PostIt.Views.PostAclDialog" x:Class="PostIt.Views.PostAclDialog"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:dtos="using:Yavsc.Api.Client.Dtos" xmlns:dtos="using:Yavsc.Api.Client.Dtos"
xmlns:yabst="using:Yavsc.Abstract.Identity.Security"
x:DataType="vm:PostAclDialogViewModel" x:DataType="vm:PostAclDialogViewModel"
> >
<Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12"> <Grid RowDefinitions="Auto,*,Auto,Auto" Margin="12">
@ -31,10 +32,10 @@
<ListBox Grid.Row="1" <ListBox Grid.Row="1"
ItemsSource="{Binding AclEntries}"> ItemsSource="{Binding AclEntries}">
<ListBox.ItemTemplate> <ListBox.ItemTemplate>
<DataTemplate x:DataType="vm:PostAclEntry"> <DataTemplate x:DataType="yabst:CircleAuthorization">
<Grid ColumnDefinitions="*,Auto"> <Grid ColumnDefinitions="*,Auto">
<StackPanel Grid.Column="0" Spacing="2"> <StackPanel Grid.Column="0" Spacing="2">
<TextBlock Text="{Binding CircleName}" <TextBlock Text="{Binding CircleId, StringFormat='Cercle #{0}'}"
FontWeight="Bold"/> FontWeight="Bold"/>
</StackPanel> </StackPanel>
<Button Grid.Column="1" Content="Révoquer" <Button Grid.Column="1" Content="Révoquer"

View file

@ -39,41 +39,25 @@
<TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox" <TextBox Grid.Row="7" x:Name="BlogsApiUrlTextBox"
Text="{Binding BlogsApiUrl, Mode=TwoWay}"/> Text="{Binding BlogsApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="8" Text="API URL"/> <TextBlock Grid.Row="8" Text="Business API URL"/>
<TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox" <TextBox Grid.Row="9" x:Name="BusinessApiUrlTextBox"
Text="{Binding ApiUrl, Mode=TwoWay}"/> Text="{Binding BusinessApiUrl, Mode=TwoWay}"/>
<TextBlock Grid.Row="10" Text="Dark mode"/> <TextBlock Grid.Row="10" Text="Dark mode"/>
<CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/> <CheckBox Grid.Row="11" x:Name="DarkModeCheckBox" IsChecked="{Binding DarkMode, Mode=TwoWay}"/>
<StackPanel Grid.Row="12" Spacing="8"> <!-- Sauver: bound to the Save RelayCommand on the Settings
<TextBlock Text="Avatar: PNG, JPG/JPEG, WEBP, GIF. Taille max 2 MB." VM. The source generator emits an ICommand property whose
Opacity="0.8" name matches the source method exactly (no "Command"
TextWrapping="Wrap" /> suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
<TextBlock x:Name="AvatarUploadStatusText" de binding pour [RelayCommand]" for the full rationale.
Text="" IsEnabled tracks IsDirty so the button auto-disables
TextWrapping="Wrap" /> when there's nothing to persist. -->
<Button Grid.Row="12" Content="Sauver"
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right"> HorizontalAlignment="Right"
<Button x:Name="ChooseAvatarButton" Command="{Binding Save}"
Content="Choisir l'avatar" IsEnabled="{Binding IsDirty}"/>
Click="ChooseAvatar_Click"
Margin="0,0,8,0"/>
<!-- Sauver: bound to the Save RelayCommand on the Settings
VM. The source generator emits an ICommand property whose
name matches the source method exactly (no "Command"
suffix is added), so we bind {Binding Save} here. See
AGENTS.md "Avalonia + CommunityToolkit.Mvvm : conventions
de binding pour [RelayCommand]" for the full rationale.
IsEnabled tracks IsDirty so the button auto-disables
when there's nothing to persist. -->
<Button Content="Sauver"
Command="{Binding Save}"
IsEnabled="{Binding IsDirty}"/>
</StackPanel>
</StackPanel>
</Grid> </Grid>
</ContentPage> </ContentPage>

View file

@ -1,116 +1,12 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Services;
namespace PostIt.Views; namespace PostIt.Views;
public partial class SettingsPage: ContentPage public partial class SettingsPage: ContentPage
{ {
private const int MaxAvatarSizeMegabytes = 2;
private const string AcceptedAvatarFormats = "PNG, JPG/JPEG, WEBP, GIF";
private int _avatarStatusVersion;
public SettingsPage() public SettingsPage()
{ {
InitializeComponent(); InitializeComponent();
} }
}
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<YavscApiClient>();
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"
};
}
}

View file

@ -62,16 +62,9 @@ public partial class SignaturePage : ContentPage
var h = PadFrame.Bounds.Height; var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return; if (w <= 0 || h <= 0) return;
var pending = _control.PendingStroke;
var strokes = _control.Strokes; var strokes = _control.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0; int i = 0;
while (i < sealedCount) while (i < strokes.Count)
{ {
int k = strokes[i]; int k = strokes[i];
if (k <= 0) break; if (k <= 0) break;
@ -94,27 +87,5 @@ public partial class SignaturePage : ContentPage
poly.Points = pts; poly.Points = pts;
InkLayer.Children.Add(poly); 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<Point>(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);
}
} }
} }

View file

@ -1,5 +1,4 @@
using Yavsc.Abstract.Identity.Security; using Yavsc.Abstract.Identity.Security;
using System.Text.Json.Serialization;
namespace Yavsc.Blogspot; namespace Yavsc.Blogspot;
@ -36,26 +35,11 @@ public class BlogPostDto : IBlogPost
return true; return true;
} }
public ICollection<CircleAuthorization> ACL = new List<CircleAuthorization>(); private List<CircleAuthorization> ACL { get; set; } = new List<CircleAuthorization>();
/// <summary>
/// Wire-only ACL bridge for System.Text.Json: accepts the
/// <c>acl</c>/<c>ACL</c> payload from GET detail responses,
/// but is never emitted on POST/PUT from the client.
/// </summary>
[JsonPropertyName("acl")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public List<CircleAuthorization>? WireAcl
{
get => null;
set => ACL = value ?? new List<CircleAuthorization>();
}
public string[] Tags { get; set; } public string[] Tags { get; set; }
ICollection<CircleAuthorization> ICircleAuthorized.ACL => this.ACL;
public string[] GetTags() => Tags; public string[] GetTags() => Tags;
public CircleAuthorization[] GetACL() => ACL.ToArray(); public ICircleAuthorization[] GetACL() => ACL.ToArray();
} }

View file

@ -3,7 +3,8 @@ using Yavsc.Abstract.Identity.Security;
namespace Yavsc.Abstract.BlogSpot; namespace Yavsc.Abstract.BlogSpot;
public class PostAccessControlRulePayload : CircleAuthorization public class PostAccessControlRulePayload : ICircleAuthorization
{ {
public long CircleId { get; set; }
public long BlogPostId { get; set; } public long BlogPostId { get; set; }
} }

View file

@ -1,11 +0,0 @@
namespace Yavsc.Models.Haircut;
/// <summary>
/// Lightweight hair-prestation description exposed to API clients.
/// </summary>
public sealed class HairPrestationDto
{
public long Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Details { get; set; } = string.Empty;
}

View file

@ -11,7 +11,7 @@ namespace Yavsc.Abstract.Identity.Security;
/// UI already has the post, and the circles are looked up by id /// UI already has the post, and the circles are looked up by id
/// against the list returned by <c>GET /api/circle</c>.</para> /// against the list returned by <c>GET /api/circle</c>.</para>
/// </summary> /// </summary>
public class CircleAuthorization public sealed class CircleAuthorization : ICircleAuthorization
{ {
public long CircleId { get; set; } public long CircleId { get; set; }
} }

View file

@ -0,0 +1,8 @@
namespace Yavsc.Abstract.Identity.Security
{
public interface ICircleAuthorization
{
long CircleId { get; set; }
}
}

View file

@ -9,7 +9,7 @@ namespace Yavsc.Abstract.Identity.Security
bool AuthorizeCircle(long circleId); bool AuthorizeCircle(long circleId);
ICollection<CircleAuthorization> ACL { get; } //ICircleAuthorization [] GetACL(); ICircleAuthorization [] GetACL();
} }
} }

View file

@ -1,17 +0,0 @@
namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Activity node returned by the browsing API.
/// </summary>
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<CommandFormSummary> Forms { get; set; } = new();
public List<ActivityInfo> Children { get; set; } = new();
}

View file

@ -1,11 +0,0 @@
namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Lightweight command-form description exposed to API clients.
/// </summary>
public sealed class CommandFormSummary
{
public long Id { get; set; }
public string ActionName { get; set; } = string.Empty;
public string Title { get; set; } = string.Empty;
}

View file

@ -1,19 +0,0 @@
using System.ComponentModel.DataAnnotations;
namespace Yavsc.Models.Workflow
{
/// <summary>
/// Supported country of exercise for performer profile validations.
/// </summary>
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;
}
}

View file

@ -3,7 +3,6 @@ namespace Yavsc.Workflow
public interface IPerformerProfile public interface IPerformerProfile
{ {
string PerformerId { get; set; } string PerformerId { get; set; }
string ExerciseCountryCode { get; set; }
string SIREN { get; set; } string SIREN { get; set; }
bool AcceptNotifications { get; set; } bool AcceptNotifications { get; set; }
long OrganizationAddressId { get; set; } long OrganizationAddressId { get; set; }

View file

@ -1,19 +0,0 @@
namespace Yavsc.Abstract.Workflow;
/// <summary>
/// Lightweight performer description returned for one activity.
/// </summary>
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; }
}

View file

@ -1,86 +0,0 @@
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
namespace Yavsc.Models.Workflow
{
/// <summary>
/// Validation rule for performer business code input by country.
/// </summary>
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<Country> Countries = new List<Country>
{
new() { Code = "fr", DisplayName = "France" },
new() { Code = "en", DisplayName = "England" },
new() { Code = "pt", DisplayName = "Portugal" },
};
public static readonly IReadOnlyList<PerformerCodeInputValidation> Rules = new List<PerformerCodeInputValidation>
{
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;
}
}
}

View file

@ -11,7 +11,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -1,79 +0,0 @@
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;
/// <summary>
/// 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.
/// </summary>
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<List<ActivityInfo>> GetCatalogAsync(
string? parentCode = null,
CancellationToken ct = default)
{
var path = string.IsNullOrWhiteSpace(parentCode)
? $"{PathPrefix}/catalog"
: $"{PathPrefix}/catalog?parentCode={Uri.EscapeDataString(parentCode)}";
return _api.CallAsync<List<ActivityInfo>>(HttpMethod.Get, Absolute(path), ct: ct);
}
public Task<List<PerformerActivity>> GetUsersAsync(
string activityCode,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(activityCode))
throw new ArgumentException("Activity code is required.", nameof(activityCode));
return _api.CallAsync<List<PerformerActivity>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Uri.EscapeDataString(activityCode)}/users"),
ct: ct);
}
public Task<List<PerformerActivity>> 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();
}

View file

@ -1,364 +0,0 @@
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;
/// <summary>
/// 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.
/// </summary>
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<List<HairPrestationDto>> GetHairPrestationsAsync(string billingCode, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("Billing code is required.", nameof(billingCode));
return _api.CallAsync<List<HairPrestationDto>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Uri.EscapeDataString(billingCode)}/prestations"),
ct: ct);
}
public async Task<List<BillingQuerySummaryDto>> GetQuerySummariesAsync(string billingCode, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("Billing code is required.", nameof(billingCode));
var items = await _api.CallAsync<List<BillingQuerySummaryDto>>(
HttpMethod.Get,
Absolute($"{PathPrefix}/{Uri.EscapeDataString(billingCode)}"),
ct: ct) ?? new List<BillingQuerySummaryDto>();
foreach (var item in items)
{
item.BillingCode = billingCode;
}
return items;
}
public async Task<BillingQueryDetailsDto> 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<RdvQueryResponse>(HttpMethod.Get, path, ct: ct).ConfigureAwait(false);
return MapRdv(dto, code);
}
if (string.Equals(code, BillingCodes.Brush, StringComparison.Ordinal))
{
var dto = await _api.CallAsync<HairCutQueryResponse>(HttpMethod.Get, path, ct: ct).ConfigureAwait(false);
return MapBrush(dto, code);
}
if (string.Equals(code, BillingCodes.MBrush, StringComparison.Ordinal))
{
var dto = await _api.CallAsync<HairMultiCutQueryResponse>(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<HairPrestationCollectionItemResponse>())
.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<string, object?>
{
["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<HairPrestationCollectionItemResponse>? Prestations { get; set; }
}
private sealed class HairPrestationCollectionItemResponse
{
public long PrestationId { get; set; }
}
}

View file

@ -1,35 +0,0 @@
using System;
using System.Collections.Generic;
using Yavsc;
namespace Yavsc.Api.Client;
/// <summary>
/// Normalized billing-query shape used by PostIt when opening an existing
/// command from history.
/// </summary>
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<long> 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; }
}

View file

@ -1,23 +0,0 @@
using System;
using Yavsc;
namespace Yavsc.Api.Client;
/// <summary>
/// Lightweight billing-query projection consumed by PostIt list views.
/// Extra JSON fields from concrete query types are ignored.
/// </summary>
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; }
}

View file

@ -17,7 +17,7 @@
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-beta.1+183.Branch.release-1.0.8-rc8.Sha.6cff3db32ecf72c0d2d430b7002fa7816a34e070</InformationalVersion> <InformationalVersion>1.1.0-beta.1+177.Branch.release-1.0.8-rc7.Sha.1b237fa5404368bc891ac6c599fc3920bbf83c1c</InformationalVersion>
<Version>1.1.0-beta.1</Version> <Version>1.1.0-beta.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,161 +0,0 @@
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<ApiWebServerFixture>
{
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<List<PerformerActivity>>(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<Yavsc.Models.ApplicationDbContext>();
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<List<PerformerActivity>>(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<List<ActivityInfo>>(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<List<PerformerActivity>>(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<List<ActivityInfo>>(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<List<ActivityInfo>>(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<List<PerformerActivity>>(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<List<PerformerActivity>>(TestContext.Current.CancellationToken);
var performersPayload = await performersResponse.Content.ReadFromJsonAsync<List<PerformerActivity>>(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);
}
}

View file

@ -1,6 +0,0 @@
namespace Yavsc.Api.Test;
[CollectionDefinition("Yavsc Api")]
public sealed class ApiCollection
{
}

View file

@ -1,7 +0,0 @@
<Project>
<!--
Yavsc.Api.Test has no project-specific package versions. All
package versions are declared at the repository root.
-->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
</Project>

View file

@ -1,343 +0,0 @@
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<ApplicationDbContext>(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<WebApplication> ConfigurePipelineAsync(WebApplication app)
{
app.UseDeveloperExceptionPage();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>();
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<ApplicationDbContext>();
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<HairTaintInstance>(),
};
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<HairTaintInstance>(),
};
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<HairPrestationCollectionItem>
{
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;
}
}
}
}
}

View file

@ -1,120 +0,0 @@
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<ApiWebServerFixture>
{
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<HairCutQuery>(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<HairCutQuery>(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<List<HairPrestationDto>>(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<long> GetPrestationIdAsync()
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return await db.HairPrestation.Select(p => p.Id).FirstAsync(TestContext.Current.CancellationToken);
}
}

Some files were not shown because too many files have changed in this diff Show more