Compare commits

...
Author SHA1 Message Date
988de7c366 version update
All checks were successful
Forgejo Release / release (push) Successful in 14m46s
2026-09-22 18:39:19 +01:00
07dd9d4434 CHANGELOG: add 1.0.8-rc16 section
Describes the estimate signature + TeX/PDF generation release impact:
signature pad on validation, role-aware accept/reject with signatures,
ProAccepted/ClientAccepted split, estimate.tex/estimate.pdf endpoints
in Yavsc.Api, vector TikZ signatures, PostIt PDF/TeX download buttons,
list refresh + title renames, the lualatex stdin runtime, and the
Emergency stop / log-write / RenderViewToString / Layout fixes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:35:48 +01:00
7e13161f27 Fix lualatex stdin PDF gen + unit test for signed estimate PDF
GenerateEstimatePdf passed /dev/stdin as the input filename so lualatex
treats stdin as a regular input file instead of the terminal — without
it, lualatex emergency-stops after the first line of a multi-line
document (the real root cause of the production "Emergency stop", not
bera/luaotfload which were separate host-package gaps already fixed).

EstimatePdf endpoint calls GenerateEstimatePdf directly and streams the
returned FileInfo, guarding on fo.Exists. QueryAcceptanceRequest is
extracted into its own file under ViewModels/FrontOffice.

Add EstimatePdfGenerationTests: unit tests for SignatureToTikz (vector
draws of both signatures) and GenerateEstimatePdf on a devis signed by
both parties — asserts the PDF exists at the expected DestDir path, is
non-empty, and that only the PDF persists (aux/log cleaned). Skips
cleanly when lualatex is absent.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 18:33:47 +01:00
82d1d6f6d6 Draw estimate signatures as vector TikZ instead of PNG
The estimate foot now renders the provider and client signatures as
TikZ \draw/\fill paths straight from the captured stroke data, in two
side-by-side minipages with a \useasboundingbox so the boxes align
regardless of stroke coverage. A side that hasn't signed shows a
signature rule.

- TeXHelpers.SignatureToTikz walks the PostIt wire format
  ([k, x0,y0, ...] per stroke, coords in [0, CoordinateMax], y-down)
  and emits one \draw per stroke (round caps/joins, 1.5pt), flipping y
  so the signature is upright on paper; a single-point tap becomes a
  filled dot, mirroring the Avalonia round-capped Polyline.
- FrontOfficeApiController.LoadEstimateForRenderAsync includes
  Estimate.Signatures so SignaturePro/SignatureClient resolve.
- Estimate_tex.cshtml drops the \includegraphics of a PNG (which
  failed the build when the file was absent) and adds \usepackage{tikz}.
- README: document texlive-pictures (tikz) and texlive-fonts-extra
  (bera) as runtime deps; bera.sty missing is the likely cause of the
  remaining host Emergency stop.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 16:45:05 +01:00
b76ce164f5 corrige la génération du Pdf 2026-09-22 16:34:51 +01:00
3f01a3ee74 PDF gen: capture lualatex stdout/stderr into the error message
The detailed LaTeX error (missing .sty, missing \includegraphics
target, …) was being lost: we only redirected stdin, and the engine
writes its diagnostics to the .log file we delete in cleanup. So a
generation failure surfaced only as the terse "Emergency stop" in the
journal, with no clue what actually failed.

Redirect stdout and stderr, and on non-zero exit include them in
GenerationErrorMessage so the caller sees the real engine diagnostic.
Keep the .log file on failure (only clean it on success) so it can be
inspected alongside.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:31:10 +01:00
2a30746a32 README: document lualatex runtime deps for PDF generation
texlive-binaries (lualatex) + texlive-luatex (luaotfload) are required
on the API host for estimate/bill PDF generation. Note that luaotfload
moved from texlive-latex-extra into texlive-luatex on recent Debian.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:28:52 +01:00
3ddfed5fe0 Estimate validation polish + PDF/TeX generation in Yavsc.Api
- List removal: GetOngoingEstimatesAsProvider filters on
  ProviderValidationDate so a provider's signed estimate leaves their
  list; PostIt refreshes the list on navigation pop-back.
- Title renames: "Devis en attente de signature fournisseur/client"
  on both the provider and client sides (HomePage, VM, tests).
- Move the estimate/bill TeX+PDF generation flow from Yavsc.Org into
  Yavsc.Api (TeXHelpers, PdfGenerationViewModel, BillViewComponent,
  Bill/Estimate templates, _ViewImports). Program.cs wires
  AddControllersWithViews + SiteSettings; FrontOfficeApiController
  injects IRazorViewEngine (cast to IViewEngine) with optional deps
  and exposes estimate.tex/estimate.pdf endpoints keyed by query id.
- PDF generation drives through the Estimate_pdf view (side-effect
  render) and compiles via the system lualatex, fed TeX on stdin (no
  intermediate .tex), PDF to the bills dir, aux artifacts cleaned so
  only the .pdf persists. Resolve the bills dir to an absolute path so
  lualatex's -output-directory and working directory agree regardless
  of the host cwd (fixes "I can't write on file estimate-1.log").
- PostIt: DownloadAsync on the client + GetEstimateTex/PdfAsync;
  Télécharger PDF/TeX buttons on the validation and command-detail
  pages via a StorageProvider save helper.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-22 03:22:01 +01:00
05f60a72b2 CLI options : -c configurqtion-file.json 2026-09-21 18:53:17 +01:00
064be13388 Reopen estimate: carry signature strokes on the estimate payload
Reopening an already-validated estimate showed an empty pad: the
strokes were stored server-side but never sent back. Embed them in the
estimate payload and load them straight into the pad, in the same wire
format PostIt captured — no separate fetch.

- Server: Signature inverse navs get STJ [JsonIgnore] (cycle-safe
  under System.Text.Json, matching ApplicationUser); the four estimate
  read endpoints Include(e => e.Signatures) so strokes ride the payload
- Client: EstimateDto.Signatures with EstimateSignatureDto (Strokes,
  Type=Pro/0|Client/1, CoordinateMax, CapturedAtUtc)
- PostIt: SignaturePadControl.Load(int[]) replaces the buffer and
  raises RedrawRequested; EstimateValidationPageViewModel.LoadExisting
  Signature() picks the perspective's side off the estimate and loads
  it on Attach
- Tests: server asserts GET estimate/{id} carries the strokes; PostIt
  VM tests cover load + side-by-perspective

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 18:03:08 +01:00
725fd0c728 Split QueryStatus.Accepted into ProAccepted/ClientAccepted
Distinguish the two parties' acceptances: ProAccepted (provider) and
ClientAccepted (client) replace the single Accepted value for new
acceptances. Accepted=2 is retained as a legacy value so existing DB rows
keep their integer mapping.

- FrontOfficeApiController.AcceptQuery sets status by ActorRole
- BillingController provider-ongoing filters match the new values
- FrontOfficeController (MVC) dashboard counts use ProAccepted||Accepted
- PostIt: IsOngoingStatus, GetStatusRank (client-accepted ranks first for
  the pro), and status-badge switches (P/C glyphs) handle the new values
- Tests assert ProAccepted (provider) vs ClientAccepted (client)

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:32 +01:00
35c5a7f611 PostIt: validate/refuse an estimate with a signature (pro or client)
Adds the client-side ability to open an estimate from the list, draw a
signature on a reusable SignaturePadView control, and accept or reject it
against /api/v1/front/query/accept|reject. The server resolves the actor
role (provider vs client) from the caller's identity, so the same page works
for either party.

- Yavsc.Api.Client: QueryAcceptanceDto + FrontOfficeApiClient (accept/reject)
- PostIt: extract SignaturePadView UserControl from SignaturePage; new
  EstimateValidationPage (VM + view) with Valider/Refuser/Effacer/Retour;
  wire OpenEstimate entry point from EstimateListPage; register client/page
  in DI + ViewLocator
- Tests: FrontOfficeApiClient + EstimateValidationPageViewModel unit tests;
  update EstimateListPageViewModelTests ctor

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 17:33:18 +01:00
806eb05bcc Add missing Account/Delete confirmation page
The POST Delete(UnregisterViewModel) action and the GET Delete() action
both existed in AccountController, but Views/Account/Delete.cshtml was
missing — so GET /Account/Delete had no page to render. Add the
confirmation view (posts back to Delete with the user's UserId, with a
"Confirmer la suppression" button and an "Annuler" cancel link).

Add a smoke test: an authenticated GET /Account/Delete returns 200 and
the confirmation page body.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:31 +01:00
eb560045b9 Finalize FrontOffice accept/reject: role-aware, authz, signature; drop Signature.FilePath
FrontOfficeApiController.AcceptQuery/RejectQuery now resolve the actor
(provider vs client vs admin) from the query's PerformerId/ClientId and
authorize accordingly — a non-party caller is forbidden. Either party may
accept or reject. Acceptance may carry a PostIt signature, which is staged
on the query's linked estimate (Estimate.CommandId == queryId) and sets the
estimate's ProviderValidationDate / ClientValidationDate; a signature with
no linked estimate, or a signature on a reject, returns 400. Admin bypasses
authz but cannot sign.

The shared signature-staging logic (find-or-add the Signature row by
(EstimateId, Type), write Strokes/CoordinateMax/CapturedAtUtc) is extracted
into EstimateSignaturePersister, and BillingController.Sign now calls it.
The persister owns no disk I/O and no SaveChanges — the caller persists in
its own transaction.

Signature.Strokes is now the sole source of truth: drop the FilePath
property, remove the on-disk EstimateSignatureFileHelper (and its tests),
and add the DropSignatureFilePath migration. The legacy PNG prosign/clisign
flow is independent of Signature.FilePath and is unchanged.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 16:15:18 +01:00
682c0a658d make reinstall, better 2026-09-21 14:58:40 +01:00
e6ded21bce asciidoc => markdown 2026-09-21 14:39:25 +01:00
caea7463b1 Hide inserted estimates from client list; redirect anon to /signin
Estimates with status Inserted (provider hasn't worked on them yet)
were showing up in the client's "Mes devis à valider" list. The
asclient endpoint now requires ProviderValidationDate to be set, so
only estimates the provider has actually established and submitted
are visible to the client. The asprovider endpoint is unchanged
(providers still need to see incoming unprocessed requests).

Also fix the anonymous-access redirect on Yavsc.Org: the application
cookie's LoginPath defaulted to /Account/Login, which has no
controller. Anonymous hits to a restricted page now redirect to the
real /signin endpoint (with /signout and /Account/AccessDenied set
to match the existing controller routes).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 14:38:49 +01:00
0e98f5732c deploy in pre and prod : bug fixes 2026-09-21 12:49:38 +01:00
595c7e0ff5 Fix white-on-white comment blocks in quartz theme
Quartz sets white body text on a coloured page background, but
--bs-tertiary-bg is near-white in its light variant. The .blogcomment
background now uses a semi-transparent black overlay so white text
stays legible, matching the dropdown hover fix.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:29:33 +01:00
15c64bbf7b Restore tree-structured comments in Blog Details
The $.psc.blogcomment widget (from the removed wwwroot/js/comment.js)
provided reply-to-comment nesting. Rewrite Details.cshtml to drive the
same /api/v1/blogcomments endpoint with ParentId, delegating reply
clicks so they work across the whole nested tree.

Render comment metadata server-side in the Comment view component
template (the widget used to add it client-side) and Include Author so
nested comments get their author instead of relying on the request-scoped
identity map.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:21:33 +01:00
c721f7bdd0 Rewrite Blogspot Index view with skip/take pagination
Renders Prev/Next pager from the Index(string id, int skip, int take)
call profile, drops dead .sametitle JS, and replaces the redundant
ReadPermission branch and Minutes>0 modifié check.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-21 11:12:20 +01:00
f521d6e159 The blogspot iis now in the PostIt app,
no more creation/edition from here
2026-09-21 10:40:49 +01:00
61d7f99ba0 accessibility 2026-09-21 10:38:09 +01:00
9494ac2067 reinstall-prod target 2026-09-21 10:37:39 +01:00
1b92b76fc9 code cleaup 2026-09-20 21:06:02 +01:00
4b34d67b3b reprod deployment 2026-09-20 18:35:27 +01:00
7f20ded14e --stop-on-fail 2026-09-20 14:12:08 +01:00
e80ad6ac92 test is ialready skipped when adb is not available 2026-09-20 13:42:45 +01:00
350dd1446d nuget referencezs updated 2026-09-20 01:53:20 +01:00
bc72d46bb3 Use AvaloniaEdit 2026-09-20 00:21:07 +01:00
2b2dca2a38 Publish, bug fix 2026-09-19 22:20:52 +01:00
1f849af886 refacto publication 2026-09-19 21:46:08 +01:00
6329309e68 Blog Post Id 2026-09-19 21:08:10 +01:00
dec9c6eb03 Updated 2026-09-19 18:09:02 +01:00
3632ed0ceb Org Markdown : Markdig 2026-09-19 18:00:09 +01:00
140 changed files with 13987 additions and 1977 deletions

View file

@ -15,7 +15,7 @@ POSTGRES_HOST=localhost
POSTGRES_PORT=5432 POSTGRES_PORT=5432
POSTGRES_DB=db POSTGRES_DB=db
POSTGRES_USER=pguser POSTGRES_USER=pguser
POSTGRES_PASSWORD=pgpassword POSTGRES_PASSWORD=<your pg password>
ANTHROPIC_API_KEY=none ANTHROPIC_API_KEY=none
ANTHROPIC_MAX_TOKENS=0 ANTHROPIC_MAX_TOKENS=0

View file

@ -33,7 +33,7 @@ jobs:
run: | run: |
set -e set -e
cd /src cd /src
git clone https://forgejo.pschneider.fr/notazof/yavsc.git _src git clone "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
cd _src cd _src
if [ -n "${GITHUB_REF:-}" ]; then if [ -n "${GITHUB_REF:-}" ]; then
git fetch origin "$GITHUB_REF" git fetch origin "$GITHUB_REF"
@ -53,5 +53,6 @@ jobs:
run: | run: |
echo "🚀 Lancement des tests..." echo "🚀 Lancement des tests..."
cd /src/_src && dotnet test \ cd /src/_src && dotnet test \
--verbosity normal \ --report-xunit \
--filter="Category!=Platform-Android" --report-xunit-html \
--report-xunit-html-filename "test-results.html"

View file

@ -69,7 +69,7 @@ jobs:
cd /src cd /src
if [[ ! -d _src/.git ]]; then if [[ ! -d _src/.git ]]; then
git clone --depth=1 https://forgejo.pschneider.fr/notazof/yavsc.git _src git clone --depth=1 "${FORGEJO_SERVER_URL}/${FORGEJO_REPOSITORY}.git" _src
fi fi
cd _src cd _src

20
.vscode/tasks.json vendored
View file

@ -1,27 +1,13 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"isRoot": true, "isRoot": true,
"problemMatcher": [
{
"owner": "dotnet",
"fileLocation": ["relative", "${workspaceFolder}"],
"source": "dotnet",
"pattern": {
"regexp": "^\\s*(.*)\\((\\d+),(\\d+)\\):\\s+(error|warning) (.*)$",
"file": 1,
"line": 2,
"column": 3,
"severity": 4,
"code": 5,
"message": 6
}
}
],
"tasks": [ "tasks": [
{ {
"label": "run-debug-android", "label": "run-debug-android",
"command": "dotnet", "command": "dotnet",
"type": "shell", "type": "shell",
"problemMatcher": ["$msCompile"],
"options": { "options": {
"cwd": "${workspaceFolder}/src/PostIt/PostIt.Android", "cwd": "${workspaceFolder}/src/PostIt/PostIt.Android",
"env": { "env": {
@ -43,6 +29,7 @@
"label": "build", "label": "build",
"command": "dotnet", "command": "dotnet",
"type": "process", "type": "process",
"problemMatcher": ["$msCompile"],
"args": ["build", "/property:GenerateFullPaths=true"], "args": ["build", "/property:GenerateFullPaths=true"],
"group": "build", "group": "build",
"isBuildCommand": true, "isBuildCommand": true,
@ -109,6 +96,7 @@
"isTestCommand": false, "isTestCommand": false,
"isBackground": true, "isBackground": true,
"command": "dotnet", "command": "dotnet",
"problemMatcher": ["$msCompile"],
"args": ["build", "/property:GenerateFullPaths=true"], "args": ["build", "/property:GenerateFullPaths=true"],
"options": { "options": {
"cwd": "src/PostIt/PostIt.Desktop" "cwd": "src/PostIt/PostIt.Desktop"

View file

@ -1,5 +1,81 @@
# Changelog # Changelog
## [1.0.8-rc16] - unstable
Flux de signature et de génération de devis : un devis peut désormais être
signé par les deux parties (fournisseur et client) puis téléchargé en PDF
ou TeX depuis PostIt, avec les signatures dessinées en vecteur.
### Added
* [PostIt] Pad de signature sur la page de validation d'un devis : on valide
ou on refuse en joignant une signature (côté fournisseur ou client), capturée
au format filaire PostIt (strokes) et envoyée sur l'acceptation.
* [Yavsc.Api] Finalisation de `POST /api/v1/front/query/accept` et `.../reject` :
autorisation selon le rôle (fournisseur/client/admin), signature optionnelle
persistée sur l'estimate avec horodatage `ProviderValidationDate` /
`ClientValidationDate` selon le côté signataire.
* [Yavsc.Api] `QueryStatus.Accepted` éclaté en `ProAccepted` / `ClientAccepted`
pour distinguer l'acceptation de chaque partie.
* [Yavsc.Api] Endpoints de devis `GET /api/v1/front/query/{id}/estimate.tex` et
`estimate.pdf` (clé par id de query). Le flux de génération TeX/PDF est
rapatrié de Yavsc.Org vers Yavsc.Api (`TeXHelpers`, `PdfGenerationViewModel`,
`BillViewComponent`, modèles Bill/Estimate, `_ViewImports` ; `Program.cs`
câble `AddControllersWithViews` + `SiteSettings`).
* [Yavsc.Api] Signatures du devis dessinées en TikZ vectoriel
(`TeXHelpers.SignatureToTikz`) en bas du document, fournisseur et client
côte à côte dans deux minipages ; plus de dépendance à un PNG de signature.
* [Yavsc.Api.Client] `DownloadAsync` sur le client + `GetEstimateTexAsync` /
`GetEstimatePdfAsync` ; boutons « Télécharger PDF » / « Télécharger TeX »
dans PostIt sur la page de validation du devis et le détail de commande,
avec un helper `StorageProvider` d'enregistrement de fichier.
* [PostIt] La liste des devis se rafraîchit au retour de navigation (pop-back),
de sorte qu'un devis validé quitte immédiatement la liste du signataire.
* [PostIt] Renommage des titres de liste : « Devis en attente de signature
fournisseur » / « Devis en attente de signature client ».
* [Yavsc.Abstract] `SignatureType` déplacé dans `Yavsc.Abstract.Billing`.
* [CLI] Option `-c configuration-file.json` au lancement.
* [Tests] `EstimatePdfGenerationTests` : tests unitaires du pipeline
signature → TikZ → PDF (devis signé des deux côtés), assertant que le PDF
existe au chemin de sortie attendu et que seul le PDF persiste (aux/log
nettoyés) ; skip propre si `lualatex` est absent.
* [README] Dépendances système au runtime pour la génération PDF :
`texlive-binaries` (lualatex), `texlive-luatex` (luaotfload),
`texlive-pictures` (tikz), `texlive-fonts-extra` (bera).
### Changed
* [Yavsc.Api] `GetOngoingEstimatesAsProvider` filtre désormais sur
`ProviderValidationDate` : un devis signé par le fournisseur quitte sa liste
« en attente ».
* [Yavsc.Api] La génération PDF compile via le `lualatex` système, le TeX fourni
sur l'entrée standard (`/dev/stdin`), le PDF écrit directement dans le
répertoire des factures (`Site.Bills`), les artefacts `.aux`/`.log` nettoyés
afin que seul le `.pdf` persiste.
### Fixed
* [Yavsc.Api] `Emergency stop` à la génération PDF : `lualatex` sans argument
fichier lit stdin en mode terminal et s'arrête après la première ligne d'un
document multi-lignes. Corrigé en passant `/dev/stdin` comme nom de fichier
d'entrée — stdin est alors traité comme un fichier régulier et le document
multi-lignes compile. C'était la cause racine de l'échec en production, pas
`bera` ni `luaotfload` (problèmes réels mais séparés, comblés côté paquets).
* [Yavsc.Api] `I can't write on file 'estimate-1.log'` : le chemin relatif
`Site.Bills` (`"bills"`) était imbriqué quand il servait à la fois de
`-output-directory` et de répertoire de travail ; résolu en chemin absolu.
* [Yavsc.Api] NRE dans `RenderViewToString` (RouteData / `ViewData.Model`
nuls) : reconstruit sur le `ControllerContext` du contrôleur ; `IViewEngine`
résolu via `IRazorViewEngine` (.NET 10 n'enregistre pas de `IViewEngine`
unique).
* [Yavsc.Api] `Layout = "null"` (chaîne) dans les modèles TeX cherchait
`null.cshtml` ; corrigé en `Layout = null` (null C#).
* [Yavsc.Api] `texi2pdf` (outil Texinfo, inadapté au LaTeX) remplacé par
`lualatex`.
* [Yavsc.Api] Les diagnostics lualatex étaient perdus (écrits dans le `.log`
qu'on supprimait) ; stdout/stderr sont désormais capturés dans le message
d'erreur, et le `.log` est conservé en cas d'échec.
## [1.0.8-rc15] - unstable ## [1.0.8-rc15] - unstable
### Added ### Added
@ -369,6 +445,8 @@ the ACL now comes along with the article,
the same user-visible switch without a schema change. the same user-visible switch without a schema change.
[Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD [Unreleased]: https://forgejo.pschneider.fr/notazof/yavsc/compare/HEAD
[1.0.8-rc16]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc15...1.0.8-rc16
[1.0.8-rc15]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.8-rc14...1.0.8-rc15
[1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1 [1.0.8-rc1]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.7...1.0.8-rc1
[1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7 [1.0.7]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.6...1.0.7
[1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6 [1.0.6]: https://forgejo.pschneider.fr/notazof/yavsc/compare/1.0.5...1.0.6

View file

@ -4,53 +4,71 @@
<IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version> <IdentityServer8Version>8.1.0-pazofrc007</IdentityServer8Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.9" /> <PackageVersion Include="DotMarkdown" Version="0.3.0" />
<PackageVersion Include="Markdig" Version="1.3.2" />
<PackageVersion Include="System.Security.Cryptography.Pkcs" Version="10.0.12" />
<PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.AspNetIdentity" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.EntityFramework.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.Security" Version="$(IdentityServer8Version)" />
<PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" /> <PackageVersion Include="HigginsSoft.IdentityServer8.Storage" Version="$(IdentityServer8Version)" />
<PackageVersion Include="BouncyCastle.Cryptography" Version="2.7.0" />
<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="Google.Apis.Compute.v1" Version="1.74.0.4138" />
<PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Antiforgery" Version="2.3.11" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Authentication.Google" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Diagnostics.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.NewtonsoftJson" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.11" /> <PackageVersion Include="Microsoft.AspNetCore.SignalR" Version="1.2.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.11" /> <PackageVersion Include="Microsoft.AspNetCore.Hosting" Version="2.3.13" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Identity.UI" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.0" /> <PackageVersion Include="Microsoft.AspNetCore.Razor" Version="2.3.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.9" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.12" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.12" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
<PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" /> <PackageVersion Include="Microsoft.VisualStudio.Web.CodeGeneration.Design" Version="10.0.2" />
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.12" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.2" /> <PackageVersion Include="Microsoft.IdentityModel.Tokens" Version="8.23.0" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.12" />
<PackageVersion Include="Microsoft.AspNetCore.Razor.Language" Version="6.0.36" />
<PackageVersion Include="Microsoft.Extensions.CommandLineUtils" Version="1.1.1" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.12" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="coverlet.collector" Version="10.0.1" /> <PackageVersion Include="coverlet.collector" Version="10.0.1" />
<PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" /> <PackageVersion Include="IdentityModel.OidcClient" Version="6.0.0" />
<PackageVersion Include="System.IdentityModel.Tokens.Jwt" Version="8.23.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.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" />
<PackageVersion Include="Anthropic.SDK" Version="5.10.0" />
<PackageVersion Include="Google.Apis.Calendar.v3" Version="1.75.0.4206" />
<PackageVersion Include="Magick.NET-Q8-AnyCPU" Version="14.17.1" />
<PackageVersion Include="MailKit" Version="4.18.0" />
<PackageVersion Include="Microsoft.AspNetCore.Http.Features" Version="5.0.17" />
<PackageVersion Include="Microsoft.AspNetCore.StaticFiles" Version="2.3.13" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="10.0.12" />
<PackageVersion Include="Microsoft.Testing.Platform.MSBuild" Version="2.4.1" />
<PackageVersion Include="MimeKit" Version="4.18.1" />
<PackageVersion Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.3" />
<PackageVersion Include="PayPalMerchantSDK" Version="2.16.250" />
<PackageVersion Include="pazof.rules" Version="1.1.3" />
<PackageVersion Include="RazorEngine.NetCore" Version="3.1.1" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -13,6 +13,10 @@ all:
clean: clean:
dotnet clean -c $(CONFIG) dotnet clean -c $(CONFIG)
brutal_clean:
@find -name "obj" -exec rm -rf {} \;
@find -name "bin" -exec rm -rf {} \;
src/Yavsc/bin/output/wwwroot: src/Yavsc/bin/output/wwwroot:
dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish dotnet --project src/Yavsc.Org/Yavsc.Org.csproj publish

View file

@ -61,9 +61,40 @@ make test
### les services et l'API ### les services et l'API
#### Dépendances système au runtime
La génération des devis et factures en PDF (`/api/v1/front/query/{id}/estimate.pdf`,
`BillingController` → `BillViewComponent`) compile un source LaTeX via la commande
système `lualatex` : le TeX est fourni sur l'entrée standard et le PDF est écrit
directement dans le répertoire des factures (`Site.Bills`). `lualatex` doit donc
être installé et opérationnel sur l'hôte qui exécute l'API — typiquement le service
systemd `yavscApi`.
Sous Debian/Ubuntu :
```bash
sudo apt install texlive-binaries texlive-luatex texlive-pictures texlive-fonts-extra
```
- `texlive-binaries` fournit l'exécutable `lualatex` (collection basic).
- `texlive-luatex` fournit les paquets de support chargés au démarrage de lualatex,
notamment `luaotfload`. Sans lui, lualatex démarre mais s'arrête sur
`module 'luaotfload-main' not found … Emergency stop` et ne produit aucun PDF.
(Sur les Debian récents, `luaotfload` a migré de `texlive-latex-extra` vers
`texlive-luatex` — avoir `texlive-latex-extra` seul ne suffit plus.)
- `texlive-pictures` fournit `tikz`/`pgf`, utilisé pour dessiner les signatures
du client et du fournisseur en bas du devis (vecteur, pas de PNG).
- `texlive-fonts-extra` fournit la fonte `bera` chargée par le modèle de devis.
Sans elle, lualatex s'arrête sur `! LaTeX Error: File 'bera.sty' not found`
(`Emergency stop`, aucun PDF).
Sur un hôte sans `lualatex`, les endpoints PDF renvoient une erreur 500 « PDF
generation failed » ; l'endpoint TeX (`estimate.tex`) reste utilisable et permet
de compiler le document hors-ligne.
### La Prod ### La Prod
`cd srv/Yavsc` : `make pushInProd CONFIGURATION=Release`. `cd srv/Yavsc/contrib` : `make reinstall CONFIGURATION=Release APP_INSTALL_ENV=production`.
puis, pour une première installation puis, pour une première installation
`make install_service`. `make install_service`.

View file

@ -1,23 +1,32 @@
DESTDIR=
DOTNET_FRAMEWORK=net10.0
APP_PROJECT_NAMES=Org Blogs Api APP_PROJECT_NAMES=Org Blogs Api
SLNDIR=.. SLNDIR=..
APP_INSTALL_ENV=preprod
APP_INSTALL_PREFIX=preprod-
include $(SLNDIR)/.env include .$(APP_INSTALL_ENV).env
include .env
generated/: reinstall: copy-binaries
@mkdir -p $@ @for project in $(APP_PROJECT_NAMES); do \
echo "🚀 Starting $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; done
@echo "💯 backend started"
generated/yavscOrg.service: reinstall-prod:
generated/yavscBlogs.service: make reinstall APP_INSTALL_ENV= APP_INSTALL_PREFIX= APP_INSTALL_ENV=production
generated/yavscApi.service:
generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env generated/$(APP_INSTALL_PREFIX)yavscOrg.service:
generated/$(APP_INSTALL_PREFIX)yavscBlogs.service:
generated/$(APP_INSTALL_PREFIX)yavscApi.service:
generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLNDIR)/.env
@cat template.service | APP_NAME="$*" \ @cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \ DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \ HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$*_$(HTTP_PORT)" \ HTTP_PORT="$*_$(HTTP_PORT)" \
BASEAPPDIR="$(BASEAPPDIR)" \ APPDIR="$(APPDIR)" \
APP_SYSLOG_ID="yavsc$*" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
POSTGRES_HOST="$(POSTGRES_HOST)" \ POSTGRES_HOST="$(POSTGRES_HOST)" \
POSTGRES_PORT="$(POSTGRES_PORT)" \ POSTGRES_PORT="$(POSTGRES_PORT)" \
POSTGRES_DB="$(POSTGRES_DB)" \ POSTGRES_DB="$(POSTGRES_DB)" \
@ -34,11 +43,13 @@ generated/yavsc%.service: generated/ template.service $(SLNDIR)/.env
envsubst > $@ envsubst > $@
@echo Created service file: $@ @echo Created service file: $@
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
copy-services: copy-service-Org copy-service-Blogs copy-service-Api install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
copy-service-Org: /etc/systemd/system/yavscOrg.service sudo systemctl daemon-reload
copy-service-Blogs: /etc/systemd/system/yavscBlogs.service sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*
copy-service-Api: /etc/systemd/system/yavscApi.service
install-services: install-service-Org install-service-Blogs install-service-Api
copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-services
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
@ -52,39 +63,36 @@ copy-binaries: build_publish_Org build_publish_Blogs build_publish_Api stop-serv
rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \ rm -f $${BINDIR}/appsettings-$${LCAPI}.json ; \
sync ; \ sync ; \
ls $${BINDIR}/appsettings* 2>&1 ; \ ls $${BINDIR}/appsettings* 2>&1 ; \
sudo cp -a $${BINDIR}/* $(BASEAPPDIR) ; \ sudo cp -a $${BINDIR}/* $(DESTDIR)$(APPDIR) ; \
done done
@sudo chown -R $(USER_AND_GROUP) $(BASEAPPDIR) @sudo chown -R $(USER_AND_GROUP) $(DESTDIR)$(APPDIR)
@sync
/etc/systemd/system/yavsc%.service: generated/yavsc%.service $(DESTDIR)/etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service: generated/$(APP_INSTALL_PREFIX)yavsc%.service
sudo cp $^ $@ sudo cp $^ $@
sudo chown root:root $@ sudo chown root:root $@
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
install: build_publish copy-binaries copy-services install: copy-binaries copy-services
@sudo systemctl daemon-reload @sudo systemctl daemon-reload
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do \ do \
sudo systemctl enable yavsc$${project} ; \ echo "🖒 enable $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start yavsc$${project} ; \ sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$${project} ; \
echo "🚀 start $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl start $(APP_INSTALL_PREFIX)yavsc$${project} ; \
done done
reinstall: copy-binaries
@sync
@for project in $(APP_PROJECT_NAMES); do \
sudo systemctl start yavsc$${project} ; done
stop-services: stop-services:
@for project in $(APP_PROJECT_NAMES); \ @for project in $(APP_PROJECT_NAMES); \
do \ do \
sudo systemctl stop yavsc$${project} ; \ echo "🔥 Stopping $(APP_INSTALL_PREFIX)yavsc$${project}" ; \
sudo systemctl stop $(APP_INSTALL_PREFIX)yavsc$${project} ; \
done done
$(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish $(SLNDIR)/src/Yavsc.Org/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_publish
@ -93,7 +101,7 @@ $(SLNDIR)/src/Yavsc.Api/bin/$(CONFIGURATION)/$(DOTNET_FRAMEWORK)/publish: build_
showConfig: showConfig:
@echo CONFIGURATION: $(CONFIGURATION) @echo CONFIGURATION: $(CONFIGURATION)
@echo BASEAPPDIR: $(BASEAPPDIR) @echo APPDIR: $(APPDIR)
showApiLogs: showApiLogs:
@sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log @sudo journalctl -u yavscApi.service -S "2 min ago" | tee yavscApi.log
@ -107,3 +115,5 @@ showBlogsLogs:
clean: clean:
@rm -rf generated @rm -rf generated
generated/:
@mkdir -p $@

View file

@ -10,10 +10,10 @@ RestartSec=5s
Type=simple Type=simple
User=yavsc User=yavsc
Group=yavsc Group=yavsc
WorkingDirectory=$DESTDIR WorkingDirectory=$APPDIR
ExecStart=$DESTDIR/Yavsc.$APP_NAME ExecStart=$APPDIR/Yavsc.$APP_NAME
Restart=always Restart=always
Environment="HOME=$DESTDIR" Environment="HOME=$APPDIR"
Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY" Environment="ANTHROPIC_API_KEY=$ANTHROPIC_API_KEY"
Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS" Environment="ANTHROPIC_MAX_TOKENS=$ANTHROPIC_MAX_TOKENS"
Environment="ASPNETCORE_Environment=$ENV" Environment="ASPNETCORE_Environment=$ENV"
@ -29,8 +29,6 @@ Environment="ASPNETCORE_Smtp__Password=\"$ASPNETCORE_Smtp__Password\""
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE AmbientCapabilities=CAP_NET_BIND_SERVICE
StandardOutput=syslog
StandardError=syslog
SyslogIdentifier=$APP_SYSLOG_ID SyslogIdentifier=$APP_SYSLOG_ID
[Install] [Install]

View file

@ -1,54 +0,0 @@
server {
if ($host = yavsc.pschneider.fr) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name yavsc.pschneider.fr;
location / {
root /srv/www/yavsc/wwwroot;
dav_methods PUT;
proxy_pass http://127.0.0.1:3003;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection keep-alive;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
server {
listen 443 ssl;
server_name yavsc.pschneider.fr;
ssl_certificate /etc/letsencrypt/live/yavsc.pschneider.fr-0001/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/yavsc.pschneider.fr-0001/privkey.pem; # managed by Certbot
# Configure the SignalR Endpoint
location / {
# App server url
proxy_pass http://localhost:3003;
proxy_cache off;
# WebSockets were implemented after http/1.0
proxy_http_version 1.1;
# Configuration for ServerSentEvents
proxy_buffering off;
# Configuration for LongPolling or if your KeepAliveInterval is longer than 60 seconds
proxy_read_timeout 100s;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}

5
global.json Normal file
View file

@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}

View file

@ -4,28 +4,31 @@
<!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management --> <!-- https://learn.microsoft.com/en-us/nuget/consume-packages/central-package-management -->
<PropertyGroup> <PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AvaloniaVersionBase>12.1.1</AvaloniaVersionBase> <AvaloniaVersionBase>12.1.2</AvaloniaVersionBase>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.AvaloniaEdit" Version="12.0.0" />
<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)" />
<PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Browser" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Android" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Headless" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Headless" Version="12.1.2" />
<PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Headless.Xunit" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" /> <PackageVersion Include="Avalonia.Fonts.Inter" Version="$(AvaloniaVersionBase)" />
<PackageVersion Include="Mapsui" Version="5.1.0" /> <PackageVersion Include="Mapsui" Version="5.1.0" />
<PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" /> <PackageVersion Include="Mapsui.Avalonia12" Version="5.1.0" />
<PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" /> <PackageVersion Include="MarkdownViewer.Core" Version="1.0.5" />
<PackageVersion Include="Material.Avalonia" Version="3.19.0" /> <PackageVersion Include="Material.Avalonia" Version="3.20.0" />
<PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" /> <PackageVersion Include="AvaloniaUI.DiagnosticsSupport" Version="2.2.3" />
<PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" /> <PackageVersion Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageVersion Include="Mono.Options" Version="6.12.0.148" />
<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.3" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Runtime" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.10.0.1" /> <PackageVersion Include="Xamarin.AndroidX.Lifecycle.Common" Version="2.11.0.1" />
<PackageVersion Include="Xamarin.AndroidX.Lifecycle.Process" Version="2.11.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.12" />
</ItemGroup> </ItemGroup>
</Project> </Project>

View file

@ -11,8 +11,8 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AndroidResource Include="Icon.png"> <AndroidResource Include="Icon.png">

View file

@ -6,8 +6,8 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Browser" /> <PackageReference Include="Avalonia.Browser" />

View file

@ -7,8 +7,8 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
<ApplicationManifest>app.manifest</ApplicationManifest> <ApplicationManifest>app.manifest</ApplicationManifest>

View file

@ -12,6 +12,8 @@ sealed class Program
[STAThread] [STAThread]
public static void Main(string[] args) public static void Main(string[] args)
{ {
CLIOptionHandler.HandleArgs(args);
// Short-circuit 2nd-instance launches (OS handing us the // Short-circuit 2nd-instance launches (OS handing us the
// postit://callback URL) BEFORE Avalonia spins up a window. // postit://callback URL) BEFORE Avalonia spins up a window.
// If we let Avalonia initialise, the new MainWindow flashes // If we let Avalonia initialise, the new MainWindow flashes

View file

@ -11,12 +11,13 @@ public class EstimateListPageViewModelTests
{ {
var api = new StubEstimateListApi(); var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync(); await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths); Assert.Contains("https://business.example/api/v1/estimate/asclient", api.Paths);
Assert.Equal("Mes devis à valider", vm.Title); Assert.Equal("Devis en attente de signature client", vm.Title);
Assert.Equal(2, vm.Estimates.Count); Assert.Equal(2, vm.Estimates.Count);
// Most recent first // Most recent first
Assert.Equal(2, vm.Estimates[0].Id); Assert.Equal(2, vm.Estimates[0].Id);
@ -28,12 +29,13 @@ public class EstimateListPageViewModelTests
{ {
var api = new StubEstimateListApi(); var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync(); await vm.InitializeAsync();
Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths); Assert.Contains("https://business.example/api/v1/estimate/asprovider", api.Paths);
Assert.Equal("Mes devis en attente", vm.Title); Assert.Equal("Devis en attente de signature fournisseur", vm.Title);
Assert.Equal(2, vm.Estimates.Count); Assert.Equal(2, vm.Estimates.Count);
} }
@ -42,7 +44,8 @@ public class EstimateListPageViewModelTests
{ {
var api = new StubEstimateListApi(); var api = new StubEstimateListApi();
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider); var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Provider, front);
await vm.InitializeAsync(); await vm.InitializeAsync();
@ -63,7 +66,8 @@ public class EstimateListPageViewModelTests
{ {
var api = new StubEstimateListApi { Empty = true }; var api = new StubEstimateListApi { Empty = true };
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client); var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateListPageViewModel(client, EstimateListPerspective.Client, front);
await vm.InitializeAsync(); await vm.InitializeAsync();

View file

@ -0,0 +1,255 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using PostIt.Controls;
using PostIt.ViewModels;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.Tests;
/// <summary>
/// Verifies <see cref="EstimateValidationPageViewModel"/> wires the two
/// signature pads (provider + client) into the right
/// <see cref="FrontOfficeApiClient"/> call. The view (XAML, rendering)
/// is not tested here; the VM is driven through the same
/// <see cref="SignaturePadControl"/> test hooks the signature tests use,
/// and the API client through a recording fake
/// <see cref="IYavscApiClient"/>. The test host's
/// <c>TestApp</c> makes <c>GoBackAsync</c> a no-op, so the success paths
/// run to completion without navigation side effects.
/// </summary>
public class EstimateValidationPageViewModelTests
{
private static EstimateDto SampleEstimate(long? commandId = 42) => new()
{
Id = 7,
CommandId = commandId,
CommandType = "Rdv",
Title = "Devis plomberie",
Description = "Réparation",
ClientId = "bob",
OwnerId = "alice",
};
private static void DrawOneStroke(SignaturePadControl pad)
{
pad.AppendPointForTest(100, 200);
pad.AppendPointForTest(300, 400);
pad.SealStrokeForTest();
}
[Fact]
public async Task Validate_calls_accept_with_strokes_and_command_id()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var clientPad = new SignaturePadControl();
vm.Attach(proPad, clientPad);
DrawOneStroke(proPad); // provider signs on their writable pad
await vm.ValidateAsync();
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Contains("front/query/accept", api.LastPath);
Assert.Contains("billingCode=Rdv", api.LastPath);
Assert.Contains("queryId=42", api.LastPath);
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
Assert.NotNull(body.Strokes);
Assert.NotEmpty(body.Strokes);
Assert.Equal(10_000, body.CoordinateMax);
// The provider perspective declares the Pro side, so the
// server stores the signature under the provider's author
// regardless of role inference.
Assert.Equal(SignatureType.Pro, body.SignatureType);
}
[Fact]
public async Task Validate_from_client_perspective_declares_client_side()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
var cliPad = new SignaturePadControl();
vm.Attach(new SignaturePadControl(), cliPad);
DrawOneStroke(cliPad); // client signs on their writable pad
await vm.ValidateAsync();
var body = Assert.IsType<QueryAcceptanceRequestDto>(api.LastBody);
Assert.Equal(SignatureType.Client, body.SignatureType); // Client side
}
[Fact]
public async Task Reject_calls_reject_endpoint_with_no_body()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
vm.Attach(new SignaturePadControl(), new SignaturePadControl());
await vm.RejectAsync();
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Contains("front/query/reject", api.LastPath);
Assert.Contains("queryId=42", api.LastPath);
Assert.Null(api.LastBody);
}
[Fact]
public async Task Validate_without_signature_warns_and_does_not_call()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
vm.Attach(new SignaturePadControl(), new SignaturePadControl()); // empty pads
await vm.ValidateAsync(); // no throw: returns before GoBack
Assert.Null(api.LastMethod);
Assert.Contains("signer", vm.StatusMessage, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Validate_command_gates_on_signature_and_command()
{
var api = new RecordingApi();
var front = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var vm = new EstimateValidationPageViewModel(
SampleEstimate(commandId: null), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
vm.Attach(proPad, new SignaturePadControl());
// No CommandId ⇒ cannot validate even after signing.
DrawOneStroke(proPad);
Assert.False(vm.ValidateCommand.CanExecute(null));
var vm2 = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad2 = new SignaturePadControl();
vm2.Attach(proPad2, new SignaturePadControl());
Assert.False(vm2.ValidateCommand.CanExecute(null)); // no signature yet
DrawOneStroke(proPad2);
Assert.True(vm2.ValidateCommand.CanExecute(null));
}
[Fact]
public void RoleLabel_reflects_perspective()
{
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var pro = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var cli = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
Assert.Contains("prestataire", pro.RoleLabel, StringComparison.OrdinalIgnoreCase);
Assert.Contains("client", cli.RoleLabel, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void Only_the_author_pad_is_writable()
{
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var proVm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
proVm.Attach(proPad, cliPad);
Assert.False(proPad.IsReadOnly); // provider writes the Pro pad
Assert.True(cliPad.IsReadOnly);
var cliVm = new EstimateValidationPageViewModel(
SampleEstimate(), EstimateListPerspective.Client, front);
var proPad2 = new SignaturePadControl();
var cliPad2 = new SignaturePadControl();
cliVm.Attach(proPad2, cliPad2);
Assert.True(proPad2.IsReadOnly);
Assert.False(cliPad2.IsReadOnly); // client writes the Client pad
}
[Fact]
public void Load_existing_signatures_renders_both_sides_in_their_pads()
{
var proStrokes = new[] { 1, 1000, 1000 };
var cliStrokes = new[] { 1, 9000, 9000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.SignaturePro = new EstimateSignatureDto { Strokes = proStrokes };
estimate.SignatureClient = new EstimateSignatureDto { Strokes = cliStrokes };
// Client perspective: both pads show the stored signatures, but
// only the client pad is writable.
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Client, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
vm.Attach(proPad, cliPad);
Assert.Equal(proStrokes, proPad.Snapshot().Strokes);
Assert.Equal(cliStrokes, cliPad.Snapshot().Strokes);
Assert.True(vm.HasProSignature);
Assert.True(vm.HasClientSignature);
}
[Fact]
public void Load_existing_pro_signature_renders_in_pro_pad_only()
{
var strokes = new[] { 1, 5000, 5000 };
var front = new FrontOfficeApiClient(new RecordingApi(), "https://business.example/api/v1/");
var estimate = SampleEstimate();
estimate.SignaturePro = new EstimateSignatureDto { Strokes = strokes, CoordinateMax = 10_000 };
var vm = new EstimateValidationPageViewModel(estimate, EstimateListPerspective.Provider, front);
var proPad = new SignaturePadControl();
var cliPad = new SignaturePadControl();
vm.Attach(proPad, cliPad);
Assert.Equal(strokes, proPad.Snapshot().Strokes);
Assert.True(cliPad.Snapshot().IsEmpty);
Assert.True(vm.HasProSignature);
Assert.False(vm.HasClientSignature);
}
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 Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
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 Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -0,0 +1,157 @@
using System.Net.Http;
using Yavsc.Api.Client;
namespace PostIt.Tests;
/// <summary>
/// Verifies <see cref="FrontOfficeApiClient"/> issues the right
/// <c>api/v1/front/query/accept</c> / <c>.../reject</c> requests with
/// the PostIt signature payload, using a recording fake
/// <see cref="IYavscApiClient"/> (no network).
/// </summary>
public class FrontOfficeApiClientTests
{
private static QueryAcceptanceRequestDto SampleBody() => new()
{
Strokes = new[] { 2, 100, 200, 300, 400 },
CoordinateMax = 10_000,
CapturedAtUtc = new DateTime(2026, 9, 21, 12, 0, 0, DateTimeKind.Utc),
};
[Fact]
public async Task Accept_posts_to_accept_endpoint_with_signature_body()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var body = SampleBody();
await client.AcceptQueryAsync("Rdv", 42, body);
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/accept?billingCode=Rdv&queryId=42",
api.LastPath);
Assert.Same(body, api.LastBody);
}
[Fact]
public async Task Reject_posts_to_reject_endpoint_with_no_body()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.RejectQueryAsync("Rdv", 42);
Assert.Equal(HttpMethod.Post, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/reject?billingCode=Rdv&queryId=42",
api.LastPath);
Assert.Null(api.LastBody);
}
[Fact]
public async Task Accept_returns_deserialized_response()
{
var api = new RecordingApi
{
Response = new QueryAcceptanceResponseDto
{
QueryId = 42,
Status = "Accepted",
Signature = new QuerySignatureRefDto { Id = 9, EstimateId = 5, Type = "Pro" },
},
};
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
var response = await client.AcceptQueryAsync("Rdv", 42, SampleBody());
Assert.Equal(42, response.QueryId);
Assert.Equal("Accepted", response.Status);
Assert.NotNull(response.Signature);
Assert.Equal("Pro", response.Signature!.Type);
}
[Fact]
public async Task Accept_rejects_invalid_arguments()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await Assert.ThrowsAsync<ArgumentException>(() => client.AcceptQueryAsync("", 42, SampleBody()));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.AcceptQueryAsync("Rdv", 0, SampleBody()));
await Assert.ThrowsAsync<ArgumentNullException>(() => client.AcceptQueryAsync("Rdv", 42, null!));
await Assert.ThrowsAsync<ArgumentException>(() => client.RejectQueryAsync("", 42));
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() => client.RejectQueryAsync("Rdv", 0));
}
[Fact]
public async Task GetEstimatePdf_gets_estimate_pdf_endpoint()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.GetEstimatePdfAsync(42);
Assert.Equal(HttpMethod.Get, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/42/estimate.pdf",
api.LastPath);
}
[Fact]
public async Task GetEstimateTex_gets_estimate_tex_endpoint()
{
var api = new RecordingApi();
var client = new FrontOfficeApiClient(api, "https://business.example/api/v1/");
await client.GetEstimateTexAsync(42);
Assert.Equal(HttpMethod.Get, api.LastMethod);
Assert.Equal(
"https://business.example/api/v1/front/query/42/estimate.tex",
api.LastPath);
}
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 QueryAcceptanceResponseDto? Response { get; set; }
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
var response = Response ?? new QueryAcceptanceResponseDto();
return Task.FromResult((T)(object)response);
}
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask;
}
public Task<T> CallAsync<T>(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync<T>(method, path, (object?)null, ct);
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
LastMethod = method;
LastPath = path;
LastBody = null;
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
}
}

View file

@ -8,14 +8,13 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Xamarin.UITest" /> <PackageReference Include="Xamarin.UITest" />
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.runner.visualstudio" />
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
<PackageReference Include="Avalonia.Headless" /> <PackageReference Include="Avalonia.Headless" />
<PackageReference Include="Avalonia.Headless.XUnit" /> <PackageReference Include="Avalonia.Headless.XUnit" />

View file

@ -11,5 +11,6 @@
<Application.Styles> <Application.Styles>
<FluentTheme /> <FluentTheme />
<StyleInclude Source="avares://AvaloniaEdit/Themes/Fluent/AvaloniaEdit.xaml" />
</Application.Styles> </Application.Styles>
</Application> </Application>

View file

@ -17,6 +17,7 @@ namespace PostIt;
public partial class App : Application public partial class App : Application
{ {
private int _bootStarted; private int _bootStarted;
public string? CliConfigFileSpecification {  get; private set; }
/// <summary> /// <summary>
/// DI container the platform entry points hand to ViewModels so /// DI container the platform entry points hand to ViewModels so
@ -203,4 +204,9 @@ public partial class App : Application
{ {
await View!.NavRoot.PopAsync(); await View!.NavRoot.PopAsync();
} }
internal void UseConfigFileWhenLoading(string configFile)
{
this.CliConfigFileSpecification = configFile;
}
} }

View file

@ -0,0 +1,33 @@
using System;
using System.Collections.Generic;
using Mono.Options;
using PostIt;
using PostIt.ViewModels;
public static class CLIOptionHandler
{
public static void HandleArgs(string[] args)
{
bool showHelp = false;
string configFile = null;
var options = new OptionSet {
{ "c|configuration=", "Configuration file to load", (string v) => configFile = v },
{ "h|help", "Show this message and exit", v => showHelp = true }
};
List<string> extra = options.Parse(args);
if (showHelp)
{
Console.WriteLine("Usage: app [OPTIONS]+");
options.WriteOptionDescriptions(Console.Out);
return;
}
if (configFile != null)
{
var app = App.Current as App;
app.UseConfigFileWhenLoading(configFile);
}
}
}

View file

@ -46,6 +46,21 @@ public class SignaturePadControl : TemplatedControl
set => SetValue(CaptureAreaProperty, value); set => SetValue(CaptureAreaProperty, value);
} }
/// <summary>
/// When true the control ignores pointer input — it only displays
/// strokes loaded into its buffer (e.g. the other party's
/// signature, which must be shown but not editable). <see cref="Load"/>
/// and <see cref="Clear"/> still work.
/// </summary>
public static readonly StyledProperty<bool> IsReadOnlyProperty =
AvaloniaProperty.Register<SignaturePadControl, bool>(nameof(IsReadOnly));
public bool IsReadOnly
{
get => GetValue(IsReadOnlyProperty);
set => SetValue(IsReadOnlyProperty, value);
}
/// <summary> /// <summary>
/// Captured strokes in wire form. Exposed as a read-only view /// Captured strokes in wire form. Exposed as a read-only view
/// over the internal buffer. The buffer only mutates on the UI /// over the internal buffer. The buffer only mutates on the UI
@ -109,6 +124,7 @@ public class SignaturePadControl : TemplatedControl
private void OnCapturePressed(object? sender, PointerPressedEventArgs e) private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
{ {
if (IsReadOnly) return;
if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return; if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
e.Pointer.Capture(CaptureArea); e.Pointer.Capture(CaptureArea);
_capturing = true; _capturing = true;
@ -181,6 +197,38 @@ public class SignaturePadControl : TemplatedControl
RedrawRequested?.Invoke(this, EventArgs.Empty); RedrawRequested?.Invoke(this, EventArgs.Empty);
} }
/// <summary>
/// Replace the buffer with the given wire-format strokes — e.g. a
/// signature loaded back from the server — and raise
/// <see cref="RedrawRequested"/> so the host view repaints the ink.
/// Loaded strokes are all sealed (there is no pending stroke).
/// Malformed input (a non-positive length prefix, or a stroke that
/// runs past the end of the array) is truncated at the offending
/// point so the buffer never holds an inconsistent state.
/// </summary>
public void Load(int[] strokes)
{
if (strokes is null) throw new ArgumentNullException(nameof(strokes));
_strokes.Clear();
_pendingPoints = 0;
_capturing = false;
int i = 0;
while (i < strokes.Length)
{
int k = strokes[i];
if (k <= 0) break;
if (i + 1 + 2 * k > strokes.Length) break;
_strokes.Add(k);
for (int p = 0; p < 2 * k; p++)
_strokes.Add(strokes[i + 1 + p]);
i += 1 + 2 * k;
}
RedrawRequested?.Invoke(this, EventArgs.Empty);
}
/// <summary> /// <summary>
/// Defensive copy of the current buffer wrapped in a /// Defensive copy of the current buffer wrapped in a
/// <see cref="SignaturePadData"/>. Cheap; call only when the /// <see cref="SignaturePadData"/>. Cheap; call only when the

View file

@ -0,0 +1,28 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:controls="using:PostIt.Controls"
x:Class="PostIt.Controls.SignaturePadView"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<!--
Capture surface. The SignaturePadControl is render-agnostic,
so we host it inside a Border that is the capture area, and
overlay a Canvas for the visual feedback. The code-behind
repaints the canvas on every RedrawRequested. The Border size
is driven by the CaptureWidth / CaptureHeight styled properties.
-->
<Border x:Name="PadFrame"
BorderBrush="Black"
BorderThickness="1"
Background="White"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True">
<Grid>
<controls:SignaturePadControl x:Name="InnerPad" />
<Canvas x:Name="InkLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
</UserControl>

View file

@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Models;
namespace PostIt.Controls;
/// <summary>
/// Reusable signature-capture surface: wraps the render-agnostic
/// <see cref="SignaturePadControl"/> in a sized <see cref="Border"/>
/// (the hit-test region) with an overlay <see cref="Canvas"/> that
/// the code-behind repaints from the control's stroke buffer. Owns
/// the ink-rendering logic so every page that captures a signature
/// (the dev-only <c>SignaturePage</c>, the estimate validation page,
/// ...) shares one implementation.
///
/// Hosts bind <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>
/// (DIPs, defaults 600×200) to size the surface. View models attach
/// to the inner <see cref="SignaturePad"/> control (exposed here) to
/// read snapshots, drive <c>Clear</c>, and subscribe to
/// <see cref="SignaturePadControl.StrokeCompleted"/> /
/// <see cref="SignaturePadControl.RedrawRequested"/> — the same
/// contract the headless tests use, since a <see cref="UserControl"/>
/// needs its XAML loaded to be drivable.
/// </summary>
public partial class SignaturePadView : UserControl
{
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
private const double StrokeThickness = 2.0;
private const double CoordinateMax = 10_000.0;
// Last size the ink layer was painted at. LayoutUpdated repaints
// only when this changes, so adding/removing polyline children
// (which invalidates layout) does not loop.
private Size _lastPaintedSize;
public static readonly StyledProperty<double> CaptureWidthProperty =
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureWidth), 600);
public static readonly StyledProperty<double> CaptureHeightProperty =
AvaloniaProperty.Register<SignaturePadView, double>(nameof(CaptureHeight), 200);
public double CaptureWidth
{
get => GetValue(CaptureWidthProperty);
set => SetValue(CaptureWidthProperty, value);
}
public double CaptureHeight
{
get => GetValue(CaptureHeightProperty);
set => SetValue(CaptureHeightProperty, value);
}
/// <summary>
/// The underlying capture control. View models attach to this to
/// read <see cref="SignaturePadControl.Snapshot"/> / call
/// <see cref="SignaturePadControl.Clear"/> and subscribe to its
/// events; this view repaints the ink layer from the same buffer.
/// </summary>
public SignaturePadControl SignaturePad => InnerPad;
public SignaturePadView()
{
InitializeComponent();
// Wire the capture area: the Pad is the control, the
// surrounding Border (PadFrame) is the hit-test region. For an
// inline control with no template, capture wiring happens on
// first measure, which is guaranteed before the user can
// interact, so attaching here is safe.
InnerPad.CaptureArea = PadFrame;
ApplyFrameSize();
InnerPad.RedrawRequested += OnPadRedrawRequested;
// Repaint once layout completes so an ink buffer loaded before
// the first arrange (e.g. an existing signature on reopen)
// still renders without waiting for a pointer event. The
// handler repaints only on a size change to avoid a layout
// loop (child changes invalidate layout → LayoutUpdated).
LayoutUpdated += OnLayoutUpdated;
}
private void OnLayoutUpdated(object? sender, EventArgs e)
{
var size = EffectivePadSize();
if (size != _lastPaintedSize)
Repaint();
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
base.OnPropertyChanged(change);
if (change.Property == CaptureWidthProperty || change.Property == CaptureHeightProperty)
{
ApplyFrameSize();
Repaint();
}
}
private void ApplyFrameSize()
{
if (PadFrame is null) return;
PadFrame.Width = Math.Max(1, CaptureWidth);
PadFrame.Height = Math.Max(1, CaptureHeight);
}
private void OnPadRedrawRequested(object? sender, EventArgs e) => Repaint();
/// <summary>Defensive copy of the current buffer.</summary>
public SignaturePadData Snapshot() => InnerPad.Snapshot();
/// <summary>Forget every captured stroke.</summary>
public void Clear() => InnerPad.Clear();
/// <summary>Wire-format strokes, forwarded from the inner control.</summary>
public IReadOnlyList<int> Strokes => InnerPad.Strokes;
/// <summary>
/// The effective ink surface size: the arranged bounds when
/// available, otherwise the explicitly sized
/// <see cref="CaptureWidth"/>/<see cref="CaptureHeight"/>. The
/// latter covers a repaint triggered before the first layout pass
/// (e.g. loading an existing signature on reopen), when Bounds is
/// still empty.
/// </summary>
private Size EffectivePadSize()
{
var w = PadFrame.Bounds.Width > 0 ? PadFrame.Bounds.Width : PadFrame.Width;
var h = PadFrame.Bounds.Height > 0 ? PadFrame.Bounds.Height : PadFrame.Height;
if (w <= 0 || double.IsNaN(w) || h <= 0 || double.IsNaN(h))
return default;
return new Size(w, h);
}
private void Repaint()
{
if (InkLayer is null) return;
var size = EffectivePadSize();
if (size == default) return;
_lastPaintedSize = size;
InkLayer.Children.Clear();
var w = size.Width;
var h = size.Height;
var pending = InnerPad.PendingStroke;
var strokes = InnerPad.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0;
while (i < sealedCount)
{
int k = strokes[i];
if (k <= 0) break;
i++; // skip the length prefix
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(k);
for (int p = 0; p < k; p++)
{
int nx = strokes[i++];
int ny = strokes[i++];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
if (pending.Count > 0)
{
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<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

@ -0,0 +1,61 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
namespace PostIt.Helpers;
/// <summary>
/// Saves a downloaded byte payload to a file chosen by the user through
/// the platform save picker. This is the first file-save (as opposed to
/// file-open) helper in PostIt; the open picker is already used for blog
/// attachment and avatar uploads
/// (<see cref="Views.Blogs.BlogsPage"/>, <see cref="Views.Layout.SettingsPage"/>).
///
/// <para>The <c>TopLevel</c> hosting the picker is resolved from the
/// application's root <c>MainView</c> (<c>App.View</c>), which works for
/// both the desktop and single-view lifetimes.</para>
/// </summary>
public static class FileSaveHelpers
{
/// <summary>
/// Prompt the user to choose a destination and write
/// <paramref name="content"/> to it. Returns false when the user
/// cancels the picker; throws on IO/platform errors so the caller
/// can surface them.
/// </summary>
public static async Task<bool> SaveAsync(
string suggestedName,
string defaultExtension,
byte[] content,
CancellationToken ct = default)
{
var view = (Application.Current as App)?.View;
var topLevel = view is null ? null : TopLevel.GetTopLevel(view);
if (topLevel is null)
throw new InvalidOperationException("Aucune fenêtre active pour enregistrer le fichier.");
var file = await topLevel.StorageProvider.SaveFilePickerAsync(new FilePickerSaveOptions
{
Title = "Enregistrer sous",
DefaultExtension = defaultExtension,
SuggestedFileName = suggestedName,
FileTypeChoices = new[]
{
new FilePickerFileType($"{defaultExtension.ToUpper()} file")
{
Patterns = new[] { $"*.{defaultExtension}" }
}
}
}).ConfigureAwait(true);
if (file is null)
return false; // user cancelled
await using var stream = await file.OpenWriteAsync().ConfigureAwait(true);
await stream.WriteAsync(content, ct).ConfigureAwait(true);
return true;
}
}

View file

@ -31,6 +31,7 @@ public static class ServiceCollectionHelpers
() => settings.Authentication?.Authority); () => settings.Authentication?.Authority);
var billingClient = new BillingApiClient(api, () => settings.ApiUrl); var billingClient = new BillingApiClient(api, () => settings.ApiUrl);
var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl); var estimateClient = new EstimateApiClient(api, () => settings.ApiUrl);
var frontClient = new FrontOfficeApiClient(api, () => settings.ApiUrl);
var userDirectory = new UserDirectory(userSearchClient); var userDirectory = new UserDirectory(userSearchClient);
var reverseGeocoding = new NominatimReverseGeocodingService(); var reverseGeocoding = new NominatimReverseGeocodingService();
@ -63,6 +64,7 @@ public static class ServiceCollectionHelpers
services.AddTransient<ProviderOngoingRequestsPage>(); services.AddTransient<ProviderOngoingRequestsPage>();
services.AddTransient<EstimateEditionPage>(); services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>(); services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
@ -74,6 +76,7 @@ public static class ServiceCollectionHelpers
services.AddSingleton(activityClient); services.AddSingleton(activityClient);
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton(estimateClient); services.AddSingleton(estimateClient);
services.AddSingleton(frontClient);
services.AddSingleton<IReverseGeocodingService>(reverseGeocoding); services.AddSingleton<IReverseGeocodingService>(reverseGeocoding);
services.AddSingleton<IUserDirectory>(userDirectory); services.AddSingleton<IUserDirectory>(userDirectory);
services.AddSingleton<HomePageViewModel>(); services.AddSingleton<HomePageViewModel>();

View file

@ -5,8 +5,8 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<AvaloniaResource Include="Assets\**" /> <AvaloniaResource Include="Assets\**" />
@ -23,6 +23,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" /> <PackageReference Include="Avalonia" />
<PackageReference Include="Avalonia.AvaloniaEdit" />
<PackageReference Include="Avalonia.Themes.Fluent" /> <PackageReference Include="Avalonia.Themes.Fluent" />
<PackageReference Include="Avalonia.Fonts.Inter" /> <PackageReference Include="Avalonia.Fonts.Inter" />
<PackageReference Include="AvaloniaUI.DiagnosticsSupport"> <PackageReference Include="AvaloniaUI.DiagnosticsSupport">
@ -35,6 +36,7 @@
<PackageReference Include="IdentityModel.OidcClient" /> <PackageReference Include="IdentityModel.OidcClient" />
<PackageReference Include="MarkdownViewer.Core" /> <PackageReference Include="MarkdownViewer.Core" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Mono.Options" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../../Yavsc.Abstract/Yavsc.Abstract.csproj" />

View file

@ -478,6 +478,41 @@ public class YavscApiClient : IYavscApiClient, IAsyncDisposable
return "Avatar mis à jour."; return "Avatar mis à jour.";
} }
/// <summary>
/// Download a binary resource (PDF, TeX, …) from the API. Unlike
/// <see cref="CallAsync{T}(HttpMethod, string, object?, CancellationToken)"/>,
/// which JSON-decodes the response, this returns the raw response
/// bytes. Reuses the same Bearer-token attach + 401-refresh-retry-once
/// contract as <see cref="SetAvatarAsync"/>.
/// </summary>
public virtual async Task<byte[]> DownloadAsync(
HttpMethod method,
string path,
CancellationToken ct = default)
{
if (_tokens is null)
throw new InvalidOperationException("Not logged in. Call LoginInteractiveAsync first.");
await EnsureFreshTokenAsync(ct).ConfigureAwait(false);
var sendOnce = async () =>
{
using var request = new HttpRequestMessage(method, path);
return await Http.SendAsync(request, ct).ConfigureAwait(false);
};
var response = await sendOnce().ConfigureAwait(false);
if (response.StatusCode == HttpStatusCode.Unauthorized)
{
response.Dispose();
await ForceRefreshAsync(ct).ConfigureAwait(false);
response = await sendOnce().ConfigureAwait(false);
}
await EnsureSuccessOrThrowAsync(response, ct).ConfigureAwait(false);
return await response.Content.ReadAsByteArrayAsync(ct).ConfigureAwait(false);
}
public async Task LogoutAsync() public async Task LogoutAsync()
{ {
_store.Clear(); _store.Clear();

View file

@ -55,6 +55,7 @@ public class ViewLocator : IDataTemplate
ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(), ProviderOngoingRequestsPageViewModel => services.GetRequiredService<ProviderOngoingRequestsPage>(),
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(), EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(), EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
null => new TextBlock { Text = "No view for <null>" }, null => new TextBlock { Text = "No view for <null>" },
_ => new TextBlock { Text = $"No view for {data.GetType().Name}" } _ => new TextBlock { Text = $"No view for {data.GetType().Name}" }
}; };

View file

@ -7,6 +7,7 @@ using System.Threading.Tasks;
using Avalonia; using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers; using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Api.Client; using Yavsc.Api.Client;
@ -130,11 +131,13 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
try try
{ {
var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true); var details = await _billingClient.GetQueryAsync(Form.ActionName, SelectedQuery.Id).ConfigureAwait(true);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
Activity, Activity,
Performer, Performer,
Form, Form,
_billingClient, _billingClient,
frontClient,
details, details,
IsReadOnly); IsReadOnly);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
@ -171,5 +174,9 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
} }
private static bool IsOngoingStatus(QueryStatus status) private static bool IsOngoingStatus(QueryStatus status)
=> status is QueryStatus.Inserted or QueryStatus.Accepted or QueryStatus.InProgress; => status is QueryStatus.Inserted
or QueryStatus.Accepted
or QueryStatus.ProAccepted
or QueryStatus.ClientAccepted
or QueryStatus.InProgress;
} }

View file

@ -14,6 +14,7 @@ namespace PostIt.ViewModels;
public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly BillingApiClient _billingClient; private readonly BillingApiClient _billingClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly BillingQueryDetailsDto _details; private readonly BillingQueryDetailsDto _details;
public ActivityInfo Activity { get; } public ActivityInfo Activity { get; }
@ -68,6 +69,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
ActivityUserDisplayItem performer, ActivityUserDisplayItem performer,
CommandFormSummary form, CommandFormSummary form,
BillingApiClient billingClient, BillingApiClient billingClient,
FrontOfficeApiClient frontClient,
BillingQueryDetailsDto details, BillingQueryDetailsDto details,
bool isReadOnly) bool isReadOnly)
{ {
@ -75,6 +77,7 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
Performer = performer ?? throw new ArgumentNullException(nameof(performer)); Performer = performer ?? throw new ArgumentNullException(nameof(performer));
Form = form ?? throw new ArgumentNullException(nameof(form)); Form = form ?? throw new ArgumentNullException(nameof(form));
_billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient)); _billingClient = billingClient ?? throw new ArgumentNullException(nameof(billingClient));
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
_details = details ?? throw new ArgumentNullException(nameof(details)); _details = details ?? throw new ArgumentNullException(nameof(details));
IsReadOnly = isReadOnly; IsReadOnly = isReadOnly;
@ -131,6 +134,53 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>
/// Download the estimate linked to this command as a LaTeX source
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is
/// resolved server-side from <c>Estimate.CommandId == query Id</c>.
/// </summary>
[RelayCommand]
public async Task DownloadTexAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimateTexAsync(_details.Id),
$"devis-{_details.Id}", "tex").ConfigureAwait(true);
}
/// <summary>
/// Download the estimate linked to this command as a compiled PDF
/// (<c>GET api/v1/front/query/{Id}/estimate.pdf</c>). Requires
/// <c>texi2pdf</c> on the server host.
/// </summary>
[RelayCommand]
public async Task DownloadPdfAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimatePdfAsync(_details.Id),
$"devis-{_details.Id}", "pdf").ConfigureAwait(true);
}
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
{
IsBusy = true;
try
{
var bytes = await fetch().ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
this.SetInfoStatus(saved
? $"Fichier .{ext} enregistré."
: "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
private static string EmptyAsPlaceholder(string? value, string placeholder) private static string EmptyAsPlaceholder(string? value, string placeholder)
=> string.IsNullOrWhiteSpace(value) ? placeholder : value; => string.IsNullOrWhiteSpace(value) ? placeholder : value;
@ -166,6 +216,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "#E6F7EC", QueryStatus.Accepted => "#E6F7EC",
QueryStatus.ProAccepted => "#E6F7EC",
QueryStatus.ClientAccepted => "#E1F5FE",
QueryStatus.InProgress => "#FFF4D6", QueryStatus.InProgress => "#FFF4D6",
QueryStatus.Rejected => "#FDECEA", QueryStatus.Rejected => "#FDECEA",
QueryStatus.Failed => "#ECEFF1", QueryStatus.Failed => "#ECEFF1",
@ -177,6 +229,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "#2E7D32", QueryStatus.Accepted => "#2E7D32",
QueryStatus.ProAccepted => "#2E7D32",
QueryStatus.ClientAccepted => "#1565C0",
QueryStatus.InProgress => "#B26A00", QueryStatus.InProgress => "#B26A00",
QueryStatus.Rejected => "#C62828", QueryStatus.Rejected => "#C62828",
QueryStatus.Failed => "#607D8B", QueryStatus.Failed => "#607D8B",
@ -188,6 +242,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "#1B5E20", QueryStatus.Accepted => "#1B5E20",
QueryStatus.ProAccepted => "#1B5E20",
QueryStatus.ClientAccepted => "#0D47A1",
QueryStatus.InProgress => "#7A4A00", QueryStatus.InProgress => "#7A4A00",
QueryStatus.Rejected => "#8E0000", QueryStatus.Rejected => "#8E0000",
QueryStatus.Failed => "#37474F", QueryStatus.Failed => "#37474F",
@ -199,6 +255,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
=> status switch => status switch
{ {
QueryStatus.Accepted => "OK", QueryStatus.Accepted => "OK",
QueryStatus.ProAccepted => "P",
QueryStatus.ClientAccepted => "C",
QueryStatus.InProgress => "~", QueryStatus.InProgress => "~",
QueryStatus.Rejected => "!", QueryStatus.Rejected => "!",
QueryStatus.Failed => "X", QueryStatus.Failed => "X",

View file

@ -5,6 +5,7 @@ using System.Linq;
using System.Net; using System.Net;
using System.Net.Http; using System.Net.Http;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers; using PostIt.Helpers;
@ -18,10 +19,10 @@ namespace PostIt.ViewModels;
/// </summary> /// </summary>
public enum EstimateListPerspective public enum EstimateListPerspective
{ {
/// <summary>« Mes devis à valider » — l'utilisateur courant est le client.</summary> /// <summary>« Devis en attente de signature client » — l'utilisateur courant est le client.</summary>
Client, Client,
/// <summary>« Mes devis en attente » — l'utilisateur courant est le fournisseur.</summary> /// <summary>« Devis en attente de signature fournisseur » — l'utilisateur courant est le fournisseur.</summary>
Provider, Provider,
} }
@ -32,14 +33,15 @@ public enum EstimateListPerspective
public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly EstimateApiClient _estimateClient; private readonly EstimateApiClient _estimateClient;
private readonly FrontOfficeApiClient _frontClient;
private readonly EstimateListPerspective _perspective; private readonly EstimateListPerspective _perspective;
private List<EstimateDto> _allEstimates = new(); private List<EstimateDto> _allEstimates = new();
public EstimateListPerspective Perspective => _perspective; public EstimateListPerspective Perspective => _perspective;
public string Title => _perspective == EstimateListPerspective.Client public string Title => _perspective == EstimateListPerspective.Client
? "Mes devis à valider" ? "Devis en attente de signature client"
: "Mes devis en attente"; : "Devis en attente de signature fournisseur";
public string Subtitle => _perspective == EstimateListPerspective.Client public string Subtitle => _perspective == EstimateListPerspective.Client
? "Point de vue client" ? "Point de vue client"
@ -83,10 +85,14 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
protected set { _ = value; } protected set { _ = value; }
} }
public EstimateListPageViewModel(EstimateApiClient estimateClient, EstimateListPerspective perspective) public EstimateListPageViewModel(
EstimateApiClient estimateClient,
EstimateListPerspective perspective,
FrontOfficeApiClient frontClient)
{ {
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_perspective = perspective; _perspective = perspective;
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
} }
public Task InitializeAsync() => RefreshAsync(); public Task InitializeAsync() => RefreshAsync();
@ -153,4 +159,25 @@ public partial class EstimateListPageViewModel : ViewModelBase, IActionStatusVie
private static bool ContainsInsensitive(string? value, string query) private static bool ContainsInsensitive(string? value, string query)
=> !string.IsNullOrWhiteSpace(value) => !string.IsNullOrWhiteSpace(value)
&& value.Contains(query, StringComparison.OrdinalIgnoreCase); && value.Contains(query, StringComparison.OrdinalIgnoreCase);
/// <summary>
/// Open the estimate validation (accept/reject with signature)
/// page for a specific estimate. The perspective the list was
/// opened in is forwarded as the actor-role UI hint; the server
/// independently enforces the real authorization.
/// </summary>
[RelayCommand]
private async Task OpenEstimateAsync(EstimateDto? estimate)
{
if (estimate is null) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var vm = new EstimateValidationPageViewModel(estimate, _perspective, _frontClient);
await app.PushPageAsync(vm).ConfigureAwait(true);
}
} }

View file

@ -0,0 +1,399 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Controls;
using PostIt.Helpers;
using PostIt.Models;
using Yavsc.Api.Client;
using Yavsc.Models.Billing;
namespace PostIt.ViewModels;
/// <summary>
/// Validation (accept / reject) of a single estimate, with optional
/// signatures. Backs the estimate validation page opened from
/// <see cref="EstimateListPageViewModel"/> for either perspective.
///
/// <para>Two signatures are shown, each in its own pad: the provider's
/// (Pro) and the client's (Client). Both are rendered from the estimate
/// payload when present, so reopening a partly-signed estimate shows
/// what was already captured. Only the pad matching the current
/// <see cref="EstimateListPerspective"/> is writable — the server
/// (<c>POST api/v1/front/query/accept</c>) independently enforces the
/// same rule (it resolves the caller's role from the authenticated user
/// vs the query's <c>PerformerId</c>/<c>ClientId</c>), and the UI marks
/// the other pad read-only. The billing code sent to the server is the
/// estimate's <see cref="EstimateDto.CommandType"/> and the query id is
/// its <see cref="EstimateDto.CommandId"/>.</para>
/// </summary>
public partial class EstimateValidationPageViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly EstimateDto _estimate;
private readonly EstimateListPerspective _perspective;
private readonly FrontOfficeApiClient _frontClient;
private SignaturePadControl? _proPad;
private SignaturePadControl? _clientPad;
public string Title => _estimate.Title;
public string Description => _estimate.Description;
public long EstimateId => _estimate.Id;
public string CommandType => _estimate.CommandType;
public IReadOnlyList<EstimateLineDto> Bill => _estimate.Bill;
public DateTime ProviderValidationDate => _estimate.ProviderValidationDate;
public DateTime ClientValidationDate => _estimate.ClientValidationDate;
public string RoleLabel => _perspective == EstimateListPerspective.Provider
? "Vous validez en tant que prestataire"
: "Vous validez en tant que client";
/// <summary>
/// True when the current user may sign the provider's signature
/// (i.e. they opened the list as the provider). The other party's
/// pad is read-only.
/// </summary>
public bool CanWritePro => _perspective == EstimateListPerspective.Provider;
/// <summary>
/// True when the current user may sign the client's signature
/// (i.e. they opened the list as the client).
/// </summary>
public bool CanWriteClient => _perspective == EstimateListPerspective.Client;
/// <summary>
/// Human-readable summary of who has already validated this
/// estimate, shown above the signature pads.
/// </summary>
public string ValidationStateText
{
get
{
var parts = new List<string>();
if (ProviderValidationDate != default)
parts.Add($"Prestataire validé le {ProviderValidationDate.ToLocalTime():g}");
if (ClientValidationDate != default)
parts.Add($"Client validé le {ClientValidationDate.ToLocalTime():g}");
return parts.Count == 0 ? "Devis en attente de validation." : string.Join(" · ", parts);
}
}
/// <summary>
/// True when the estimate has a linked query to accept/reject.
/// Estimates without a <see cref="EstimateDto.CommandId"/> are
/// templates and cannot be validated through the front-office
/// endpoint.
/// </summary>
public bool HasCommand => _estimate.CommandId is not null;
[ObservableProperty]
public partial bool IsBusy { get; set; }
[ObservableProperty]
public partial string StatusMessage { get; set; } = "Prêt.";
[ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
/// <summary>Stroke/point counts for the writable pad.</summary>
[ObservableProperty]
public partial int StrokeCount { get; set; }
[ObservableProperty]
public partial int PointCount { get; set; }
/// <summary>True when the writable pad holds a signature.</summary>
[ObservableProperty]
public partial bool HasSignature { get; set; }
[ObservableProperty]
public partial bool HasProSignature { get; set; }
[ObservableProperty]
public partial bool HasClientSignature { get; set; }
[ObservableProperty]
public partial string ProSignatureHint { get; set; } = string.Empty;
[ObservableProperty]
public partial string ClientSignatureHint { get; set; } = string.Empty;
public override bool CanNavigateNext
{
get => false;
protected set { _ = value; }
}
public override bool CanNavigatePrevious
{
get => true;
protected set { _ = value; }
}
public EstimateValidationPageViewModel(
EstimateDto estimate,
EstimateListPerspective perspective,
FrontOfficeApiClient frontClient)
{
_estimate = estimate ?? throw new ArgumentNullException(nameof(estimate));
_perspective = perspective;
_frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
}
/// <summary>
/// Bind the page's two signature pads (the inner controls of the
/// page's <see cref="SignaturePadView"/>s): the provider's pad and
/// the client's pad. Called from the view's code-behind once the
/// view is in the visual tree. Marks the non-author pad read-only,
/// then renders any signatures already carried on the estimate
/// payload.
/// </summary>
public void Attach(SignaturePadControl proPad, SignaturePadControl clientPad)
{
if (proPad is null) throw new ArgumentNullException(nameof(proPad));
if (clientPad is null) throw new ArgumentNullException(nameof(clientPad));
Detach();
_proPad = proPad;
_clientPad = clientPad;
_proPad.IsReadOnly = !CanWritePro;
_clientPad.IsReadOnly = !CanWriteClient;
_proPad.RedrawRequested += OnPadRedraw;
_clientPad.RedrawRequested += OnPadRedraw;
LoadExistingSignatures();
RefreshFromPad();
}
public void Detach()
{
if (_proPad is not null)
_proPad.RedrawRequested -= OnPadRedraw;
if (_clientPad is not null)
_clientPad.RedrawRequested -= OnPadRedraw;
_proPad = null;
_clientPad = null;
}
private void OnPadRedraw(object? sender, EventArgs e) => RefreshFromPad();
/// <summary>The pad the current user may sign in.</summary>
private SignaturePadControl? WritablePad => CanWritePro ? _proPad : _clientPad;
/// <summary>
/// Render the signatures already stored for this estimate back into
/// their pads. The strokes are carried on the estimate payload as
/// two dedicated slots — <see cref="EstimateDto.SignaturePro"/> and
/// <see cref="EstimateDto.SignatureClient"/> — so each pad loads
/// its own side directly, in the same wire format PostIt captured.
/// No network round-trip.
/// </summary>
public void LoadExistingSignatures()
{
LoadSide(_proPad, _estimate.SignaturePro);
LoadSide(_clientPad, _estimate.SignatureClient);
RefreshFromPad();
}
private static void LoadSide(SignaturePadControl? pad, EstimateSignatureDto? sig)
{
if (pad is null || sig is null) return;
if (sig.Strokes is { Length: > 0 } strokes)
pad.Load(strokes);
}
private void RefreshFromPad()
{
HasProSignature = _proPad?.Snapshot().IsEmpty == false;
HasClientSignature = _clientPad?.Snapshot().IsEmpty == false;
ProSignatureHint = CanWritePro
? (HasProSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que prestataire")
: (HasProSignature ? "Signature du prestataire (lecture seule)" : "En attente de signature du prestataire");
ClientSignatureHint = CanWriteClient
? (HasClientSignature ? "Signé — dessinez pour remplacer" : "Signez ici en tant que client")
: (HasClientSignature ? "Signature du client (lecture seule)" : "En attente de signature du client");
var pad = WritablePad;
if (pad is null) return;
var snap = pad.Snapshot();
StrokeCount = snap.StrokeCount;
PointCount = snap.PointCount;
HasSignature = !snap.IsEmpty;
}
partial void OnIsBusyChanged(bool value)
{
ValidateCommand.NotifyCanExecuteChanged();
RejectCommand.NotifyCanExecuteChanged();
}
partial void OnHasSignatureChanged(bool value)
=> ValidateCommand.NotifyCanExecuteChanged();
private bool CanValidate => !IsBusy && HasSignature && HasCommand;
private bool CanReject => !IsBusy && HasCommand;
[RelayCommand(CanExecute = nameof(CanValidate))]
public async Task ValidateAsync()
{
if (_estimate.CommandId is not long queryId)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être validé.");
return;
}
var pad = WritablePad;
if (pad is null || pad.Snapshot().IsEmpty)
{
this.SetWarningStatus("Veuillez signer avant de valider.");
return;
}
var snap = pad.Snapshot();
IsBusy = true;
try
{
var body = new QueryAcceptanceRequestDto
{
Strokes = snap.Strokes,
CoordinateMax = SignaturePadData.CoordinateMax,
CapturedAtUtc = DateTime.UtcNow,
// Declare which side the caller is signing as, so the
// server stores the signature under the right author
// (Pro/Client) even when one user is both parties.
SignatureType = CanWritePro ? SignatureType.Pro : SignatureType.Client,
};
await _frontClient.AcceptQueryAsync(_estimate.CommandType, queryId, body)
.ConfigureAwait(true);
this.SetInfoStatus("Devis validé.");
await GoBackAsync().ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Vous n'êtes pas partie à ce devis.");
}
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.BadRequest)
{
this.SetWarningStatus($"Validation refusée: {ex.Message}");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand(CanExecute = nameof(CanReject))]
public async Task RejectAsync()
{
if (_estimate.CommandId is not long queryId)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande et ne peut être refusé.");
return;
}
IsBusy = true;
try
{
await _frontClient.RejectQueryAsync(_estimate.CommandType, queryId)
.ConfigureAwait(true);
this.SetInfoStatus("Devis refusé.");
await GoBackAsync().ConfigureAwait(true);
}
catch (HttpRequestException ex) when (ex.StatusCode is System.Net.HttpStatusCode.Forbidden)
{
this.SetWarningStatus("Vous n'êtes pas partie à ce devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
public void ClearSignature()
{
WritablePad?.Clear();
RefreshFromPad();
}
/// <summary>
/// Download the estimate as a LaTeX source document. The server
/// renders the Estimate_tex template to a <c>.tex</c> byte stream
/// (<c>GET api/v1/front/query/{CommandId}/estimate.tex</c>); the
/// user picks a destination via the platform save picker.
/// </summary>
[RelayCommand]
public async Task DownloadTexAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimateTexAsync(_estimate.CommandId!.Value),
$"devis-{EstimateId}", "tex").ConfigureAwait(true);
}
/// <summary>
/// Download the estimate as a compiled PDF
/// (<c>GET api/v1/front/query/{CommandId}/estimate.pdf</c>).
/// Requires <c>texi2pdf</c> on the server host; a failure is
/// surfaced as a status message.
/// </summary>
[RelayCommand]
public async Task DownloadPdfAsync()
{
await DownloadAsync(
() => _frontClient.GetEstimatePdfAsync(_estimate.CommandId!.Value),
$"devis-{EstimateId}", "pdf").ConfigureAwait(true);
}
private async Task DownloadAsync(Func<Task<byte[]>> fetch, string fileName, string ext)
{
if (_estimate.CommandId is not long)
{
this.SetWarningStatus("Ce devis n'est pas lié à une demande.");
return;
}
IsBusy = true;
try
{
var bytes = await fetch().ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(fileName, ext, bytes).ConfigureAwait(true);
this.SetInfoStatus(saved
? $"Fichier .{ext} enregistré."
: "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement: {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task BackAsync() => await GoBackAsync().ConfigureAwait(true);
private static async Task GoBackAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
await app.GoBackAsync().ConfigureAwait(true);
}
}

View file

@ -8,6 +8,7 @@ using System.Threading.Tasks;
using Avalonia; using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using Microsoft.Extensions.DependencyInjection;
using PostIt.Helpers; using PostIt.Helpers;
using Yavsc; using Yavsc;
using Yavsc.Abstract.Workflow; using Yavsc.Abstract.Workflow;
@ -153,12 +154,14 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
.GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id) .GetQueryAsync(SelectedQuery.BillingCode, SelectedQuery.Id)
.ConfigureAwait(true); .ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery); var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
activity, activity,
performer, performer,
form, form,
_billingClient, _billingClient,
frontClient,
details, details,
isReadOnly: false); isReadOnly: false);
@ -344,11 +347,13 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
=> status switch => status switch
{ {
QueryStatus.InProgress => 0, QueryStatus.InProgress => 0,
QueryStatus.Accepted => 1, QueryStatus.ClientAccepted => 1,
QueryStatus.Inserted => 2, QueryStatus.ProAccepted => 2,
QueryStatus.Success => 3, QueryStatus.Accepted => 2,
QueryStatus.Rejected => 4, QueryStatus.Inserted => 3,
QueryStatus.Failed => 5, QueryStatus.Success => 4,
QueryStatus.Rejected => 5,
QueryStatus.Failed => 6,
_ => 99, _ => 99,
}; };

View file

@ -11,6 +11,7 @@ using Yavsc.Blogspot;
using Yavsc.Api.Client; using Yavsc.Api.Client;
using Yavsc.Abstract.Files; using Yavsc.Abstract.Files;
using PostIt.Helpers; using PostIt.Helpers;
using AvaloniaEdit.Document;
namespace PostIt.ViewModels; namespace PostIt.ViewModels;
@ -36,8 +37,19 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
/// <summary>Editor buffer for the post body. Same pattern as /// <summary>Editor buffer for the post body. Same pattern as
/// <see cref="DraftTitle"/>.</summary> /// <see cref="DraftTitle"/>.</summary>
public string DraftArticle
{
get => DraftArticleDocument.Text;
set {
OnPropertyChanging(nameof(DraftArticle));
DraftArticleDocument = new TextDocument(value);
this.OnPropertyChanged(nameof(DraftArticle));
}
}
[ObservableProperty] [ObservableProperty]
public partial string DraftArticle { get; set; } public partial TextDocument DraftArticleDocument {get; set;}
/// <summary>Editor buffer for the post's publication state. /// <summary>Editor buffer for the post's publication state.
/// Reflects the server-side <c>IsPublished</c> flag (the /// Reflects the server-side <c>IsPublished</c> flag (the
@ -381,6 +393,7 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
WindowTitle = "PostIt"; WindowTitle = "PostIt";
DraftTitle = string.Empty; DraftTitle = string.Empty;
DraftArticle = string.Empty; DraftArticle = string.Empty;
DraftIsPublished = false; DraftIsPublished = false;
IsLoaded = false; IsLoaded = false;
// Production path: DI injects the canonical Settings singleton // Production path: DI injects the canonical Settings singleton
@ -464,7 +477,6 @@ public partial class BlogsViewModel : ViewModelBase, IActionStatusViewModel
// enable as soon as the user has typed a non-whitespace // enable as soon as the user has typed a non-whitespace
// title, regardless of whether a post is selected. // title, regardless of whether a post is selected.
partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnDraftTitleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
partial void OnDraftArticleChanged(string value) => SaveCommand.NotifyCanExecuteChanged();
private async Task RefreshPostsAsync() private async Task RefreshPostsAsync()

View file

@ -90,7 +90,13 @@ public class HomePageViewModel : ViewModelBase
throw new InvalidOperationException("Client devis indisponible."); throw new InvalidOperationException("Client devis indisponible.");
} }
var vm = new EstimateListPageViewModel(estimateClient, perspective); var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
if (frontClient is null)
{
throw new InvalidOperationException("Client front-office indisponible.");
}
var vm = new EstimateListPageViewModel(estimateClient, perspective, frontClient);
await vm.InitializeAsync(); await vm.InitializeAsync();
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }

View file

@ -259,25 +259,13 @@ public partial class Settings : ViewModelBase
return; return;
} }
} }
if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envJson
&& !string.IsNullOrWhiteSpace(envJson)) var app = App.Current as App;
if (app!=null)
if (app.CliConfigFileSpecification!=null)
{ {
Console.WriteLine("🔎 Loading settings from POSTIT_SETTINGS_JSON environment variable."); SettingsFileFullName = app.CliConfigFileSpecification;
FileInfo configByEnvFileInfo = new FileInfo(envJson);
if (!configByEnvFileInfo.Exists)
{
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
}
string json = File.ReadAllText(configByEnvFileInfo.FullName);
ApplyJson(json, "POSTIT_SETTINGS_JSON");
SettingsFileFullName = configByEnvFileInfo.FullName;
Loaded = true;
return;
} }
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt"
);
if (SettingsFileFullName is not null) if (SettingsFileFullName is not null)
{ {
@ -287,10 +275,18 @@ public partial class Settings : ViewModelBase
else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath else if (Environment.GetEnvironmentVariable("POSTIT_SETTINGS_JSON") is string envPath
&& !string.IsNullOrWhiteSpace(envPath)) && !string.IsNullOrWhiteSpace(envPath))
{ {
SettingsFileFullName = envPath; FileInfo configByEnvFileInfo = new FileInfo(envPath);
if (!configByEnvFileInfo.Exists)
{
throw new Exception($"🩎 Settings file not found at {configByEnvFileInfo.FullName}");
}
SettingsFileFullName = configByEnvFileInfo.FullName;
} }
else else
{ {
string configDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"PostIt");
SettingsFileFullName = Path.Combine(configDir, "postit-settings.json"); SettingsFileFullName = Path.Combine(configDir, "postit-settings.json");
} }
@ -536,6 +532,7 @@ public partial class Settings : ViewModelBase
/// </summary> /// </summary>
partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged(); partial void OnIsDirtyChanged(bool value) => SaveCommand.NotifyCanExecuteChanged();
public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); } public override bool CanNavigateNext { get => false; protected set => throw new System.NotImplementedException(); }
public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); } public override bool CanNavigatePrevious { get => true; protected set => throw new System.NotImplementedException(); }
} }

View file

@ -85,10 +85,12 @@ public partial class SignaturePageViewModel : ViewModelBase, IActionStatusViewMo
} }
/// <summary> /// <summary>
/// Bind a freshly-constructed (or re-templated) control to this /// Bind the inner <see cref="SignaturePadControl"/> of the page's
/// VM. Called from the view's code-behind once the control has /// <see cref="SignaturePadView"/> to this VM. Called from the
/// been added to the visual tree and its template applied (so /// page's code-behind once the view is in the visual tree (so the
/// <see cref="SignaturePadControl.CaptureArea"/> is wired). /// control's <see cref="SignaturePadControl.CaptureArea"/> is
/// wired). Attaching to the control directly (rather than the
/// view) keeps the same contract the headless tests use.
/// </summary> /// </summary>
public void Attach(SignaturePadControl control) public void Attach(SignaturePadControl control)
{ {

View file

@ -14,7 +14,7 @@
<TextBlock Text="{Binding ContextLabel}" Opacity="0.75" /> <TextBlock Text="{Binding ContextLabel}" Opacity="0.75" />
</StackPanel> </StackPanel>
<Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,*"> <Grid Grid.Row="1" Margin="0,10,0,0" ColumnDefinitions="Auto,8,Auto,8,Auto,8,Auto,*">
<Button Grid.Column="0" <Button Grid.Column="0"
Content="Retour" Content="Retour"
Command="{Binding BackCommand}" /> Command="{Binding BackCommand}" />
@ -22,6 +22,12 @@
Content="Ouvrir en edition" Content="Ouvrir en edition"
Command="{Binding OpenEditorCommand}" Command="{Binding OpenEditorCommand}"
IsVisible="{Binding CanEdit}" /> IsVisible="{Binding CanEdit}" />
<Button Grid.Column="4"
Content="Télécharger PDF"
Command="{Binding DownloadPdfCommand}" />
<Button Grid.Column="6"
Content="Télécharger TeX"
Command="{Binding DownloadTexCommand}" />
</Grid> </Grid>
<ScrollViewer Grid.Row="2" Margin="0,12,0,12"> <ScrollViewer Grid.Row="2" Margin="0,12,0,12">

View file

@ -29,34 +29,40 @@
CornerRadius="8" CornerRadius="8"
Padding="10" Padding="10"
Margin="0,0,0,8"> Margin="0,0,0,8">
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto"> <StackPanel Spacing="8">
<TextBlock Grid.Row="0" <Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
Grid.Column="0" <TextBlock Grid.Row="0"
Text="{Binding Title}" Grid.Column="0"
FontWeight="Bold" Text="{Binding Title}"
TextWrapping="Wrap" /> FontWeight="Bold"
<TextBlock Grid.Row="0" TextWrapping="Wrap" />
Grid.Column="1" <TextBlock Grid.Row="0"
Text="{Binding Id}" Grid.Column="1"
FontSize="11" Text="{Binding Id}"
Opacity="0.75" FontSize="11"
HorizontalAlignment="Right" /> Opacity="0.75"
HorizontalAlignment="Right" />
<TextBlock Grid.Row="1" <TextBlock Grid.Row="1"
Grid.Column="0" Grid.Column="0"
Margin="0,6,0,0" Margin="0,6,0,0"
Text="{Binding Description}" Text="{Binding Description}"
FontSize="11" FontSize="11"
Opacity="0.75" Opacity="0.75"
TextWrapping="Wrap" /> TextWrapping="Wrap" />
<TextBlock Grid.Row="1" <TextBlock Grid.Row="1"
Grid.Column="1" Grid.Column="1"
Margin="0,6,0,0" Margin="0,6,0,0"
Text="{Binding Bill.Count}" Text="{Binding Bill.Count}"
FontSize="11" FontSize="11"
Opacity="0.65" Opacity="0.65"
HorizontalAlignment="Right" /> HorizontalAlignment="Right" />
</Grid> </Grid>
<Button Content="Ouvrir"
HorizontalAlignment="Right"
Command="{Binding $parent[ContentPage].((vm:EstimateListPageViewModel)DataContext).OpenEstimateCommand}"
CommandParameter="{Binding}" />
</StackPanel>
</Border> </Border>
</DataTemplate> </DataTemplate>
</ListBox.ItemTemplate> </ListBox.ItemTemplate>

View file

@ -1,5 +1,6 @@
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using PostIt.ViewModels;
namespace PostIt.Views; namespace PostIt.Views;
@ -14,4 +15,19 @@ public partial class EstimateListPage : ContentPage
{ {
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
// Re-fetch the list when returning to it (e.g. after validating an
// estimate on the validation page). Avalonia's Page.OnNavigatedTo
// fires on the page popped back to with NavigationType.Pop; the list
// VM is retained on the nav stack while away, so this refresh picks
// up the server-side filter change that removed the signed estimate.
protected override void OnNavigatedTo(NavigatedToEventArgs e)
{
base.OnNavigatedTo(e);
if (e.NavigationType == NavigationType.Pop
&& DataContext is EstimateListPageViewModel vm)
{
_ = vm.RefreshAsync();
}
}
} }

View file

@ -0,0 +1,146 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:controls="using:PostIt.Controls"
xmlns:dto="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateValidationPage"
x:DataType="vm:EstimateValidationPageViewModel"
Header="Validation du devis">
<Grid RowDefinitions="Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3" Margin="0,0,0,8">
<TextBlock Text="{Binding Title}"
FontSize="20"
FontWeight="Bold"
TextWrapping="Wrap" />
<TextBlock Text="{Binding RoleLabel}" Opacity="0.75" />
<TextBlock Text="{Binding ValidationStateText}"
FontSize="11"
Opacity="0.75"
TextWrapping="Wrap" />
</StackPanel>
<ScrollViewer Grid.Row="1">
<StackPanel Spacing="10">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="6">
<Grid ColumnDefinitions="Auto,12,*">
<TextBlock Grid.Column="0"
Text="N°"
FontWeight="SemiBold" />
<TextBlock Grid.Column="2"
Text="{Binding EstimateId}" />
</Grid>
<Grid ColumnDefinitions="Auto,12,*">
<TextBlock Grid.Column="0"
Text="Type"
FontWeight="SemiBold" />
<TextBlock Grid.Column="2"
Text="{Binding CommandType}" />
</Grid>
<TextBlock Text="{Binding Description}"
TextWrapping="Wrap"
Opacity="0.85" />
</StackPanel>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="Lignes du devis" FontWeight="SemiBold" />
<ItemsControl ItemsSource="{Binding Bill}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="dto:EstimateLineDto">
<Grid ColumnDefinitions="*,8,60,8,120" Margin="0,0,0,4">
<TextBlock Grid.Column="0"
Text="{Binding Name}"
TextWrapping="Wrap" />
<TextBlock Grid.Column="2"
Text="{Binding Count}"
HorizontalAlignment="Right" />
<StackPanel Grid.Column="4"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="4">
<TextBlock Text="{Binding UnitaryCost, StringFormat='{}{0:N2}'}" />
<TextBlock Text="{Binding Currency}" Opacity="0.7" />
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="Signature du prestataire" FontWeight="SemiBold" />
<TextBlock Text="{Binding ProSignatureHint}"
FontSize="11"
Opacity="0.7" />
<controls:SignaturePadView x:Name="ProPad"
CaptureWidth="560"
CaptureHeight="160" />
</StackPanel>
</Border>
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<TextBlock Text="Signature du client" FontWeight="SemiBold" />
<TextBlock Text="{Binding ClientSignatureHint}"
FontSize="11"
Opacity="0.7" />
<controls:SignaturePadView x:Name="ClientPad"
CaptureWidth="560"
CaptureHeight="160" />
</StackPanel>
</Border>
<TextBlock FontSize="11"
Foreground="DarkSlateGray">
<Run Text="Votre signature : traits " />
<Run Text="{Binding StrokeCount, Mode=OneWay}" />
<Run Text=" · points " />
<Run Text="{Binding PointCount, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="2" ColumnDefinitions="Auto,*,Auto" Margin="0,12,0,0">
<StackPanel Grid.Column="0" Orientation="Horizontal" Spacing="8">
<Button Content="Effacer"
Command="{Binding ClearSignatureCommand}" />
<Button Content="Valider"
Command="{Binding ValidateCommand}" />
<Button Content="Refuser"
Command="{Binding RejectCommand}" />
<Button Content="Télécharger PDF"
Command="{Binding DownloadPdfCommand}" />
<Button Content="Télécharger TeX"
Command="{Binding DownloadTexCommand}" />
<Button Content="Retour"
Command="{Binding BackCommand}" />
</StackPanel>
<StackPanel Grid.Column="2"
Orientation="Horizontal"
Spacing="8"
VerticalAlignment="Center">
<ProgressBar Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
<postitControls:StatusBar DataContext="{Binding ActionStatus}" />
</StackPanel>
</Grid>
</Grid>
</ContentPage>

View file

@ -0,0 +1,32 @@
using Avalonia.Controls;
using PostIt.Controls;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class EstimateValidationPage : ContentPage
{
private EstimateValidationPageViewModel? _vm;
public EstimateValidationPage()
{
InitializeComponent();
DataContextChanged += (_, _) => RebindViewModel(DataContext as EstimateValidationPageViewModel);
Unloaded += (_, _) => _vm?.Detach();
}
private void RebindViewModel(EstimateValidationPageViewModel? vm)
{
if (_vm is not null)
{
_vm.Detach();
}
_vm = vm;
if (_vm is null || ProPad is null || ClientPad is null) return;
_vm.Attach(ProPad.SignaturePad, ClientPad.SignaturePad);
}
}

View file

@ -4,9 +4,9 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:models="using:Yavsc.Blogspot" xmlns:models="using:Yavsc.Blogspot"
xmlns:views="using:PostIt.Views.Blogs" xmlns:views="using:PostIt.Views.Blogs"
xmlns:AvaloniaEdit="clr-namespace:AvaloniaEdit;assembly=AvaloniaEdit"
xmlns:md="using:MarkdownViewer.Core.Controls" xmlns:md="using:MarkdownViewer.Core.Controls"
mc:Ignorable="d" mc:Ignorable="d"
x:Class="PostIt.Views.Blogs.BlogsPage" x:Class="PostIt.Views.Blogs.BlogsPage"
@ -68,6 +68,7 @@
</CommandBarButton.Icon> </CommandBarButton.Icon>
</CommandBarButton> </CommandBarButton>
<CommandBarToggleButton Label="Publié" <CommandBarToggleButton Label="Publié"
Command="{Binding TogglePublishCommand}"
IsChecked="{Binding DraftIsPublished, Mode=TwoWay}"> IsChecked="{Binding DraftIsPublished, Mode=TwoWay}">
<CommandBarToggleButton.Icon> <CommandBarToggleButton.Icon>
<PathIcon Data="{StaticResource PublishedIcon}" /> <PathIcon Data="{StaticResource PublishedIcon}" />
@ -131,11 +132,14 @@
<TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" /> <TextBlock Grid.Row="0" Text="Post detail" FontWeight="SemiBold" />
<TextBox x:Name="DraftTitleTextBox" <TextBox x:Name="DraftTitleTextBox"
Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" /> Grid.Row="1" Text="{Binding DraftTitle, Mode=TwoWay}" PlaceholderText="Title" />
<TextBox Grid.Row="2" PlaceholderText="Write something here !"
Text="{Binding DraftArticle, Mode=TwoWay}" <AvaloniaEdit:TextEditor
MinHeight="320" Grid.Row="2" VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" /> Document="{Binding DraftArticleDocument, Mode=TwoWay}"
ShowLineNumbers="True"
FontFamily="Cascadia Code,Consolas,Menlo,Monospace"/>
<Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8"> <Border Grid.Row="3" BorderBrush="LightGray" BorderThickness="1" Padding="8">
<StackPanel Spacing="4"> <StackPanel Spacing="4">

View file

@ -26,11 +26,11 @@
Command="{Binding OpenProviderRequests}" Command="{Binding OpenProviderRequests}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes devis à valider" <Button Content="Devis en attente de signature client"
Command="{Binding OpenClientEstimates}" Command="{Binding OpenClientEstimates}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes devis en attente" <Button Content="Devis en attente de signature fournisseur"
Command="{Binding OpenProviderEstimates}" Command="{Binding OpenProviderEstimates}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>

View file

@ -29,29 +29,15 @@
FontWeight="Bold" /> FontWeight="Bold" />
<!-- <!--
Capture surface. The SignaturePadControl is render- Capture surface. SignaturePadView owns the SignaturePadControl,
agnostic, so we host it inside a fixed-size Border the capture-area Border, the ink overlay canvas, and the
that is the PART_CaptureArea, and overlay a Canvas repaint logic; the page just sizes it from the VM and reads
for the visual feedback. The view's code-behind the captured data through the view's API.
repaints the canvas on every RedrawRequested.
--> -->
<Border Grid.Row="1" <controls:SignaturePadView Grid.Row="1"
x:Name="PadFrame" x:Name="Pad"
Width="{Binding Width}" CaptureWidth="{Binding Width}"
Height="{Binding Height}" CaptureHeight="{Binding Height}" />
BorderBrush="Black"
BorderThickness="1"
Background="White"
HorizontalAlignment="Left"
VerticalAlignment="Top"
ClipToBounds="True">
<Grid>
<controls:SignaturePadControl x:Name="Pad" />
<Canvas x:Name="InkLayer"
Background="Transparent"
IsHitTestVisible="False" />
</Grid>
</Border>
<StackPanel Grid.Row="2" <StackPanel Grid.Row="2"
Orientation="Horizontal" Orientation="Horizontal"

View file

@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using Avalonia;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Shapes;
using Avalonia.Media;
using PostIt.Controls; using PostIt.Controls;
using PostIt.ViewModels; using PostIt.ViewModels;
@ -11,26 +6,15 @@ namespace PostIt.Views;
public partial class SignaturePage : ContentPage public partial class SignaturePage : ContentPage
{ {
private static readonly IBrush StrokeBrush = new SolidColorBrush(Color.FromRgb(0x10, 0x10, 0x10));
private const double StrokeThickness = 2.0;
private const double CoordinateMax = 10_000.0;
private SignaturePageViewModel? _vm; private SignaturePageViewModel? _vm;
private SignaturePadControl? _control;
public SignaturePage() public SignaturePage()
{ {
InitializeComponent(); InitializeComponent();
// Wire the capture area: the Pad itself is the control, the // SignaturePadView owns the capture area wiring and the ink
// surrounding Border (PadFrame) is the hit-test region. We // repaint; the page only forwards the view to the VM so it can
// set CaptureArea once the control's template has been // read snapshots / subscribe to stroke events.
// applied — for an inline control with no template, that
// happens on first measure, which is guaranteed before
// the user can interact, so attaching here is safe.
_control = Pad;
_control.CaptureArea = PadFrame;
DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel); DataContextChanged += (_, _) => RebindViewModel(DataContext as SignaturePageViewModel);
} }
@ -39,82 +23,12 @@ public partial class SignaturePage : ContentPage
if (_vm is not null) if (_vm is not null)
{ {
_vm.Detach(); _vm.Detach();
_control!.RedrawRequested -= OnRedrawRequested;
} }
_vm = vm; _vm = vm;
if (_vm is null || _control is null) return; if (_vm is null || Pad is null) return;
_vm.Attach(_control); _vm.Attach(Pad.SignaturePad);
_control.RedrawRequested += OnRedrawRequested;
Repaint();
} }
}
private void OnRedrawRequested(object? sender, EventArgs e) => Repaint();
private void Repaint()
{
if (_control is null || InkLayer is null) return;
InkLayer.Children.Clear();
var w = PadFrame.Bounds.Width;
var h = PadFrame.Bounds.Height;
if (w <= 0 || h <= 0) return;
var pending = _control.PendingStroke;
var strokes = _control.Strokes;
int sealedCount = strokes.Count - pending.Count;
if (sealedCount < 0)
{
sealedCount = 0;
}
int i = 0;
while (i < sealedCount)
{
int k = strokes[i];
if (k <= 0) break;
i++; // skip the length prefix
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<Point>(k);
for (int p = 0; p < k; p++)
{
int nx = strokes[i++];
int ny = strokes[i++];
pts.Add(new Point(nx / CoordinateMax * w, ny / CoordinateMax * h));
}
poly.Points = pts;
InkLayer.Children.Add(poly);
}
if (pending.Count > 0)
{
var poly = new Polyline
{
Stroke = StrokeBrush,
StrokeThickness = StrokeThickness,
StrokeLineCap = PenLineCap.Round,
StrokeJoin = PenLineJoin.Round,
};
var pts = new List<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

@ -12,4 +12,4 @@ public enum SignatureType
{ {
Pro = 0, Pro = 0,
Client = 1, Client = 1,
} }

View file

@ -5,15 +5,28 @@ namespace Yavsc
/// should be associated to any /// should be associated to any
/// client user query to a provider user or /// client user query to a provider user or
/// other external entity. /// other external entity.
///
/// <para>
/// Acceptance is split by party: <see cref="ProAccepted"/> when the
/// provider accepts the client's request (and produces/submits the
/// estimate), <see cref="ClientAccepted"/> when the client accepts
/// the provider's estimate. <see cref="Accepted"/> is the legacy
/// single-value kept for rows written before the split; new writes
/// use the party-specific values. Its integer value (2) is stable
/// so existing rows still deserialize.
/// </para>
/// </summary> /// </summary>
public enum QueryStatus: int public enum QueryStatus: int
{ {
Inserted, Inserted = 0,
Rejected, Rejected = 1,
Accepted, Accepted = 2,
InProgress, InProgress = 3,
// final states // final states
Failed, Failed = 4,
Success Success = 5,
// party-specific acceptance (see summary)
ProAccepted = 6,
ClientAccepted = 7,
} }
} }

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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -39,6 +39,39 @@ public sealed class EstimateDto
public string CommandType { get; set; } = string.Empty; public string CommandType { get; set; } = string.Empty;
public DateTime ProviderValidationDate { get; set; } public DateTime ProviderValidationDate { get; set; }
public DateTime ClientValidationDate { get; set; } public DateTime ClientValidationDate { get; set; }
/// <summary>
/// The provider's signature on this estimate, or null when the
/// provider has not signed. Carries the PostIt wire-format
/// <see cref="EstimateSignatureDto.Strokes"/> so the pad can
/// repaint a previously drawn signature on reopen without a second
/// round-trip.
/// </summary>
public EstimateSignatureDto? SignaturePro { get; set; }
/// <summary>
/// The client's signature on this estimate, or null when the
/// client has not signed.
/// </summary>
public EstimateSignatureDto? SignatureClient { get; set; }
}
/// <summary>
/// One stored signature on an estimate, mirroring the JSON shape of
/// the server-side <c>Yavsc.Models.Billing.Signature</c> entity. The
/// <see cref="Strokes"/> are the same PostIt wire-format payload that
/// was captured and are loaded back into the pad unchanged. The side
/// (Pro/Client) is implicit in which property of
/// <see cref="EstimateDto"/> carries this DTO, so no <c>Type</c>
/// field is needed.
/// </summary>
public sealed class EstimateSignatureDto
{
public long Id { get; set; }
public long EstimateId { get; set; }
public int CoordinateMax { get; set; }
public DateTime CapturedAtUtc { get; set; }
public int[]? Strokes { get; set; }
} }
/// <summary> /// <summary>

View file

@ -0,0 +1,56 @@
using System;
using Yavsc.Models.Billing;
namespace Yavsc.Api.Client;
/// <summary>
/// Body of <c>POST api/v1/front/query/accept</c> and
/// <c>.../query/reject</c>, mirroring the server-side
/// <c>Yavsc.ViewModels.FrontOffice.QueryAcceptanceRequest</c>.
/// <see cref="Strokes"/> is optional: when non-empty on accept it is
/// the PostIt wire-format signature captured with the acceptance; null
/// or empty means no signature (the only valid value on reject).
/// </summary>
public sealed class QueryAcceptanceRequestDto
{
public int[]? Strokes { get; set; }
public int CoordinateMax { get; set; } = 10_000;
public DateTime? CapturedAtUtc { get; set; }
/// <summary>
/// Which signature is being submitted:
/// <see cref="SignatureType.Pro"/> (the provider) or
/// <see cref="SignatureType.Client"/>. The PostIt client sets this
/// from the perspective the page was opened in (provider signs Pro,
/// client signs Client), so the server stores the signature under
/// the right author even when one user is both parties. Null lets
/// the server infer the role from the caller's identity.
/// </summary>
public SignatureType? SignatureType { get; set; }
}
/// <summary>
/// Response of <c>POST .../query/accept</c> / <c>.../query/reject</c>
/// (<c>Ok(new { queryId, status, signature = ... })</c>). The
/// <see cref="Signature"/> ref is non-null only when a signature was
/// staged on accept.
/// </summary>
public sealed class QueryAcceptanceResponseDto
{
public long QueryId { get; set; }
public string? Status { get; set; }
public QuerySignatureRefDto? Signature { get; set; }
}
public sealed class QuerySignatureRefDto
{
public long Id { get; set; }
public long EstimateId { get; set; }
public string? Type { get; set; }
}

View file

@ -0,0 +1,135 @@
using System;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// HTTP client for the front-office query routes
/// (<c>api/v1/front/query/accept</c> / <c>.../reject</c>, served by
/// <see cref="Yavsc.ApiControllers.FrontOfficeApiController"/>). Same
/// DTO↔path mapper shape as <see cref="EstimateApiClient"/>: transport
/// (base URL, JSON, Bearer auth, silent refresh on 401) is delegated
/// to <see cref="IYavscApiClient"/>.
///
/// The server resolves the caller's role (provider vs client vs admin)
/// from the authenticated user compared to the query's
/// <c>PerformerId</c>/<c>ClientId</c>; the client does not declare a
/// role. <paramref name="billingCode"/> is the estimate's
/// <see cref="EstimateDto.CommandType"/> and <paramref name="queryId"/>
/// is its <see cref="EstimateDto.CommandId"/>.
/// </summary>
public sealed class FrontOfficeApiClient
{
private const string PathPrefix = "front";
private readonly IYavscApiClient _api;
private readonly Func<string> _businessBaseAddress;
public FrontOfficeApiClient(IYavscApiClient api, string businessBaseAddress)
: this(api, () => businessBaseAddress)
{
}
public FrontOfficeApiClient(IYavscApiClient api, Func<string> businessBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
_businessBaseAddress = businessBaseAddress ?? throw new ArgumentNullException(nameof(businessBaseAddress));
_ = ResolveBusinessBaseAddress();
}
/// <summary>
/// Accept (validate) the query <paramref name="queryId"/> under
/// billing code <paramref name="billingCode"/>. When
/// <paramref name="body"/> carries a non-empty signature, the
/// server stages it on the query's linked estimate and stamps the
/// matching validation date.
/// </summary>
public async Task<QueryAcceptanceResponseDto> AcceptQueryAsync(
string billingCode,
long queryId,
QueryAcceptanceRequestDto body,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0)
throw new ArgumentOutOfRangeException(nameof(queryId));
if (body is null)
throw new ArgumentNullException(nameof(body));
var path = $"{PathPrefix}/query/accept?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
HttpMethod.Post,
Absolute(path),
body: body,
ct: ct).ConfigureAwait(false);
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
}
/// <summary>
/// Reject the query <paramref name="queryId"/> under billing code
/// <paramref name="billingCode"/>. A signature is not meaningful on
/// a rejection; the server refuses one with 400, so this sends no
/// body.
/// </summary>
public async Task<QueryAcceptanceResponseDto> RejectQueryAsync(
string billingCode,
long queryId,
CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0)
throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/reject?billingCode={Uri.EscapeDataString(billingCode)}&queryId={queryId}";
var response = await _api.CallAsync<QueryAcceptanceResponseDto>(
HttpMethod.Post,
Absolute(path),
body: null,
ct: ct).ConfigureAwait(false);
return response ?? new QueryAcceptanceResponseDto { QueryId = queryId };
}
/// <summary>
/// Download the estimate linked to query <paramref name="queryId"/>
/// as a LaTeX (<c>.tex</c>) source document
/// (<c>GET api/v1/front/query/{queryId}/estimate.tex</c>). Returns
/// the raw TeX bytes (UTF-8 text).
/// </summary>
public Task<byte[]> GetEstimateTexAsync(long queryId, CancellationToken ct = default)
{
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/{queryId}/estimate.tex";
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
}
/// <summary>
/// Download the estimate linked to query <paramref name="queryId"/>
/// as a compiled PDF document
/// (<c>GET api/v1/front/query/{queryId}/estimate.pdf</c>). Returns
/// the raw PDF bytes. Requires <c>texi2pdf</c> on the server host;
/// a non-2xx response throws <see cref="HttpRequestException"/>.
/// </summary>
public Task<byte[]> GetEstimatePdfAsync(long queryId, CancellationToken ct = default)
{
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
var path = $"{PathPrefix}/query/{queryId}/estimate.pdf";
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress()
{
var raw = _businessBaseAddress();
if (string.IsNullOrWhiteSpace(raw))
throw new InvalidOperationException("Business base address is required.");
return new Uri(raw, UriKind.Absolute);
}
}

View file

@ -73,4 +73,23 @@ public interface IYavscApiClient : IAsyncDisposable
string path, string path,
Func<HttpContent> contentFactory, Func<HttpContent> contentFactory,
CancellationToken ct = default); CancellationToken ct = default);
/// <summary>
/// Download a binary resource (PDF, TeX, image, …). Unlike
/// <see cref="CallAsync{T}(HttpMethod,string,object?,CancellationToken)"/>,
/// which JSON-decodes the response, this returns the raw response
/// bytes. The same Bearer-token attach + 401-refresh-retry-once
/// contract applies.
/// </summary>
/// <remarks>
/// A default interface implementation is provided so that test
/// fakes that do not exercise binary download still compile without
/// declaring the member; the real <see cref="YavscApiClient"/>
/// overrides it with the authenticated transport.
/// </remarks>
Task<byte[]> DownloadAsync(
HttpMethod method,
string path,
CancellationToken ct = default)
=> Task.FromResult(Array.Empty<byte>());
} }

View file

@ -17,8 +17,8 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" /> <ProjectReference Include="../Yavsc.Abstract/Yavsc.Abstract.csproj" />

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

@ -209,9 +209,12 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
/// <summary> /// <summary>
/// Seeds an estimate owned by alice (the provider) for the given /// Seeds an estimate owned by alice (the provider) for the given
/// client, optionally already validated by the client. /// client, optionally already validated by the client. By default the
/// estimate is also provider-validated (i.e. the provider has submitted
/// it); pass <paramref name="providerValidated"/> = false to seed a
/// freshly inserted request the provider hasn't worked on yet.
/// </summary> /// </summary>
private long SeedEstimate(string clientId, bool clientValidated) private long SeedEstimate(string clientId, bool clientValidated, bool providerValidated = true)
{ {
using var scope = _fixture.Services.CreateScope(); using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
@ -229,6 +232,7 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
{ {
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" }, new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
}, },
ProviderValidationDate = providerValidated ? DateTime.UtcNow : default,
ClientValidationDate = clientValidated ? DateTime.UtcNow : default, ClientValidationDate = clientValidated ? DateTime.UtcNow : default,
}; };
db.Estimates.Add(estimate); db.Estimates.Add(estimate);
@ -260,11 +264,34 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
} }
[Fact] [Fact]
public async Task GetOngoingEstimatesAsProvider_returns_only_ongoing_estimates_established_by_the_provider() public async Task GetOngoingEstimatesAsClient_excludes_inserted_requests_not_yet_provider_validated()
{ {
_fixture.ResetAndSeedActivityGraph(); _fixture.ResetAndSeedActivityGraph();
var ongoingId = SeedEstimate(clientId: "bob", clientValidated: false); var submittedId = SeedEstimate(clientId: "bob", clientValidated: false); // provider submitted: visible
SeedEstimate(clientId: "bob", clientValidated: true); // already validated: excluded SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false); // inserted, not worked on: excluded
using var http = NewClient("bob");
var response = await http.GetAsync("/api/v1/estimate/asclient", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items);
Assert.Equal(submittedId, item.GetProperty("id").GetInt64());
}
[Fact]
public async Task GetOngoingEstimatesAsProvider_returns_estimates_the_provider_has_not_yet_signed()
{
_fixture.ResetAndSeedActivityGraph();
// Not yet provider-signed: visible in the provider's "awaiting
// provider signature" list.
var pendingId = SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: false);
// Provider already signed: must leave the list immediately.
SeedEstimate(clientId: "bob", clientValidated: false, providerValidated: true);
using var http = NewClient("alice"); using var http = NewClient("alice");
@ -276,7 +303,7 @@ public sealed class EstimateApiControllerTests : IClassFixture<ApiWebServerFixtu
using var doc = JsonDocument.Parse(body); using var doc = JsonDocument.Parse(body);
var items = doc.RootElement.EnumerateArray().ToArray(); var items = doc.RootElement.EnumerateArray().ToArray();
var item = Assert.Single(items); var item = Assert.Single(items);
Assert.Equal(ongoingId, item.GetProperty("id").GetInt64()); Assert.Equal(pendingId, item.GetProperty("id").GetInt64());
Assert.Equal("alice", item.GetProperty("ownerId").GetString()); Assert.Equal("alice", item.GetProperty("ownerId").GetString());
Assert.Single(item.GetProperty("bill").EnumerateArray()); Assert.Single(item.GetProperty("bill").EnumerateArray());
} }

View file

@ -0,0 +1,191 @@
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Reflection;
using Yavsc.Helpers;
using Yavsc.Models.Billing;
using Yavsc.ViewModels.Gen;
namespace Yavsc.Api.Test;
// Unit test of the estimate PDF generation pipeline (no HTTP fixture
// needed): it exercises TeXHelpers.SignatureToTikz — the vector drawing
// of the provider and client signatures — and TeXHelpers.GenerateEstimatePdf
// — the lualatex stdin compile that writes <name>.pdf into DestDir. The
// TeX source mirrors what the Estimate_tex template emits for a devis
// signed by both parties: two side-by-side minipages, each with a
// tikzpicture bounded by \useasboundingbox and the strokes drawn by
// SignatureToTikz.
//
// The PDF test requires the system `lualatex` (and the TeX Live packages
// listed in the README runtime-deps section) on the host running the
// tests. On hosts without lualatex it skips cleanly rather than failing.
public sealed class EstimatePdfGenerationTests
{
// A provider signature: a downstroke, a cross, and a tap (dot).
// PostIt wire format — [k, x0,y0, x1,y1, …] per stroke, coords in
// [0, 10000], y-down.
private static readonly int[] ProStrokes =
{
4, 2000, 8000, 2000, 5000, 2000, 2000, 2100, 1900,
3, 2000, 5000, 3500, 4500, 3500, 5100,
1, 4000, 3000,
};
// A client signature: a wavy stroke and a shorter one.
private static readonly int[] CliStrokes =
{
5, 1000, 7000, 1500, 4000, 2200, 3000, 3000, 2500, 4000, 1500,
2, 3000, 6000, 4200, 5500,
};
[Fact]
public void SignatureToTikz_RendersBothSignaturesAsVectorDraws()
{
var pro = MakeSig(SignatureType.Pro, ProStrokes);
var cli = MakeSig(SignatureType.Client, CliStrokes);
var proTex = pro.SignatureToTikz(5, 2.5).ToString();
var cliTex = cli.SignatureToTikz(5, 2.5).ToString();
Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", proTex);
Assert.Contains("\\fill", proTex); // the single-point tap → dot
Assert.Contains("(1.000,0.500)", proTex); // first point (2000,8000), y flipped
Assert.Contains("\\draw[line cap=round, line join=round, line width=1.5pt]", cliTex);
Assert.Contains("--", cliTex);
}
[Fact]
public void SignatureToTikz_EmptySignatureIsEmpty()
{
var empty = MakeSig(SignatureType.Pro, Array.Empty<int>());
Assert.Equal("", empty.SignatureToTikz(5, 2.5).ToString());
}
[Fact]
public void GenerateEstimatePdf_WithBothSignatures_ProducesPdfInDestDir()
{
if (!IsLualatexAvailable())
throw Xunit.Sdk.SkipException.ForSkip(
"lualatex is not installed on this host; skipping PDF generation test. " +
"Install the TeX Live runtime deps documented in the README.");
var destDir = Path.Combine(Path.GetTempPath(),
"yavsc-estimate-pdf-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(destDir);
try
{
var pro = MakeSig(SignatureType.Pro, ProStrokes);
var cli = MakeSig(SignatureType.Client, CliStrokes);
var baseName = "estimate-test-both-signed";
var model = new PdfGenerationViewModel
{
TeXSource = BuildEstimateTeX(pro, cli),
BaseFileName = baseName,
DestDir = destDir,
};
var fo = model.GenerateEstimatePdf();
// The PDF must exist, at the expected path, and be non-empty.
var expectedPath = Path.Combine(destDir, baseName + ".pdf");
Assert.True(fo is not null && fo.Exists,
"GenerateEstimatePdf returned a non-existent file. Error: "
+ model.GenerationErrorMessage?.Value);
Assert.Equal(expectedPath, fo.FullName);
Assert.True(fo.Length > 0, "generated PDF is empty");
// Only the PDF should persist in DestDir — aux/log artifacts
// are cleaned up by GenerateEstimatePdf on success.
var leftovers = Directory.GetFiles(destDir)
.Where(p => !Path.GetFileName(p).Equals(baseName + ".pdf", StringComparison.Ordinal))
.ToArray();
Assert.Empty(leftovers);
}
finally
{
if (Directory.Exists(destDir))
Directory.Delete(destDir, recursive: true);
}
}
private static Signature MakeSig(SignatureType type, int[] strokes)
=> new()
{
Type = type,
CoordinateMax = 10_000,
Strokes = strokes,
CapturedAtUtc = new DateTime(2026, 9, 22, 0, 0, 0, DateTimeKind.Utc),
};
// Builds the LaTeX source the Estimate_tex template would emit for a
// devis signed by both parties: same preamble (incl. tikz + bera) and
// the two-minipage signature foot, with the strokes drawn by
// SignatureToTikz.
private static string BuildEstimateTeX(Signature pro, Signature cli)
{
var fr = CultureInfo.CreateSpecificCulture("fr-FR");
var date = new DateTime(2026, 9, 22).ToString("dddd dd MMMM yyyy", fr);
var proTikz = pro.SignatureToTikz(5, 2.5).ToString();
var cliTikz = cli.SignatureToTikz(5, 2.5).ToString();
return $@"\documentclass[french,11pt]{{article}}
\usepackage{{eurosym}}
\usepackage{{babel}}
\usepackage[T1]{{fontenc}}
\usepackage[utf8]{{inputenc}}
\usepackage[a4paper]{{geometry}}
\usepackage{{units}}
\usepackage{{bera}}
\usepackage{{graphicx}}
\usepackage{{fancyhdr}}
\usepackage{{fp}}
\usepackage{{tikz}}
\geometry{{verbose,tmargin=4em,bmargin=8em,lmargin=6em,rmargin=6em}}
\setlength{{\parindent}}{{0pt}}
\begin{{document}}
Devis n°1 --- test signe par les deux parties
\vspace{{2em}}
\noindent
\begin{{minipage}}[t]{{0.45\textwidth}}
\textbf{{Signature du fournisseur}}\\
\small Valide le {date}\\
\begin{{tikzpicture}}
\useasboundingbox (0,0) rectangle (5,2.5);
{proTikz}
\end{{tikzpicture}}
\end{{minipage}}\hfill
\begin{{minipage}}[t]{{0.45\textwidth}}
\textbf{{Signature du client}}\\
\small Valide le {date}\\
\begin{{tikzpicture}}
\useasboundingbox (0,0) rectangle (5,2.5);
{cliTikz}
\end{{tikzpicture}}
\end{{minipage}}
\end{{document}}
";
}
private static bool IsLualatexAvailable()
{
try
{
using var p = Process.Start(new ProcessStartInfo("lualatex", "--version")
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
});
if (p is null) return false;
return p.WaitForExit(5000) && p.ExitCode == 0;
}
catch
{
return false;
}
}
}

View file

@ -1,9 +1,14 @@
using System.Linq;
using System.Net; using System.Net;
using System.Net.Http.Headers; using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures; using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared; using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test; namespace Yavsc.Api.Test;
@ -34,21 +39,95 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
return http; return http;
} }
// Minimal valid PostIt wire-format signature: one stroke of one
// point (k=1, then 2*k coords). See PostIt.Models.SignaturePadData.
private static readonly int[] SampleStrokes = { 1, 5000, 5000 };
private static HttpContent NoSignature() => JsonBody(new { });
private static HttpContent WithSignature(SignatureType? type = null) =>
type is null
? JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000 })
: JsonBody(new { strokes = SampleStrokes, coordinateMax = 10_000, signatureType = type });
private static JsonContent JsonBody(object value) =>
JsonContent.Create(value, mediaType: new MediaTypeHeaderValue("application/json"));
/// <summary>
/// Seeds an Rdv query and, when <paramref name="withEstimate"/> is
/// true, an estimate linked to it via CommandId. The provider is
/// <paramref name="performer"/> (default alice) and the client is
/// <paramref name="client"/> (default bob) — pass the same value
/// for both to model a user who is both parties. Returns the query
/// id and (when seeded) the estimate id.
/// </summary>
private (long queryId, long estimateId) SeedQuery(
bool withEstimate,
string performer = "alice",
string client = "bob")
{
WorkflowHelpers.ConfigureBillingService();
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var location = db.Locations.Single(l => l.Address == "1 rue du Test");
var query = new RdvQuery
{
ActivityCode = "dev",
ClientId = client,
PerformerId = performer,
Consent = true,
UserCreated = performer,
UserModified = performer,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Initial rendez-vous",
Status = Yavsc.QueryStatus.Inserted,
};
db.RdvQueries.Add(query);
db.SaveChanges();
long estimateId = 0;
if (withEstimate)
{
var estimate = new Estimate
{
CommandId = query.Id,
ClientId = client,
OwnerId = performer,
CommandType = BillingCodes.Rdv,
Title = "Devis prestation",
Description = "Devis de test",
AttachedFiles = new List<string>(),
AttachedGraphics = new List<string>(),
Bill = new List<CommandLine>
{
new() { Name = "Prestation", Description = "Prestation de base", Count = 1, UnitaryCost = 120m, Currency = "EUR" },
},
};
db.Estimates.Add(estimate);
db.SaveChanges();
estimateId = estimate.Id;
}
return (query.Id, estimateId);
}
[Fact] [Fact]
public async Task Front_accept_query_updates_status_without_server_error() public async Task Front_accept_query_updates_status_without_server_error()
{ {
WorkflowHelpers.ConfigureBillingService(); var (queryId, _) = SeedQuery(withEstimate: false);
_fixture.ResetAndSeedRdvQueryGraph();
long queryId;
using (var scope = _fixture.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
queryId = db.RdvQueries.Select(q => q.Id).Single();
}
using var http = NewClient(); using var http = NewClient();
var response = await http.PostAsync($"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}", content: null, TestContext.Current.CancellationToken); var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
NoSignature(),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken); var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}"); Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
@ -57,6 +136,207 @@ public sealed class FrontOfficeApiControllerTests : IClassFixture<ApiWebServerFi
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>(); var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var updated = assertDb.RdvQueries.Single(q => q.Id == queryId); var updated = assertDb.RdvQueries.Single(q => q.Id == queryId);
Assert.Equal(QueryStatus.Accepted, updated.Status); // Default caller is "alice" (the provider) → ProAccepted.
Assert.Equal(QueryStatus.ProAccepted, updated.Status);
} }
}
[Fact]
public async Task Provider_accept_with_signature_sets_ProValidationDate_and_Pro_signature()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.ProAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ProviderValidationDate != default);
Assert.True(estimate.ClientValidationDate == default);
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Pro, signature.Type);
Assert.Equal("alice", signature.SignerId);
}
[Fact]
public async Task Client_accept_with_signature_sets_ClientValidationDate_and_Client_signature()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
using var http = NewClient(subject: "bob");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ClientValidationDate != default);
Assert.True(estimate.ProviderValidationDate == default);
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Client, signature.Type);
Assert.Equal("bob", signature.SignerId);
}
[Fact]
public async Task Reject_sets_status_Rejected()
{
var (queryId, _) = SeedQuery(withEstimate: false);
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
NoSignature(),
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.Rejected, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
}
[Fact]
public async Task Non_party_actor_is_forbidden()
{
var (queryId, _) = SeedQuery(withEstimate: false);
using var http = NewClient(subject: "carol");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
NoSignature(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Accept_with_signature_but_no_estimate_returns_400()
{
var (queryId, _) = SeedQuery(withEstimate: false);
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Reject_with_signature_returns_400()
{
var (queryId, _) = SeedQuery(withEstimate: true);
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/reject?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task Estimate_payload_carries_existing_signature_strokes()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true);
// Provider accepts with a signature.
using (var pro = NewClient(subject: "alice"))
{
var r = await pro.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(),
TestContext.Current.CancellationToken);
Assert.True(r.IsSuccessStatusCode, await r.Content.ReadAsStringAsync(TestContext.Current.CancellationToken));
}
// The estimate read endpoint carries the signature strokes on
// the estimate payload, so PostIt can repaint them on reopen.
using var reader = NewClient(subject: "alice");
var response = await reader.GetAsync(
$"/api/v1/estimate/{estimateId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<JsonElement>(TestContext.Current.CancellationToken);
// Signatures ride on the estimate payload as two dedicated
// slots, not a list: the provider signed here, so signaturePro
// is populated and signatureClient is absent/null.
var proSig = body.GetProperty("signaturePro");
Assert.Equal(JsonValueKind.Object, proSig.ValueKind);
Assert.Equal(JsonValueKind.Array, proSig.GetProperty("strokes").ValueKind);
Assert.True(body.TryGetProperty("signatureClient", out var cli)
&& cli.ValueKind == JsonValueKind.Null,
"signatureClient should be null when only the provider has signed");
}
// When one user is both the provider and the client of a query, the
// server cannot infer which side they are signing as from identity
// alone (it would always pick the provider). The caller declares the
// side (signatureType=1 here = Client); the server honors it because
// the caller is that party, and stores a Client signature.
[Fact]
public async Task Accept_declared_client_side_stores_client_signature_when_user_is_both_parties()
{
var (queryId, estimateId) = SeedQuery(withEstimate: true, performer: "alice", client: "alice");
using var http = NewClient(subject: "alice");
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(SignatureType.Client), // signing as the client
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK, $"Unexpected status {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var assertScope = _fixture.Services.CreateScope();
var assertDb = assertScope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
Assert.Equal(QueryStatus.ClientAccepted, assertDb.RdvQueries.Single(q => q.Id == queryId).Status);
var estimate = assertDb.Estimates.Single(e => e.Id == estimateId);
Assert.True(estimate.ClientValidationDate != default);
Assert.True(estimate.ProviderValidationDate == default);
var signature = assertDb.Signatures.Single(s => s.EstimateId == estimateId);
Assert.Equal(SignatureType.Client, signature.Type);
Assert.Equal("alice", signature.SignerId);
}
// A provider may not sign as the client (and vice versa): declaring
// the other party's side is refused even though the caller is a
// party to the query. Write is authorized only for the signature's
// author.
[Fact]
public async Task Accept_declaring_other_party_side_is_forbidden()
{
var (queryId, _) = SeedQuery(withEstimate: true); // provider alice, client bob
using var http = NewClient(subject: "alice"); // alice is the provider
var response = await http.PostAsync(
$"/api/v1/front/query/accept?billingCode=Rdv&queryId={queryId}",
WithSignature(SignatureType.Client), // alice tries to sign as the client
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View file

@ -8,8 +8,8 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
@ -19,7 +19,6 @@
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" /> <PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" /> <PackageReference Include="xunit.v3.extensibility.core" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" /> <ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />

View file

@ -157,6 +157,8 @@ namespace Yavsc.ApiControllers
.Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted .Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted || q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.ProAccepted
|| q.Status == QueryStatus.ClientAccepted
|| q.Status == QueryStatus.InProgress) || q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>() .Cast<NominativeServiceCommand>()
.ToList() .ToList()
@ -169,6 +171,8 @@ namespace Yavsc.ApiControllers
.Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted .Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted || q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.ProAccepted
|| q.Status == QueryStatus.ClientAccepted
|| q.Status == QueryStatus.InProgress) || q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>() .Cast<NominativeServiceCommand>()
.ToList() .ToList()
@ -181,6 +185,8 @@ namespace Yavsc.ApiControllers
.Where(q => allowedActivityCodes.Contains(q.ActivityCode)) .Where(q => allowedActivityCodes.Contains(q.ActivityCode))
.Where(q => q.Status == QueryStatus.Inserted .Where(q => q.Status == QueryStatus.Inserted
|| q.Status == QueryStatus.Accepted || q.Status == QueryStatus.Accepted
|| q.Status == QueryStatus.ProAccepted
|| q.Status == QueryStatus.ClientAccepted
|| q.Status == QueryStatus.InProgress) || q.Status == QueryStatus.InProgress)
.Cast<NominativeServiceCommand>() .Cast<NominativeServiceCommand>()
.ToList() .ToList()
@ -329,9 +335,9 @@ namespace Yavsc.ApiControllers
return new ChallengeResult(); return new ChallengeResult();
} }
var filename = AbstractFileSystemHelpers.SignFileNameFormat("pro", billingCode, id); var filename = AbstractFileSystemHelpers.SignFileNameFormat("cli", billingCode, id);
FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename)); FileInfo fi = new FileInfo(Path.Combine(siteSettings.Bills, filename));
if (!fi.Exists) return NotFound(new { Error = "Professional signature not found" }); if (!fi.Exists) return NotFound(new { Error = "Client signature not found" });
return File(fi.OpenRead(), "application/x-pdf", filename); return File(fi.OpenRead(), "application/x-pdf", filename);
} }
@ -401,79 +407,12 @@ namespace Yavsc.ApiControllers
Strokes = body.Strokes, Strokes = body.Strokes,
}; };
// Disk write first: a disk failure shouldn't leave // The Signature row is staged by the shared persister; we
// a Signature row pointing at a file that doesn't // SaveChanges here so the signature write is one
// exist. The file helper throws on filesystem // transaction. The Strokes column is the source of truth —
// problems and propagates here. // there is no on-disk copy to roll back on failure.
FileReceivedInfo fi; var signature = await EstimateSignaturePersister.StageAsync(
try dbContext, id, type, userId, payload, token);
{
fi = await User.ReceiveEstimateSignatureAsync(id, type, payload,
siteSettings, token);
}
catch (Exception ex)
{
logger.LogError(ex, "estimate {Id}: signature file write failed", id);
return BadRequest(new { Error = "file write failed", Detail = ex.Message });
}
// Find-or-add: the (EstimateId, Type) pair is
// unique, so a second POST for the same side of the
// estimate replaces the previous signature. EF
// translates this into a single UPDATE when the
// row exists and an INSERT otherwise; the unique
// index in ApplicationDbContext is the
// database-level guarantee that the contract
// holds if two requests race.
var signature = await dbContext.Signatures
.FirstOrDefaultAsync(s => s.EstimateId == id && s.Type == type, token);
if (signature is null)
{
signature = new Signature
{
EstimateId = id,
SignerId = userId,
Type = type,
};
dbContext.Signatures.Add(signature);
}
else
{
// Roll the signer's quota back by the size of
// the file we're about to orphan: the old
// FilePath is no longer referenced once we
// overwrite FilePath below.
try
{
var orphan = new FileInfo(signature.FilePath);
if (orphan.Exists)
{
var signerForOrphan = await dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId, token);
if (signerForOrphan is not null)
signerForOrphan.DiskUsage =
Math.Max(0, signerForOrphan.DiskUsage - orphan.Length);
}
}
catch { /* best effort — the file is being replaced anyway */ }
}
signature.SignerId = userId;
signature.CoordinateMax = payload.CoordinateMax;
signature.Strokes = payload.Strokes;
signature.CapturedAtUtc = payload.CapturedAtUtc;
signature.FilePath = Path.Combine(fi.DestDir, fi.FileName);
// Bump the signer's quota. The Signature row's
// SignerId is the IdentityUser.Id (a string), so we
// look up by Id and not by username.
var signer = await dbContext.Users
.FirstOrDefaultAsync(u => u.Id == userId, token);
if (signer is not null)
{
signer.DiskUsage += new FileInfo(signature.FilePath).Length;
}
try try
{ {
@ -482,10 +421,6 @@ namespace Yavsc.ApiControllers
catch (Exception ex) catch (Exception ex)
{ {
logger.LogError(ex, "estimate {Id}: signature db write failed", id); logger.LogError(ex, "estimate {Id}: signature db write failed", id);
// Best-effort rollback: remove the file we wrote
// so disk and db don't disagree.
try { System.IO.File.Delete(signature.FilePath); }
catch { /* swallow — the row will be re-orphaned, the user re-signs */ }
return BadRequest(new { Error = "db write failed", Detail = ex.Message }); return BadRequest(new { Error = "db write failed", Detail = ex.Message });
} }

View file

@ -40,7 +40,7 @@ namespace Yavsc.Controllers
else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ; else if (!UserIsAdminOrThis(ownerId)) // throw new Exception("Not authorized") ;
// or just do nothing // or just do nothing
return new StatusCodeResult(StatusCodes.Status403Forbidden); return new StatusCodeResult(StatusCodes.Status403Forbidden);
return Ok(_context.Estimates.Include(e => e.Bill).Where(e => e.OwnerId == ownerId)); return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Where(e => e.OwnerId == ownerId));
} }
// GET: api/Estimate/5 // GET: api/Estimate/5
[HttpGet("{id}", Name = "GetEstimate")] [HttpGet("{id}", Name = "GetEstimate")]
@ -51,7 +51,7 @@ namespace Yavsc.Controllers
return BadRequest(ModelState); return BadRequest(ModelState);
} }
Estimate estimate = _context.Estimates.Include(e => e.Bill).Single(m => m.Id == id); Estimate estimate = _context.Estimates.Include(e => e.Bill).Include(e => e.Signatures).Single(m => m.Id == id);
if (estimate == null) if (estimate == null)
{ {
@ -201,23 +201,31 @@ namespace Yavsc.Controllers
// GET: api/estimate/asclient // GET: api/estimate/asclient
// Ongoing estimates (not yet validated by the client) where the // Ongoing estimates (not yet validated by the client) where the
// current user is the client who initiated the request. // current user is the client who initiated the request.
// An estimate only becomes visible to the client once the provider
// has actually established and submitted it (ProviderValidationDate);
// a freshly inserted request the provider hasn't worked on yet is
// not something the client can validate, so it's excluded.
[HttpGet("asclient")] [HttpGet("asclient")]
public IActionResult GetOngoingEstimatesAsClient() public IActionResult GetOngoingEstimatesAsClient()
{ {
var uid = User.GetUserId(); var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill) return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
.Where(e => e.ClientId == uid && e.ClientValidationDate == default)); .Where(e => e.ClientId == uid
&& e.ProviderValidationDate != default
&& e.ClientValidationDate == default));
} }
// GET: api/estimate/asprovider // GET: api/estimate/asprovider
// Ongoing estimates (not yet validated by the client) established // Estimates awaiting the provider's signature, established by the
// by the current user as the provider. // current user as the provider. Once the provider validates
// (ProviderValidationDate stamped), the estimate leaves this list
// and appears in the client's "awaiting client signature" list.
[HttpGet("asprovider")] [HttpGet("asprovider")]
public IActionResult GetOngoingEstimatesAsProvider() public IActionResult GetOngoingEstimatesAsProvider()
{ {
var uid = User.GetUserId(); var uid = User.GetUserId();
return Ok(_context.Estimates.Include(e => e.Bill) return Ok(_context.Estimates.Include(e => e.Bill).Include(e => e.Signatures)
.Where(e => e.OwnerId == uid && e.ClientValidationDate == default)); .Where(e => e.OwnerId == uid && e.ProviderValidationDate == default));
} }
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)

View file

@ -1,10 +1,17 @@
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Razor;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using Yavsc.Abstract.Workflow;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice; using Yavsc.ViewModels.FrontOffice;
using Yavsc.ViewModels.Gen;
namespace Yavsc.ApiControllers namespace Yavsc.ApiControllers
{ {
@ -13,13 +20,38 @@ namespace Yavsc.ApiControllers
public class FrontOfficeApiController : Controller public class FrontOfficeApiController : Controller
{ {
ApplicationDbContext dbContext; ApplicationDbContext dbContext;
private readonly IBillingService billing;
private readonly ILogger logger;
private readonly SiteSettings siteSettings;
private readonly IRazorViewEngine viewEngine;
private IBillingService billing; // The view-rendering dependencies (siteSettings, viewEngine) are
// optional: only EstimateTex/EstimatePdf use them, and those run in
public FrontOfficeApiController(ApplicationDbContext context, IBillingService billing = null) // the Api host where Program.cs wires AddControllersWithViews +
// SiteSettings. The accept/reject endpoints (and the Api integration
// test host, which composes with AddControllers alone) never touch
// them, so they must not be required for controller activation.
//
// .NET 10 does not register a single IViewEngine service (the view
// engine is registered as IRazorViewEngine and surfaced through
// MvcViewOptions.ViewEngines). IRazorViewEngine (RazorViewEngine)
// implements IViewEngine, so we inject that and hand it to
// TeXHelpers.RenderViewToString as an IViewEngine. RenderViewToString
// builds its ActionContext from the controller's own ControllerContext,
// so no IActionContextAccessor is needed (and that type is deprecated
// in .NET 10).
public FrontOfficeApiController(
ApplicationDbContext context,
ILoggerFactory loggerFactory,
IOptions<SiteSettings> siteSettings = null,
IRazorViewEngine viewEngine = null,
IBillingService billing = null)
{ {
dbContext = context; dbContext = context;
this.billing = billing ?? new BillingService(context); this.billing = billing ?? new BillingService(context);
logger = loggerFactory.CreateLogger<FrontOfficeApiController>();
this.siteSettings = siteSettings?.Value;
this.viewEngine = viewEngine;
} }
[HttpGet("profiles/{actCode}")] [HttpGet("profiles/{actCode}")]
@ -28,29 +60,313 @@ namespace Yavsc.ApiControllers
return await dbContext.ListPerformersAsync(billing, actCode); return await dbContext.ListPerformersAsync(billing, actCode);
} }
[HttpPost("query/reject")] // GET: api/front/query/{queryId}/estimate.tex
public IActionResult RejectQuery(string billingCode, long queryId) //
// Renders the estimate linked to the query (Estimate.CommandId ==
// queryId) as a LaTeX source document. The view is a generation
// template (Layout = "null"), not a web page: it is returned with
// a text/x-tex content type so the caller can save it as a .tex
// file. Either party (client or provider) or an admin may read it.
[HttpGet("query/{queryId}/estimate.tex")]
public async Task<IActionResult> EstimateTex(long queryId, CancellationToken token)
{ {
if (billingCode == null) return BadRequest("billingCode"); if (viewEngine is null || siteSettings is null)
if (queryId == 0) return BadRequest("queryId"); return Problem("TeX rendering is not configured on this host.",
var billing = BillingService.GetBillable(dbContext, billingCode, queryId); statusCode: StatusCodes.Status503ServiceUnavailable);
if (billing == null) return BadRequest();
billing.Status = QueryStatus.Rejected; var estimate = await LoadEstimateForRenderAsync(queryId, token);
dbContext.SaveChanges(User.GetUserId()); if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
return Ok();
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName)
&& uid != estimate.ClientId && uid != estimate.OwnerId)
return Forbid();
PrepareEstimateViewBag(estimate);
Response.ContentType = "text/x-tex";
return View("Estimate_tex", estimate);
} }
// GET: api/front/query/{queryId}/estimate.pdf
//
// Renders the same LaTeX template to a string, compiles it to PDF
// via texi2pdf (see SiteSettings.GenerateEstimatePdf), and streams
// the generated PDF bytes back. Requires /usr/bin/texi2pdf on the
// server host; on hosts without it the endpoint returns a problem.
[HttpGet("query/{queryId}/estimate.pdf")]
public async Task<IActionResult> EstimatePdf(long queryId, CancellationToken token)
{
if (viewEngine is null || siteSettings is null)
{
string msg = "PDF rendering is not configured on this host.";
logger.LogError(msg);
return Problem(msg,
statusCode: StatusCodes.Status503ServiceUnavailable);
}
var estimate = await LoadEstimateForRenderAsync(queryId, token);
if (estimate is null) return NotFound(new { Error = "no estimate linked to this query" });
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName)
&& uid != estimate.ClientId && uid != estimate.OwnerId)
return Forbid();
PrepareEstimateViewBag(estimate);
var baseFileName = $"estimate-{estimate.Id}";
// Resolve the bills dir to an absolute path once: appsettings
// configures it as a relative path ("bills"), and both the
// generation (GenerateEstimatePdf) and the read-back below must
// agree on the same location regardless of the host's cwd.
var billsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName;
var model = new PdfGenerationViewModel
{
BaseFileName = baseFileName,
DestDir = billsDir,
Temp = siteSettings.TempDir,
};
try
{
// 1. Render the LaTeX template (Estimate_tex) to a TeX source
// string, using the estimate as the view model.
model.TeXSource = this.RenderViewToString(viewEngine, "Estimate_tex", estimate);
// 2. Render the generation view (Estimate_pdf). Its @{} block
// calls SiteSettings.GenerateEstimatePdf(Model), which
// compiles the TeX to PDF on disk as a side effect of the
// render. The HTML the view emits is not what we return —
// we stream the generated PDF bytes — but driving the
// generation through the view keeps the template as the
// output producer, per the original design.
if (model.GenerateEstimatePdf() is FileInfo fo && fo.Exists)
{
return File(fo.OpenRead(), "application/pdf");
}
string msg = "TeX render failed: " + model.GenerationErrorMessage;
logger.LogError(msg);
return Problem(msg);
}
catch (Exception ex)
{
logger.LogError(ex, "estimate {QueryId}: TeX/PDF render failed", queryId);
return Problem("TeX render failed: " + ex.Message);
}
}
// Loads the estimate linked to a query with the navigation the
// Estimate_tex template reads (client + performer profile + bill
// + the captured signatures, drawn as tikz at the foot of the devis).
private Task<Estimate?> LoadEstimateForRenderAsync(long queryId, CancellationToken token)
=> dbContext.Estimates
.Where(e => e.CommandId == queryId)
.OrderByDescending(e => e.Id)
.Include(e => e.Bill)
.Include(e => e.Signatures)
.Include(e => e.Query).ThenInclude(q => q!.Client).ThenInclude(c => c!.PostalAddress)
.Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.OrganizationAddress)
.Include(e => e.Query).ThenInclude(q => q!.PerformerProfile).ThenInclude(p => p!.Performer)
.FirstOrDefaultAsync(token);
// Sets the ViewBag keys the Estimate_tex template reads. A devis
// is never a bill (AsBill=false) and is never acquitted.
private void PrepareEstimateViewBag(Estimate estimate)
{
ViewBag.AsBill = false;
ViewBag.Acquitted = false;
ViewBag.BillsDir = new System.IO.DirectoryInfo(siteSettings.Bills).FullName;
ViewBag.AvatarsDir = new System.IO.DirectoryInfo(siteSettings.Avatars).FullName;
}
// POST: api/front/query/accept
//
// Either party of the query may accept: the provider
// (PerformerId) accepts the client's request, or the client
// (ClientId) accepts the provider's estimate. An optional
// signature (the PostIt wire format) may be sent along; it is
// persisted on the estimate linked to the query
// (Estimate.CommandId == queryId), and the matching validation
// date is stamped — ProviderValidationDate when the provider
// accepts, ClientValidationDate when the client accepts.
[HttpPost("query/accept")] [HttpPost("query/accept")]
public IActionResult AcceptQuery(string billingCode, long queryId) public async Task<IActionResult> AcceptQuery(
string billingCode,
long queryId,
[FromBody] QueryAcceptanceRequest? body,
CancellationToken token)
{ {
if (billingCode == null) return BadRequest("billingCode"); var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
if (queryId == 0) return BadRequest("queryId"); if (error is not null) return error;
var billing = BillingService.GetBillable(dbContext, billingCode, queryId); if (query is null) return BadRequest(new { Error = "query not found" });
if (billing == null) return BadRequest();
billing.Status = QueryStatus.Accepted; Signature? signature = null;
dbContext.SaveChanges(User.GetUserId()); // actingRole is the role the caller is signing / accepting
return Ok(); // as. It defaults to the role inferred from the caller's
// identity, but when a signature is submitted the caller
// declares the side (Pro/Client) and the server honors it
// only if the caller is that party. This disambiguates a
// user who is both parties (where inference would always
// pick the provider) and makes "write only for its author"
// explicit.
ActorRole actingRole = role;
if (HasSignature(body))
{
// Admins bypass authorization but are neither party of
// the estimate, so they cannot sign.
if (role == ActorRole.Admin)
return BadRequest(new { Error = "admin cannot sign on behalf of a party" });
var estimate = await dbContext.Estimates
.Where(e => e.CommandId == queryId)
.OrderByDescending(e => e.Id)
.FirstOrDefaultAsync(token);
if (estimate is null)
return BadRequest(new { Error = "no estimate linked to this query" });
var uid = User.GetUserId();
bool isProvider = query.PerformerId == uid;
bool isClient = query.ClientId == uid;
// The caller declares which side they sign as. Null
// falls back to the role inferred from identity.
var declared = body!.SignatureType;
if (declared.HasValue)
{
if (declared.Value == SignatureType.Pro && !isProvider)
return Forbid();
if (declared.Value == SignatureType.Client && !isClient)
return Forbid();
actingRole = declared.Value == SignatureType.Pro
? ActorRole.Provider
: ActorRole.Client;
}
var type = actingRole == ActorRole.Provider
? SignatureType.Pro
: SignatureType.Client;
var payload = new SignaturePadPayload
{
CoordinateMax = body.CoordinateMax,
CapturedAtUtc = body.CapturedAtUtc ?? DateTime.UtcNow,
Strokes = body.Strokes!,
};
try
{
signature = await EstimateSignaturePersister.StageAsync(
dbContext, estimate.Id, type, uid, payload, token);
}
catch (Exception ex)
{
logger.LogError(ex, "query {QueryId}: signature stage failed", queryId);
return BadRequest(new { Error = "signature stage failed", Detail = ex.Message });
}
if (actingRole == ActorRole.Provider)
estimate.ProviderValidationDate = DateTime.UtcNow;
else
estimate.ClientValidationDate = DateTime.UtcNow;
}
// The party-specific status records who accepted: the
// provider (ProAccepted) or the client (ClientAccepted).
// An admin accepting (neither party) falls back to the
// legacy generic Accepted.
query.Status = actingRole switch
{
ActorRole.Provider => QueryStatus.ProAccepted,
ActorRole.Client => QueryStatus.ClientAccepted,
_ => QueryStatus.Accepted,
};
try
{
await dbContext.SaveChangesAsync(User.GetUserId(), token);
}
catch (Exception ex)
{
logger.LogError(ex, "query {QueryId}: accept db write failed", queryId);
return BadRequest(new { Error = "db write failed", Detail = ex.Message });
}
return Ok(new
{
queryId,
status = query.Status.ToString(),
signature = signature is null
? null
: new
{
id = signature.Id,
estimateId = signature.EstimateId,
type = signature.Type.ToString(),
},
});
} }
// POST: api/front/query/reject
//
// Either party may reject. A signature is not meaningful on a
// rejection, so one is refused with 400.
[HttpPost("query/reject")]
public async Task<IActionResult> RejectQuery(
string billingCode,
long queryId,
[FromBody] QueryAcceptanceRequest? body,
CancellationToken token)
{
if (HasSignature(body))
return BadRequest(new { Error = "signature not allowed on reject" });
var (query, role, error) = await ResolveAndAuthorizeAsync(billingCode, queryId, token);
if (error is not null) return error;
if (query is null) return BadRequest(new { Error = "query not found" });
query.Status = QueryStatus.Rejected;
await dbContext.SaveChangesAsync(User.GetUserId(), token);
return Ok(new
{
queryId,
status = query.Status.ToString(),
});
}
// Loads the query and checks that the caller is a party to it
// (provider or client) or an admin. Returns the role the caller
// is acting in; Admin is only returned when the caller is
// neither party but is an admin.
private async Task<(IQuery? Query, ActorRole Role, IActionResult? Error)>
ResolveAndAuthorizeAsync(string billingCode, long queryId, CancellationToken token)
{
if (string.IsNullOrWhiteSpace(billingCode))
return (null, ActorRole.None, BadRequest("billingCode"));
if (queryId == 0)
return (null, ActorRole.None, BadRequest("queryId"));
var query = await billing.GetBillAsync(billingCode, queryId);
if (query is null)
return (null, ActorRole.None, BadRequest());
var uid = User.GetUserId();
bool isProvider = query.PerformerId == uid;
bool isClient = query.ClientId == uid;
if (isProvider) return (query, ActorRole.Provider, null);
if (isClient) return (query, ActorRole.Client, null);
if (User.IsInRole(Constants.AdminGroupName))
return (query, ActorRole.Admin, null);
return (null, ActorRole.None, Forbid());
}
private static bool HasSignature(QueryAcceptanceRequest? body)
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0;
} }
internal enum ActorRole { None, Provider, Client, Admin }
} }

View file

@ -0,0 +1,325 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Yavsc.Models.Billing;
namespace Yavsc.Helpers
{
using Microsoft.AspNetCore.Mvc.Razor;
using ViewModels.Gen;
public class TeXString : HtmlString
{
public TeXString(TeXString teXString): base(teXString.ToString())
{
}
public TeXString(string str) : base(str)
{
}
public static TeXString operator+ (TeXString a, TeXString b) {
return new TeXString(a.ToString()+b.ToString());
}
}
public class Replacement
{
string target;
string replacement;
public Replacement(string target, string replacement)
{
this.target = target;
this.replacement = replacement;
}
public string Execute(string source)
{
return source?.Replace(target, replacement) ?? null;
}
}
public static class TeXHelpers
{
public static readonly Replacement[] SpecialCharsDefaultRendering =
{
new Replacement("<","\\textless"),
new Replacement(">","\\textgreater"),
new Replacement("©","\\copyright"),
new Replacement("®","\\textregistered"),
new Replacement("\\","\\textbackslash"),
new Replacement("™","\\texttrademark"),
new Replacement("¶","\\P"),
new Replacement("|","\\textbar"),
new Replacement("%","\\%"),
new Replacement("{","\\{"),
new Replacement("}","\\}"),
new Replacement("_","\\_"),
new Replacement("#","\\#"),
new Replacement("$","\\$"),
new Replacement("_","\\_"),
new Replacement("¿","\\textquestiondown"),
new Replacement("§","\\S"),
new Replacement("£","\\pounds"),
new Replacement("&","\\&"),
new Replacement("¡","\\textexclamdown"),
new Replacement("†","\\dag"),
new Replacement("–","\\textendash"),
new Replacement("°","\\textdegree")
};
public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}")
{
if (source==null) return new TeXString(defaultValue);
string result=source;
foreach (var r in SpecialCharsDefaultRendering)
{
result = r.Execute(result);
}
return new TeXString(result);
}
public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}")
{
if (source==null) return new TeXString(defaultValue);
string result=source;
foreach (var r in SpecialCharsDefaultRendering)
{
result = r.Execute(result);
}
result = result.Replace("\n","\\tabularnewline ");
return new TeXString(result);
}
public static string NewLinesWith(this string target, string separator)
{
var items = target.Split(new char[] { '\n' }).Where(
s => !string.IsNullOrWhiteSpace(s));
return string.Join(separator, items);
}
public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\")
{
if (source == null) return new TeXString(defaultValue);
return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) );
}
public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}")
{
if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue);
var alines = source.Split(',');
var texlines = alines.Select(l=>l.ToTeX().ToString());
return new TeXString(string.Join(lineSeparator,texlines));
}
/// <summary>
/// Renders a captured signature as TikZ <c>\draw</c>/<c>\fill</c>
/// paths, suitable for embedding in a <c>tikzpicture</c> at the
/// foot of a devis. The signature is the PostIt wire format — a
/// length-prefixed sequence of strokes, each stroke being
/// <c>[k, x0, y0, x1, y1, …]</c> with coordinates normalised to
/// <c>[0, CoordinateMax]</c> and y growing downward (screen
/// orientation). We denormalise to a <paramref name="widthCm"/> ×
/// <paramref name="heightCm"/> canvas and flip y so the signature
/// appears upright on paper. A single-point stroke (a pen-down
/// tap) is rendered as a filled dot, mirroring the round-capped
/// <c>Polyline</c> the Avalonia pad draws for it.
/// </summary>
/// <param name="sig">The persisted signature. Null/empty yields
/// <see cref="HtmlString.Empty"/>, so the caller can gate the
/// <c>tikzpicture</c> on a non-empty result.</param>
public static HtmlString SignatureToTikz(
this Signature sig, double widthCm, double heightCm)
{
if (sig is null || sig.Strokes is null || sig.Strokes.Length == 0)
return HtmlString.Empty;
double max = sig.CoordinateMax > 0 ? sig.CoordinateMax : 10_000.0;
var strokes = sig.Strokes;
var sb = new StringBuilder();
int i = 0;
while (i < strokes.Length)
{
int k = strokes[i];
if (k <= 0) break;
if (i + 1 + 2 * k > strokes.Length) break;
i++; // skip the length prefix
// A single-point stroke: a tap. Draw a round dot so it
// shows instead of a zero-length, invisible \draw.
if (k == 1)
{
double px = strokes[i] / max * widthCm;
double py = heightCm - strokes[i + 1] / max * heightCm;
sb.Append(CultureInfo.InvariantCulture,
$"\\fill ({px:F3},{py:F3}) circle (0.8pt); ");
i += 2;
continue;
}
sb.Append("\\draw[line cap=round, line join=round, line width=1.5pt] ");
for (int p = 0; p < k; p++)
{
double px = strokes[i + 2 * p] / max * widthCm;
double py = heightCm - strokes[i + 2 * p + 1] / max * heightCm;
if (p == 0) sb.Append(CultureInfo.InvariantCulture,
$"({px:F3},{py:F3})");
else sb.Append(CultureInfo.InvariantCulture,
$" -- ({px:F3},{py:F3})");
}
sb.Append("; ");
i += 2 * k;
}
return new HtmlString(sb.ToString());
}
public static FileInfo GenerateEstimatePdf(this PdfGenerationViewModel Model)
{
string errorMsg = null;
// Resolve to an absolute path: SiteSettings.Bills may be a
// relative path (e.g. "bills"), and lualatex resolves
// -output-directory against its working directory — passing a
// relative path to both would nest them (<cwd>/bills/bills) and
// the log/pdf write would fail.
var billdir = new DirectoryInfo(Model.DestDir).FullName;
string name = Model.BaseFileName;
FileInfo fo = new FileInfo(System.IO.Path.Combine(billdir, name + ".pdf"));
System.IO.Directory.CreateDirectory(billdir);
// Compile the LaTeX source with the system lualatex, feeding it
// the TeX on standard input (no intermediate .tex file) and
// directing the resulting <name>.pdf straight into the bills
// directory. lualatex is a file-based engine — it cannot emit
// the PDF on stdout — so the pdf lands at its final destination
// and we only clean the transient aux/log artifacts beside it.
//
// We pass "/dev/stdin" as the input filename so lualatex treats
// stdin as a regular input *file* rather than the terminal: with
// no filename argument it reads stdin in terminal mode, which
// emergency-stops after the first line of a multi-line document.
// /dev/stdin makes the full multi-line TeX compile cleanly. (The
// API host is Linux — the systemd service and lualatex dep are
// Linux-only too.)
//
// We capture stdout/stderr so a LaTeX error (missing .sty,
// missing \includegraphics target, …) is surfaced in
// GenerationErrorMessage instead of being buried in the .log
// we then delete.
int exitCode;
string stdout = null, stderr = null;
try
{
using (Process p = new Process())
{
p.StartInfo = new ProcessStartInfo
{
UseShellExecute = false,
WorkingDirectory = billdir,
FileName = "lualatex",
Arguments = $"-interaction=nonstopmode -halt-on-error"
+ $" -jobname=\"{name}\" -output-directory=\"{billdir}\""
+ " /dev/stdin",
RedirectStandardInput = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
p.Start();
// Write the TeX source, then drain stdout/stderr. Read
// them after WaitForExit — lualatex's output is small
// (a few KB), well under the pipe buffer, so no deadlock.
using (var stdin = p.StandardInput)
{
stdin.Write(Model.TeXSource);
}
p.WaitForExit();
exitCode = p.ExitCode;
stdout = p.StandardOutput.ReadToEnd();
stderr = p.StandardError.ReadToEnd();
}
}
catch (Exception ex)
{
errorMsg = "lualatex invocation failed: " + ex.Message;
exitCode = -1;
}
if (exitCode != 0 && errorMsg == null)
{
// Surface the engine's own diagnostics (stderr carries the
// "! Emergency stop" / "! LaTeX Error: …" lines; stdout has
// the transcript banner) so the caller can see why it failed
// without having to find the (soon-deleted) .log file.
var diag = string.IsNullOrWhiteSpace(stderr) ? stdout : stderr;
errorMsg = $"Pdf generation failed with exit code {exitCode}: {diag}";
}
else if (!fo.Exists && errorMsg == null)
errorMsg = "Pdf generation produced no output";
// Remove the LaTeX build artifacts left beside the pdf so only
// <name>.pdf persists in the bills directory. Keep the .log on
// failure so it can be inspected.
foreach (var ext in new[] { ".aux", ".log", ".out", ".fls",
".fdb_latexmk", ".synctex.gz", ".toc" })
{
if (ext == ".log" && !fo.Exists) continue;
var fileName = name + ext;
var f = new FileInfo(System.IO.Path.Combine(billdir, fileName));
if (f.Exists) { try { f.Delete(); } catch { } }
}
Model.Generated = fo.Exists;
Model.GenerationErrorMessage = new HtmlString(errorMsg);
return fo;
}
public static string RenderViewToString(
this Controller controller, IRazorViewEngine engine,
string viewName, object model, bool isMainPage = true)
{
if (engine == null)
throw new InvalidOperationException("no engine");
// Build an ActionContext from the controller's own context —
// .NET 10 deprecated IActionContextAccessor, and the controller
// already holds the live request context on its ControllerContext.
var controllerContext = controller.ControllerContext;
var actionContext = new ActionContext(
controllerContext.HttpContext,
controllerContext.RouteData,
controllerContext.ActionDescriptor);
ViewEngineResult viewResult = engine.FindView(actionContext, viewName, isMainPage);
if (!viewResult.Success)
throw new InvalidOperationException(
$"View '{viewName}' not found. Searched locations: "
+ string.Join(", ", viewResult.SearchedLocations));
// Hand the model to the view through ViewData so the template's
// @model directive resolves it (the previous implementation never
// set ViewData.Model, leaving Model null inside the template).
controller.ViewData.Model = model;
using (var sw = new StringWriter())
{
var viewContext = new ViewContext(
actionContext,
viewResult.View,
controller.ViewData,
controller.TempData,
sw,
new HtmlHelperOptions());
viewResult.View.RenderAsync(viewContext).GetAwaiter().GetResult();
return sw.GetStringBuilder().ToString();
}
}
}
}

View file

@ -55,7 +55,11 @@ internal class Program
}); });
}) })
.AddYavscCors(builder.Configuration) .AddYavscCors(builder.Configuration)
.AddControllers(); .AddControllersWithViews();
// Binds the "Site" section (Bills, TempDir, Avatars, …) used by
// the document-generation flow and BillingController.
services.Configure<SiteSettings>(builder.Configuration.GetSection("Site"));
// accepts any access token issued by identity server // accepts any access token issued by identity server
services.AddAuthentication("Bearer") services.AddAuthentication("Bearer")

View file

@ -0,0 +1,40 @@
using Yavsc.Models.Billing;
namespace Yavsc.ViewModels.FrontOffice
{
/// <summary>
/// Body of <c>POST /api/v1/front/query/accept</c> and
/// <c>.../reject</c>. The <see cref="Strokes"/> field is optional:
/// when present (non-empty) on accept, it is the PostIt wire-format
/// signature captured with the acceptance. All fields are optional
/// so a caller may post <c>{}</c> to accept/reject without a
/// signature.
/// </summary>
public class QueryAcceptanceRequest
{
/// <summary>
/// Wire-format strokes. See
/// <c>PostIt.Models.SignaturePadData</c>. Null or empty means
/// no signature.
/// </summary>
public int[]? Strokes { get; set; }
public int CoordinateMax { get; set; } = 10_000;
public DateTime? CapturedAtUtc { get; set; }
/// <summary>
/// Which signature is being submitted:
/// <see cref="SignatureType.Pro"/> (the provider) or
/// <see cref="SignatureType.Client"/>. When <see cref="Strokes"/>
/// is present this declares the side the caller is signing as;
/// the server honors it only if the caller is that party
/// (<c>query.PerformerId</c> for Pro, <c>query.ClientId</c>
/// for Client). This disambiguates the case where one user is
/// both parties, and makes "write only for its author"
/// explicit. When null, the server falls back to the role
/// inferred from the caller's identity.
/// </summary>
public SignatureType? SignatureType { get; set; }
}
}

View file

@ -5,7 +5,7 @@ namespace Yavsc.ViewModels.Gen
{ {
public class PdfGenerationViewModel public class PdfGenerationViewModel
{ {
[Required] [Required]
public string TeXSource { get; set; } public string TeXSource { get; set; }
[Required] [Required]

View file

@ -3,7 +3,7 @@
@using System.Globalization @using System.Globalization
@model IBillable @model IBillable
@{ @{
Layout = "null"; Layout = null;
var pro = ViewBag.PerformerProfile; var pro = ViewBag.PerformerProfile;
var from = ViewBag.Performer; var from = ViewBag.Performer;
var to = ViewBag.Client; var to = ViewBag.Client;

View file

@ -3,7 +3,7 @@
@using System.Globalization @using System.Globalization
@model Estimate @model Estimate
@{ @{
Layout = "null"; Layout = null;
var pro = Model.Query.PerformerProfile; var pro = Model.Query.PerformerProfile;
var from = Model.Query.PerformerProfile.Performer; var from = Model.Query.PerformerProfile.Performer;
var to = Model.Query.Client; var to = Model.Query.Client;
@ -11,7 +11,6 @@
var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX(); var proaddr = Model.Query.PerformerProfile.OrganizationAddress.Address.SplitAddressToTeX();
var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address); var proaddrm = new HtmlString(Model.Query.PerformerProfile.OrganizationAddress.Address);
var isestimate = !ViewBag.AsBill; var isestimate = !ViewBag.AsBill;
var prosign = new FileInfo($"{ViewBag.BillsDir}/estimate-prosign-{Model.Id}.png");
} }
\documentclass[french,11pt]{article} \documentclass[french,11pt]{article}
@ -25,6 +24,7 @@
\usepackage{graphicx} \usepackage{graphicx}
\usepackage{fancyhdr} \usepackage{fancyhdr}
\usepackage{fp} \usepackage{fp}
\usepackage{tikz}
\def\TVA{20} % Taux de la TVA \def\TVA{20} % Taux de la TVA
@ -49,7 +49,7 @@
\global\let\TotalHT\TotalHT \global\let\TotalHT\TotalHT
\global\let\TotalTVA\TotalTVA \global\let\TotalTVA\TotalTVA
\global\let\TotalTTC\TotalTTC \global\let\TotalTTC\TotalTTC
\cr \cr
\hline \hline
\textbf{Total} & & & \TotalHT \textbf{Total} & & & \TotalHT
} }
@ -77,12 +77,12 @@
% Infos Client % Infos Client
\def\ClientNom{@TeXHelpers.ToTeX(to.UserName)} % Nom du client \def\ClientNom{@TeXHelpers.ToTeX(to.UserName)} % Nom du client
\def\ClientAdresse{ \def\ClientAdresse{
% Adresse du client % Adresse du client
@PostalAddress @PostalAddress
@if (PostalAddress!=null) {<text>@PostalAddress</text>} @if (PostalAddress!=null) {<text>@PostalAddress</text>}
@if (!string.IsNullOrWhiteSpace(to.PhoneNumber)) {<text>@TeXHelpers.ToTeX(to.PhoneNumber)<text>\\</text> @if (!string.IsNullOrWhiteSpace(to.PhoneNumber)) {<text>@TeXHelpers.ToTeX(to.PhoneNumber)<text>\\</text>
</text>} </text>}
E-mail: @TeXHelpers.ToTeX(to.Email) E-mail: @TeXHelpers.ToTeX(to.Email)
} }
@ -105,7 +105,7 @@
\renewcommand{\headrulewidth}{0pt} \renewcommand{\headrulewidth}{0pt}
\cfoot{ @TeXHelpers.ToTeX(from.UserName) @if (proaddrm!=null) { <text> - @proaddrm </text> } \newline \cfoot{ @TeXHelpers.ToTeX(from.UserName) @if (proaddrm!=null) { <text> - @proaddrm </text> } \newline
\small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { <text> - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) </text> } \small{ E-mail: @TeXHelpers.ToTeX(from.Email) @if (!string.IsNullOrWhiteSpace(from.PhoneNumber)) { <text> - Téléphone fixe: @TeXHelpers.ToTeX(from.PhoneNumber) </text> }
} }
} }
@ -113,10 +113,10 @@
% Logo de la société % Logo de la société
@if (from.Avatar != null) { @if (from.Avatar != null) {
<text>\includegraphics[height=60pt]{@(ViewBag.AvatarsDir)/@(from.UserName).png} <text>\includegraphics[height=60pt, viewport=0 0 128 128]{@(ViewBag.AvatarsDir)/@(from.UserName).png}
</text> </text>
} else { } else {
<text>%\includegraphics{logo.png} <text>%\includegraphics[height=60pt, viewport=0 0 128 128]{logo.png}
</text> </text>
} }
% Nom et adresse de la société % Nom et adresse de la société
@ -170,12 +170,12 @@
\begin{center} \begin{center}
\begin{tabular}{|c c c c|} \begin{tabular}{|c c c c|}
@if (!string.IsNullOrWhiteSpace(bi.BankCode) && !string.IsNullOrWhiteSpace(bi.WicketCode) @if (!string.IsNullOrWhiteSpace(bi.BankCode) && !string.IsNullOrWhiteSpace(bi.WicketCode)
&& !string.IsNullOrWhiteSpace(bi.AccountNumber) ) { && !string.IsNullOrWhiteSpace(bi.AccountNumber) ) {
<text>\hline \textbf{Code banque} & \textbf{Code guichet} & \textbf{N° de Compte} & \textbf{Clé RIB} \\ <text>\hline \textbf{Code banque} & \textbf{Code guichet} & \textbf{N° de Compte} & \textbf{Clé RIB} \\
@bi.BankCode & @bi.WicketCode & @bi.AccountNumber & @bi.BankedKey \\ @bi.BankCode & @bi.WicketCode & @bi.AccountNumber & @bi.BankedKey \\
</text> </text>
} }
@if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) { @if (!string.IsNullOrWhiteSpace(@bi.IBAN) && !string.IsNullOrWhiteSpace(@bi.BIC)) {
<text> <text>
\hline \textbf{IBAN N°} & \multicolumn{3}{|l|}{ @bi.IBAN } \\ \hline \textbf{IBAN N°} & \multicolumn{3}{|l|}{ @bi.IBAN } \\
\hline \textbf{Code BIC} & \multicolumn{3}{|l|}{ @bi.BIC } \hline \textbf{Code BIC} & \multicolumn{3}{|l|}{ @bi.BIC }
@ -188,19 +188,46 @@
} }
@if (!(Model.ProviderValidationDate==null)) { @{
<text> // Captured signatures, if any. The Estimate.Signatures collection
\begin{flushright} // is loaded by the controller; SignaturePro/SignatureClient project
@(new HtmlString(isestimate?"Devis validé":"Facture validée")) le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy")) // the row by type. We draw them as vector TikZ at the foot of the
\end{flushright} // devis (see TeXHelpers.SignatureToTikz), so there is no PNG to
@if (prosign.Exists) { // produce or \includegraphics to fail on.
<text> var fr = CultureInfo.CreateSpecificCulture("fr-FR");
\begin{center} var proSig = Model.SignaturePro;
\hspace{263pt} var cliSig = Model.SignatureClient;
\includegraphics[height=60pt]{@(ViewBag.BillsDir)/estimate-prosign-@(Model.Id).png}
\end{center}
</text>
} }
</text>
\vspace{2em}
\noindent
\begin{minipage}[t]{0.45\textwidth}
\textbf{Signature du fournisseur}\\
@if (Model.ProviderValidationDate != default(DateTime)) {
<text>\small Validé le @TeXHelpers.ToTeX(Model.ProviderValidationDate.ToString("dddd dd MMMM yyyy", fr))\\</text>
} }
@if (proSig != null && proSig.Strokes != null && proSig.Strokes.Length > 0) {
<text>\begin{tikzpicture}
\useasboundingbox (0,0) rectangle (5,2.5);
@proSig.SignatureToTikz(5, 2.5)
\end{tikzpicture}</text>
} else {
<text>\rule[-2.2cm]{5cm}{0.4pt}</text>
}
\end{minipage}\hfill
\begin{minipage}[t]{0.45\textwidth}
\textbf{Signature du client}\\
@if (Model.ClientValidationDate != default(DateTime)) {
<text>\small Validé le @TeXHelpers.ToTeX(Model.ClientValidationDate.ToString("dddd dd MMMM yyyy", fr))\\</text>
}
@if (cliSig != null && cliSig.Strokes != null && cliSig.Strokes.Length > 0) {
<text>\begin{tikzpicture}
\useasboundingbox (0,0) rectangle (5,2.5);
@cliSig.SignatureToTikz(5, 2.5)
\end{tikzpicture}</text>
} else {
<text>\rule[-2.2cm]{5cm}{0.4pt}</text>
}
\end{minipage}
\end{document} \end{document}

View file

@ -0,0 +1,12 @@
@using Yavsc
@using Yavsc.Helpers
@using Yavsc.Billing
@using Yavsc.Models
@using Yavsc.Models.Billing
@using Yavsc.ViewModels.Gen
@using Microsoft.AspNetCore.Mvc
@using Microsoft.AspNetCore.Html
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@inject Microsoft.Extensions.Options.IOptions<SiteSettings> SiteSettings

View file

@ -7,8 +7,8 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

@ -13,7 +13,7 @@ using static Yavsc.Constants;
namespace Yavsc.Blogs.Tests; namespace Yavsc.Blogs.Tests;
/// <summary> /// <summary>
/// Behavioural tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>: /// Behavioral tests for <c>BlogAclApiController.PostCircleAuthorizationToBlogPost</c>:
/// <c>POST /api/v1/blogacl</c> with a JSON body of /// <c>POST /api/v1/blogacl</c> with a JSON body of
/// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId). /// <c>CircleAuthorizationToBlogPost</c> (CircleId + BlogPostId).
/// ///
@ -84,7 +84,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
// rather than being rewritten to ClaimTypes.NameIdentifier. // rather than being rewritten to ClaimTypes.NameIdentifier.
// The controller, however, reads the user id via // The controller, however, reads the user id via
// User.FindFirstValue(ClaimTypes.NameIdentifier), so we add // User.FindFirstValue(ClaimTypes.NameIdentifier), so we add
// an explicit nameid claim to keep the legacy lookup happy. // an explicit NameIdentifier claim to keep the legacy lookup happy.
http.DefaultRequestHeaders.Authorization = http.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue( new System.Net.Http.Headers.AuthenticationHeaderValue(
"Bearer", "Bearer",
@ -106,7 +106,7 @@ public sealed class BlogAclApiTests : IClassFixture<BlogsWebServerFixture>
/// when <see cref="PostAclDialogViewModel.AddAsync"/> POSTs the /// when <see cref="PostAclDialogViewModel.AddAsync"/> POSTs the
/// shape <c>{ "circleId": &lt;id&gt; }</c> — the exact body the /// shape <c>{ "circleId": &lt;id&gt; }</c> — the exact body the
/// PostIt client builds from <see cref="CircleAuthorization"/> /// PostIt client builds from <see cref="CircleAuthorization"/>
/// (which only carries <c>CircleId</c>). The server deserialises /// (which only carries <c>CircleId</c>). The server deserializes
/// it into <see cref="CircleAuthorizationToBlogPost"/>, leaves /// it into <see cref="CircleAuthorizationToBlogPost"/>, leaves
/// <c>BlogPostId</c> at its <c>default(long) = 0</c>, attaches /// <c>BlogPostId</c> at its <c>default(long) = 0</c>, attaches
/// no <c>Target</c> navigation, and EF Core refuses to INSERT /// no <c>Target</c> navigation, and EF Core refuses to INSERT

View file

@ -0,0 +1,164 @@
using System.Security.Claims;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogspot;
using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Blogs.Tests.Fixtures;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Direct service-level tests for <see cref="BlogSpotService.Index"/>.
///
/// <para>The HTTP tests in <see cref="BlogApiTests"/> all drive the
/// <c>[Authorize("BlogScope")]</c>-protected <c>BlogApiController</c>,
/// so the request is always authenticated by the time it reaches the
/// service. The MVC <c>BlogspotController.Index</c> action, by
/// contrast, is <c>[AllowAnonymous]</c> — that is the one caller that
/// actually exercises the unauthenticated branch of
/// <see cref="BlogSpotService.Index"/>, the branch that reads from
/// <c>blogSpotPublications</c> and projects to <c>p.Post</c>.</para>
///
/// <para>These tests pin that behaviour at the service layer: a
/// <c>BlogPost</c> that has a <c>BlogSpotPublication</c> row is
/// listed for an anonymous viewer, and one without is not. Seeding
/// runs against the same shared SQLite store as the rest of the
/// <c>Yavsc Blogs</c> collection, so the real
/// <see cref="BlogSpotService"/> (registered by
/// <see cref="BlogsWebServerFixture"/>) is resolved from DI and run
/// end-to-end — no mocking.</para>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class BlogSpotServiceIndexTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public BlogSpotServiceIndexTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
/// <summary>An unauthenticated principal: a <see cref="ClaimsIdentity"/>
/// with no authentication type has
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false, so
/// <see cref="BlogSpotService.Index"/> takes its anonymous
/// branch. This mirrors what the JwtBearer middleware hands the
/// MVC <c>[AllowAnonymous]</c> action when no bearer token is
/// presented.</summary>
private static ClaimsPrincipal Anonymous()
=> new(new ClaimsIdentity());
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
/// fixture's DI container and run <paramref name="body"/> against it,
/// disposing the request scope (and its scoped DbContext) afterwards.
/// The service itself is not <see cref="IDisposable"/>; the scope is,
/// so the scope is what we must dispose to avoid leaking a DbContext
/// against the shared SQLite connection across tests.</summary>
private async Task<T> WithServiceAsync<T>(
Func<BlogSpotService, Task<T>> body)
{
using var scope = _fixture.Services.CreateScope();
var service = scope.ServiceProvider.GetRequiredService<BlogSpotService>();
return await body(service);
}
private void Publish(long postId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// The publication flag is the existence of a row in
// blogSpotPublications — same write SetPublishAsync performs
// in production. Inserting it directly is the most faithful
// way to seed "this post is published" without routing
// through the authorize-gated PUT/publish endpoint.
db.blogSpotPublications.Add(new BlogSpotPublication { PostId = postId });
db.SaveChanges();
}
[Fact]
public async Task Index_lists_a_published_post_for_anonymous_access()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var publishedId = _fixture.SeedBlogPost("alice", "Published for all");
Publish(publishedId);
var posts = await WithServiceAsync(async svc =>
{
var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25);
return result.OfType<BlogPost>().ToArray();
});
var listed = Assert.Single(posts);
Assert.Equal("Published for all", listed.Title);
// The service serves IsPublished from the
// blogSpotPublications table — the anonymous branch does
// not Include the Publication navigation, so this proves
// the value is hydrated by the service, not derived from
// the EF graph.
Assert.True(listed.IsPublished);
}
[Fact]
public async Task Index_serves_IsPublished_false_for_a_draft_for_anonymous_access()
{
// Anonymous viewers never receive a draft (the branch
// queries blogSpotPublications), so this is checked through
// the authenticated branch: the service must still serve
// IsPublished == false for a post with no publication row,
// not the default of a freshly loaded entity.
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var draftId = _fixture.SeedBlogPost("alice", "Draft, not published");
var viewer = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim("sub", "alice")], authenticationType: "Bearer"));
var posts = await WithServiceAsync(async svc =>
{
var result = await svc.Index(viewer, id: "", skip: 0, take: 25);
return result.OfType<BlogPost>().ToArray();
});
var draft = Assert.Single(posts);
Assert.Equal(draftId, draft.Id);
Assert.False(draft.IsPublished);
}
[Fact]
public async Task Index_does_not_list_an_unpublished_post_for_anonymous_access()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var publishedId = _fixture.SeedBlogPost("alice", "Published for all");
_fixture.SeedBlogPost("alice", "Draft, not published");
// Only the first post is published; the draft has no
// BlogSpotPublication row.
Publish(publishedId);
var titles = await WithServiceAsync(async svc =>
{
var result = await svc.Index(Anonymous(), id: "", skip: 0, take: 25);
return result.Select(p => p.Title).ToArray();
});
Assert.Contains("Published for all", titles);
Assert.DoesNotContain("Draft, not published", titles);
}
[Fact]
public async Task Index_returns_an_empty_list_for_anonymous_access_when_nothing_is_published()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
// A draft with no publication row: invisible to anonymous.
_fixture.SeedBlogPost("alice", "Draft, not published");
var result = await WithServiceAsync(svc =>
svc.Index(Anonymous(), id: "", skip: 0, take: 25));
Assert.Empty(result);
}
}

View file

@ -1,7 +0,0 @@
<Project>
<!--
Yavsc.Blogs.Tests 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

@ -33,7 +33,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier; JwtSecurityTokenHandler.DefaultInboundClaimTypeMap["sub"] = ClaimTypes.NameIdentifier;
var builder = WebApplication.CreateBuilder(); var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:5104"); builder.WebHost.UseUrls("http://127.0.0.1:5105");
builder.Services.AddDbContext<ApplicationDbContext>(opt => builder.Services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot)); opt.UseInMemoryDatabase("Yavsc.Blogs.Tests.MappedClaims", _inMemoryRoot));
@ -88,7 +88,7 @@ public sealed class MappedClaimsBlogsWebServerFixture : IDisposable, IBackendFix
_app.MapControllers(); _app.MapControllers();
_app.StartAsync().GetAwaiter().GetResult(); _app.StartAsync().GetAwaiter().GetResult();
Addresses = ["http://127.0.0.1:5104"]; Addresses = ["http://127.0.0.1:5105"];
Services = _app.Services; Services = _app.Services;
} }

View file

@ -9,8 +9,8 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
@ -21,7 +21,6 @@
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" /> <PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" /> <PackageReference Include="xunit.v3.extensibility.core" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" /> <ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />

View file

@ -8,8 +8,8 @@
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" /> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" />

View file

@ -1,11 +0,0 @@
<Project>
<!-- Pull in shared package versions from the repository root. -->
<Import Project="$([MSBuild]::GetPathOfFileAbove('Directory.Packages.props', '$(MSBuildThisFileDirectory)../'))" />
<!-- Yavsc.Org.Tests-specific versions -->
<ItemGroup>
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.11" />
</ItemGroup>
</Project>

View file

@ -1,136 +0,0 @@
using System.Security.Claims;
using System.Text.Json;
using Xunit;
using Yavsc.Models.Billing;
using Yavsc.Server.Helpers;
using Yavsc.Server.Models.FileSystem;
namespace Yavsc.Org.Tests;
/// <summary>
/// Tests for the <see cref="EstimateSignatureFileHelper"/> static
/// helper. Scope is intentionally narrow: the file-naming format,
/// the strokes counter, and the on-disk write path. The controller
/// (authz, db persistence, signalR notification) is out of scope
/// for this commit and will get a dedicated integration test once
/// the Yavsc.Api test project is set up.
/// </summary>
public class EstimateSignatureFileHelperTests : IDisposable
{
private readonly string _tempRoot;
private SiteSettings siteSettings;
public EstimateSignatureFileHelperTests()
{
// UserFilesDirName is a process-wide static; we redirect
// it to a per-test temp dir so concurrent tests don't
// collide and the host filesystem is not littered.
_tempRoot = Path.Combine(
Path.GetTempPath(),
"yavsc-sig-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_tempRoot);
this.siteSettings = new SiteSettings { Blog = _tempRoot };
}
public void Dispose()
{
try { Directory.Delete(_tempRoot, recursive: true); }
catch { /* best effort — the OS will clean Temp eventually */ }
}
[Fact]
public void FileNameFormat_lowercases_type_and_includes_estimateId_and_ticks()
{
var name = EstimateSignatureFileHelper.FileNameFormat(
SignatureType.Pro, 42, 638_000_000_000_000_000L);
Assert.Equal("sign-pro-42-638000000000000000.json", name);
var cli = EstimateSignatureFileHelper.FileNameFormat(
SignatureType.Client, 7, 1L);
Assert.Equal("sign-client-7-1.json", cli);
}
[Theory]
[InlineData(new int[] { }, 0)]
[InlineData(new[] { 1, 100, 200 }, 1)]
[InlineData(new[] { 2, 1, 2, 3, 4 }, 1)]
[InlineData(new[] { 1, 1, 1, 2, 2, 3, 3 }, 2)]
[InlineData(new[] { 0, 1, 2, 3 }, 0)] // malformed k=0: short-circuit
public void ReceiveEstimateSignatureAsync_writes_a_v1_envelope(int[] strokes, int expectedStrokeCount)
{
// We don't read the count back from the helper (it's a
// private method), but the JSON envelope must reflect
// it; this verifies the public behaviour end-to-end.
_ = expectedStrokeCount;
// Arrange
var user = MakeUser("alice");
var payload = new SignaturePadPayload
{
CoordinateMax = 10_000,
CapturedAtUtc = new DateTime(2026, 7, 4, 12, 0, 0, DateTimeKind.Utc),
Strokes = strokes,
};
// Act
var fi = Run(user, 123L, SignatureType.Pro, payload, this.siteSettings);
// Assert: file exists, sits under the user's root, and
// parses as a yavsc.signature/v1 envelope.
var fullPath = Path.Combine(fi.DestDir, fi.FileName);
Assert.True(File.Exists(fullPath), $"missing: {fullPath}");
using var doc = JsonDocument.Parse(File.ReadAllText(fullPath));
var root = doc.RootElement;
Assert.Equal("yavsc.signature/v1", root.GetProperty("format").GetString());
Assert.Equal(10_000, root.GetProperty("coordinateMax").GetInt32());
Assert.Equal(123L, root.GetProperty("estimateId").GetInt64());
Assert.Equal("Pro", root.GetProperty("type").GetString());
Assert.Equal("alice", root.GetProperty("signerName").GetString());
Assert.Equal(expectedStrokeCount, root.GetProperty("strokeCount").GetInt32());
}
[Fact]
public async Task ReceiveEstimateSignatureAsync_rejects_null_payload()
{
var user = MakeUser("bob");
// Capture TestContext.Current.CancellationToken outside the
// lambda so xUnit1051 sees a real CancellationToken argument
// (the lambda body runs on a different stack frame).
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<ArgumentNullException>(() =>
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
user, 1L, SignatureType.Pro, payload: null!,
siteSettings, token: ct));
}
[Fact]
public async Task ReceiveEstimateSignatureAsync_rejects_non_positive_estimateId()
{
var user = MakeUser("bob");
var payload = new SignaturePadPayload { Strokes = new[] { 1, 100, 100 } };
var ct = TestContext.Current.CancellationToken;
await Assert.ThrowsAsync<ArgumentOutOfRangeException>(() =>
EstimateSignatureFileHelper.ReceiveEstimateSignatureAsync(
user, 0L, SignatureType.Pro, payload, this.siteSettings, token: ct));
}
// --- helpers ----------------------------------------------------
private static FileReceivedInfo Run(
ClaimsPrincipal user, long estimateId, SignatureType type, SignaturePadPayload payload, SiteSettings siteSettings)
{
// The helper is async; tests that don't care about the
// result can call it sync via .GetAwaiter().GetResult()
// because we know it never throws in the happy path.
return EstimateSignatureFileHelper
.ReceiveEstimateSignatureAsync(user, estimateId, type, payload, siteSettings, CancellationToken.None)
.GetAwaiter().GetResult();
}
private static ClaimsPrincipal MakeUser(string username)
{
return new ClaimsPrincipal(new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, username) },
authenticationType: "test"));
}
}

View file

@ -1,5 +1,9 @@
using System.Net;
using System.Net.Http;
using IdentityServer8.Stores; using IdentityServer8.Stores;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Yavsc.Tests.Shared;
using Xunit; using Xunit;
namespace Yavsc.Org.Tests.Smoke; namespace Yavsc.Org.Tests.Smoke;
@ -40,4 +44,48 @@ public class AccountSmokeTests : IClassFixture<TestWebApplicationFactory>
Assert.Null(exception); Assert.Null(exception);
} }
// Regression: anonymous access to a restricted page used to redirect to
// the ASP.NET Identity default LoginPath "/Account/Login", which has no
// controller here. The application cookie must redirect to the real
// sign-in endpoint at /signin instead.
[Fact]
public async Task Anonymous_access_to_protected_page_redirects_to_signin()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
// EstimateController is [Authorize]; GET /Estimate hits Index().
var response = await client.GetAsync("/Estimate", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Redirect, response.StatusCode);
var location = response.Headers.Location;
Assert.NotNull(location);
Assert.Equal("/signin", location.AbsolutePath);
Assert.Contains("ReturnUrl=%2FEstimate", location.Query);
}
// Regression: the POST Delete action existed, but neither the GET
// Delete view nor a rendered confirmation page was reachable —
// Views/Account/Delete.cshtml was missing. An authenticated GET
// must now return the confirmation page (200).
[Fact]
public async Task Authenticated_get_account_delete_returns_confirmation_page()
{
var client = _factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false,
});
client.DefaultRequestHeaders.Add(
TestAuthPolicyProvider.HeaderName, TestAuthPolicyProvider.AdminRole);
var response = await client.GetAsync("/Account/Delete", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadAsStringAsync(
TestContext.Current.CancellationToken);
Assert.Contains("Supprimer mon compte", body);
}
} }

View file

@ -11,8 +11,8 @@
<RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath> <RunSettingsFilePath>$(MSBuildProjectDirectory)\test.runsettings</RunSettingsFilePath>
<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-feat-sign-estimate.1+306.Branch.feat-sign-estimate.Sha.e9a33695b38154859729b61d1280e596f1597acf</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-feat-sign-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="coverlet.collector" /> <PackageReference Include="coverlet.collector" />
@ -30,7 +30,6 @@
<PackageReference Include="xunit.v3" /> <PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" /> <PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" /> <PackageReference Include="xunit.v3.extensibility.core" />
<PackageReference Include="xunit.runner.visualstudio" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<Content Include="appsettings.json"> <Content Include="appsettings.json">

View file

@ -1061,6 +1061,7 @@ IHtmlLocalizerFactory htmlLocalizerFactory,
} }
return View(); return View();
} }
[HttpPost, Authorize] [HttpPost, Authorize]
public async Task<IActionResult> Delete(UnregisterViewModel model) public async Task<IActionResult> Delete(UnregisterViewModel model)
{ {

View file

@ -90,73 +90,6 @@ namespace Yavsc.Org.Controllers
); );
} }
// GET: Blog/Create
[Authorize()]
public IActionResult Create(string title)
{
var result = new BlogPostEditViewModel
(new Models.Blog.BlogPost
{
Title = title
}, true);
SetLangItems();
return View(result);
}
// POST: Blog/Create
[HttpPost, Authorize, ValidateAntiForgeryToken]
public IActionResult Create(Models.Blog.BlogPost blogInput)
{
if (ModelState.IsValid)
{
var uid = User.GetUserId();
Models.Blog.BlogPost post = blogSpotService.Create(uid, blogInput);
blogSpotService.AttachFiles(Request.Form.Files, uid, post.Id);
return RedirectToAction("Index");
}
return View("Edit", blogInput);
}
[Authorize()]
// GET: Blog/Edit/5
public async Task<IActionResult> Edit(long? id)
{
if (id == null)
{
return NotFound();
}
try
{
var blog = await blogSpotService.GetPostForEdition(User, id.Value);
if (blog == null)
{
return NotFound();
}
SetLangItems();
return View(blog);
}
catch (AuthorizationFailureException)
{
return new ChallengeResult();
}
}
// POST: Blog/Edit/5
[HttpPost]
[ValidateAntiForgeryToken, Authorize()]
public async Task<IActionResult> Edit(BlogPostEditViewModel blogEdit)
{
if (ModelState.IsValid)
{
await blogSpotService.Modify(User, blogEdit);
ViewBag.StatusMessage = "Post modified";
return RedirectToAction("Index");
}
return View(blogEdit);
}
// GET: Blog/Delete/5 // GET: Blog/Delete/5
[ActionName("Delete"), Authorize()] [ActionName("Delete"), Authorize()]

View file

@ -8,7 +8,6 @@ namespace Yavsc.Controllers
using Helpers; using Helpers;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Localization; using Microsoft.Extensions.Localization;
using Microsoft.Extensions.Options;
using Models; using Models;
using ViewModels.FrontOffice; using ViewModels.FrontOffice;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
@ -21,22 +20,20 @@ namespace Yavsc.Controllers
readonly ILogger _logger; readonly ILogger _logger;
readonly IStringLocalizer _SR; readonly IStringLocalizer _SR;
private readonly IBillingService _billing; private readonly IBillingService _billing;
private readonly SiteSettings _siteSettings;
public FrontOfficeController(ApplicationDbContext context, public FrontOfficeController(ApplicationDbContext context,
UserManager<ApplicationUser> userManager, UserManager<ApplicationUser> userManager,
IBillingService billing, IBillingService billing,
ILoggerFactory loggerFactory, ILoggerFactory loggerFactory,
IStringLocalizer<FrontOfficeController> SR, IStringLocalizer<FrontOfficeController> SR)
IOptions<SiteSettings> siteSettings)
{ {
_context = context; _context = context;
_userManager = userManager; _userManager = userManager;
_logger = loggerFactory.CreateLogger<FrontOfficeController>(); _logger = loggerFactory.CreateLogger<FrontOfficeController>();
_SR = SR; _SR = SR;
_billing = billing; _billing = billing;
_siteSettings = siteSettings.Value;
} }
public ActionResult Index() public ActionResult Index()
{ {
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier); var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
@ -46,9 +43,9 @@ namespace Yavsc.Controllers
{ {
EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Inserted EstimateToProduceCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Inserted
&& c.ValidationDate == null && !_context.Estimates.Any(e => e.CommandId == c.Id)).Count(), && c.ValidationDate == null && !_context.Estimates.Any(e => e.CommandId == c.Id)).Count(),
EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && c.Status == QueryStatus.Accepted EstimateToHonorAsProCount = _context.RdvQueries.Where(c => c.PerformerId == uid && c.EventDate > now && (c.Status == QueryStatus.ProAccepted || c.Status == QueryStatus.Accepted)
&& c.ValidationDate == null && _context.Estimates.Any(e => e.CommandId == c.Id )).Count(), && c.ValidationDate == null && _context.Estimates.Any(e => e.CommandId == c.Id )).Count(),
EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && e.Query.Status == QueryStatus.Accepted).Count(), EstimateToSignAsCliCount = _context.Estimates.Where(e => e.ClientId == uid && (e.Query.Status == QueryStatus.ProAccepted || e.Query.Status == QueryStatus.Accepted)).Count(),
BillToSignAsCliCount = 0, BillToSignAsCliCount = 0,
NewPayementsCount = 0 NewPayementsCount = 0
@ -80,61 +77,5 @@ namespace Yavsc.Controllers
return View(result); return View(result);
} }
[Produces("text/x-tex"), Authorize, Route("estimate-{id}.tex")]
[HttpGet]
public ViewResult EstimateTex(long id)
{
var estimate = _context.Estimates.Include(x => x.Query)
.Include(x => x.Query.Client)
.Include(x => x.Query.PerformerProfile)
.Include(x => x.Query.PerformerProfile.OrganizationAddress)
.Include(x => x.Query.PerformerProfile.Performer)
.Include(e => e.Bill).FirstOrDefault(x => x.Id == id);
Response.ContentType = "text/x-tex";
return View("Estimate.tex", estimate);
}
[Authorize, Route("Estimate-{id}.pdf")]
[HttpGet]
public IActionResult EstimatePdf(long id)
{
ViewBag.TempDir = _siteSettings.TempDir;
ViewBag.BillsDir = _siteSettings.Bills;
var estimate = _context.Estimates.Include(x => x.Query)
.Include(x => x.Query.Client)
.Include(x => x.Query.PerformerProfile)
.Include(x => x.Query.PerformerProfile.OrganizationAddress)
.Include(x => x.Query.PerformerProfile.Performer)
.Include(e => e.Bill).FirstOrDefault(x => x.Id == id);
if (estimate == null)
throw new Exception("No data");
return View("Estimate.pdf", estimate);
}
[Authorize]
public IActionResult EstimateProValidation()
{
throw new NotImplementedException();
}
[Authorize]
public IActionResult EstimateClientValidation()
{
throw new NotImplementedException();
}
[Authorize]
public IActionResult BillValidation()
{
throw new NotImplementedException();
}
[Authorize]
public IActionResult BillAcquitment()
{
throw new NotImplementedException();
}
} }
} }

View file

@ -215,6 +215,18 @@ public static class HostingExtensions
services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>(); services.AddScoped<IUserClaimsPrincipalFactory<ApplicationUser>, UserClaimsPrincipalFactory<ApplicationUser, IdentityRole>>();
// The default application cookie LoginPath is "/Account/Login", which
// has no controller here — anonymous access to a restricted page must
// redirect to the actual sign-in endpoint instead. The paths mirror
// the route attributes on AccountController (Constants.SigninPath etc.,
// which carry a "~/" prefix that the cookie middleware does not want).
services.ConfigureApplicationCookie(options =>
{
options.LoginPath = "/signin";
options.LogoutPath = "/signout";
options.AccessDeniedPath = "/Account/AccessDenied";
});
// Dev-only: Chromium rejects SameSite=None without Secure on http:// // Dev-only: Chromium rejects SameSite=None without Secure on http://
// (e.g. http://localhost:5000). The default Identity cookie policy // (e.g. http://localhost:5000). The default Identity cookie policy
// sets SameSite=None, which is invalid without Secure. Force Lax in // sets SameSite=None, which is invalid without Secure. Force Lax in

View file

@ -1,13 +1,12 @@
using System.Web; using System.Web;
using AsciiDocSharp; using Markdig;
using AsciiDocSharp.Converters.Html;
using Microsoft.AspNetCore.Html; using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Razor.TagHelpers;
namespace Yavsc.Helpers namespace Yavsc.Helpers
{ {
public class AsciidocTagHelper : TagHelper public class MarkdownTagHelper : TagHelper
{ {
public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output) public override async Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
{ {
@ -19,6 +18,7 @@ namespace Yavsc.Helpers
if (string.IsNullOrWhiteSpace(text)) return; if (string.IsNullOrWhiteSpace(text)) return;
try try
{ {
if (context.AllAttributes.ContainsName("summary")) if (context.AllAttributes.ContainsName("summary"))
{ {
@ -34,16 +34,10 @@ namespace Yavsc.Helpers
} }
} }
} }
var processor = new AsciiDocProcessor( var pipeline = new MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
var result = Markdown.ToHtml(text, pipeline);
);
var htmlConverter = new HtmlDocumentConverter();
var document = processor.ParseFromText(text); output.Content.AppendHtml(result);
var htmlResult = processor.ConvertDocument(document, htmlConverter);
output.Content.AppendHtml(htmlResult);
} }
catch (ArgumentException ex) catch (ArgumentException ex)
{ {

View file

@ -1,198 +0,0 @@
using System.Diagnostics;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewEngines;
namespace Yavsc.Helpers
{
using Microsoft.AspNetCore.Mvc.Infrastructure;
using ViewModels.Gen;
public class TeXString : HtmlString
{
public TeXString(TeXString teXString): base(teXString.ToString())
{
}
public TeXString(string str) : base(str)
{
}
public static TeXString operator+ (TeXString a, TeXString b) {
return new TeXString(a.ToString()+b.ToString());
}
}
public class Replacement
{
string target;
string replacement;
public Replacement(string target, string replacement)
{
this.target = target;
this.replacement = replacement;
}
public string Execute(string source)
{
return source?.Replace(target, replacement) ?? null;
}
}
public static class TeXHelpers
{
public static readonly Replacement[] SpecialCharsDefaultRendering =
{
new Replacement("<","\\textless"),
new Replacement(">","\\textgreater"),
new Replacement("©","\\copyright"),
new Replacement("®","\\textregistered"),
new Replacement("\\","\\textbackslash"),
new Replacement("™","\\texttrademark"),
new Replacement("¶","\\P"),
new Replacement("|","\\textbar"),
new Replacement("%","\\%"),
new Replacement("{","\\{"),
new Replacement("}","\\}"),
new Replacement("_","\\_"),
new Replacement("#","\\#"),
new Replacement("$","\\$"),
new Replacement("_","\\_"),
new Replacement("¿","\\textquestiondown"),
new Replacement("§","\\S"),
new Replacement("£","\\pounds"),
new Replacement("&","\\&"),
new Replacement("¡","\\textexclamdown"),
new Replacement("†","\\dag"),
new Replacement("–","\\textendash"),
new Replacement("°","\\textdegree")
};
public static TeXString ToTeX(this string source, string defaultValue="\\textit{néant}")
{
if (source==null) return new TeXString(defaultValue);
string result=source;
foreach (var r in SpecialCharsDefaultRendering)
{
result = r.Execute(result);
}
return new TeXString(result);
}
public static TeXString ToTeXCell(this string source, string defaultValue="\\textit{néant}")
{
if (source==null) return new TeXString(defaultValue);
string result=source;
foreach (var r in SpecialCharsDefaultRendering)
{
result = r.Execute(result);
}
result = result.Replace("\n","\\tabularnewline ");
return new TeXString(result);
}
public static string NewLinesWith(this string target, string separator)
{
var items = target.Split(new char[] { '\n' }).Where(
s => !string.IsNullOrWhiteSpace(s));
return string.Join(separator, items);
}
public static TeXString ToTeXLines(this string source, string defaultValue, string lineSeparator = "\n\\\\")
{
if (source == null) return new TeXString(defaultValue);
return new TeXString( source.ToTeX().ToString().NewLinesWith(lineSeparator) );
}
public static TeXString SplitAddressToTeX (this string source, string lineSeparator = "\n\\\\", string defaultValue = "\\textit{pas d'adresse postale}")
{
if (string.IsNullOrWhiteSpace(source)) return new TeXString(defaultValue);
var alines = source.Split(',');
var texlines = alines.Select(l=>l.ToTeX().ToString());
return new TeXString(string.Join(lineSeparator,texlines));
}
public static bool GenerateEstimatePdf(this SiteSettings settings, PdfGenerationViewModel Model)
{
string errorMsg = null;
var billdir = Model.DestDir;
var tempdir = settings.TempDir;
string name = Model.BaseFileName;
string fullname = new FileInfo(
System.IO.Path.Combine(tempdir, name)).FullName;
string ofullname = new FileInfo(
System.IO.Path.Combine(billdir, name)).FullName;
FileInfo fi = new FileInfo(fullname + ".tex");
FileInfo fo = new FileInfo(ofullname + ".pdf");
using (StreamWriter sw = new StreamWriter(fi.FullName))
{
sw.Write(Model.TeXSource);
}
if (!fi.Exists)
{
errorMsg = "Source write failed";
}
else
{
using (Process p = new Process())
{
p.StartInfo.WorkingDirectory = tempdir;
p.StartInfo = new ProcessStartInfo
{
UseShellExecute = false,
WorkingDirectory = tempdir,
FileName = "/usr/bin/texi2pdf",
Arguments = $"--batch --build-dir=. -o {fo.FullName} {fi.FullName}"
};
p.Start();
p.WaitForExit();
if (p.ExitCode != 0)
{
errorMsg = $"Pdf generation failed with exit code: {p.ExitCode}";
}
else
{
fi.Delete();
var di = new DirectoryInfo(Path.Combine(tempdir,$"{Model.BaseFileName}.t2d"));
di.Delete(true);
}
}
}
Model.Generated = fo.Exists;
Model.GenerationErrorMessage = new HtmlString(errorMsg);
return fo.Exists;
}
public static string RenderViewToString(
this Controller controller, IViewEngine engine,
IActionContextAccessor contextAccessor,
string viewName, object model, bool isMainPage = true)
{
using (var sw = new StringWriter())
{
if (engine == null)
throw new InvalidOperationException("no engine");
// try to find the specified view
controller.TryValidateModel(model);
ViewEngineResult viewResult = engine.FindView(contextAccessor.ActionContext, viewName, isMainPage);
// create the associated context
ViewContext viewContext = new ViewContext();
viewContext.ActionDescriptor = contextAccessor.ActionContext.ActionDescriptor;
viewContext.HttpContext = contextAccessor.ActionContext.HttpContext;
viewContext.TempData = controller.TempData;
viewContext.View = viewResult.View;
viewContext.Writer = sw;
// write the render view with the given context to the stringwriter
viewResult.View.RenderAsync(viewContext).Wait();
return sw.GetStringBuilder().ToString();
}
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class blogPostId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_blogSpotPublications_BlogSpot_BlogpostId",
table: "blogSpotPublications");
migrationBuilder.RenameColumn(
name: "BlogpostId",
table: "blogSpotPublications",
newName: "PostId");
migrationBuilder.AddForeignKey(
name: "FK_blogSpotPublications_BlogSpot_PostId",
table: "blogSpotPublications",
column: "PostId",
principalTable: "BlogSpot",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_blogSpotPublications_BlogSpot_PostId",
table: "blogSpotPublications");
migrationBuilder.RenameColumn(
name: "PostId",
table: "blogSpotPublications",
newName: "BlogpostId");
migrationBuilder.AddForeignKey(
name: "FK_blogSpotPublications_BlogSpot_BlogpostId",
table: "blogSpotPublications",
column: "BlogpostId",
principalTable: "BlogSpot",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class DropSignatureFilePath : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "FilePath",
table: "Signatures");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "FilePath",
table: "Signatures",
type: "text",
nullable: false,
defaultValue: "");
}
}
}

View file

@ -17,7 +17,7 @@ namespace Yavsc.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("ProductVersion", "10.0.12")
.HasAnnotation("Relational:MaxIdentifierLength", 63); .HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@ -1521,10 +1521,6 @@ namespace Yavsc.Migrations
b.Property<long>("EstimateId") b.Property<long>("EstimateId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<string>("FilePath")
.IsRequired()
.HasColumnType("text");
b.Property<string>("SignerId") b.Property<string>("SignerId")
.IsRequired() .IsRequired()
.HasColumnType("text"); .HasColumnType("text");
@ -1693,10 +1689,10 @@ namespace Yavsc.Migrations
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b => modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
{ {
b.Property<long>("BlogpostId") b.Property<long>("PostId")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.HasKey("BlogpostId"); b.HasKey("PostId");
b.ToTable("blogSpotPublications"); b.ToTable("blogSpotPublications");
}); });
@ -3958,13 +3954,13 @@ namespace Yavsc.Migrations
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b => modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
{ {
b.HasOne("Yavsc.Models.Blog.BlogPost", "BlogPost") b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")
.WithMany() .WithOne("Publication")
.HasForeignKey("BlogpostId") .HasForeignKey("Yavsc.Models.BlogSpotPublication", "PostId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.Navigation("BlogPost"); b.Navigation("Post");
}); });
modelBuilder.Entity("Yavsc.Models.Calendar.Schedule", b => modelBuilder.Entity("Yavsc.Models.Calendar.Schedule", b =>
@ -4614,6 +4610,9 @@ namespace Yavsc.Migrations
b.Navigation("Comments"); b.Navigation("Comments");
b.Navigation("Publication")
.IsRequired();
b.Navigation("Tags"); b.Navigation("Tags");
}); });

View file

@ -1,108 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Localization;
using Yavsc.Billing;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.ViewModels;
using Yavsc.ViewModels.Gen;
using Yavsc.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
namespace Yavsc.ViewComponents
{
public class BillViewComponent : ViewComponent
{
readonly ApplicationDbContext dbContext;
readonly IBillingService billing;
readonly IStringLocalizer<BillViewComponent> localizer;
private readonly SiteSettings siteSettings;
public BillViewComponent(ApplicationDbContext dbContext,
IStringLocalizer<BillViewComponent> localizer,
IBillingService billing,
IOptions<SiteSettings> siteSettings)
{
this.billing = billing;
this.dbContext = dbContext;
this.localizer = localizer;
this.siteSettings = siteSettings.Value;
}
public async Task<IViewComponentResult> InvokeAsync(
string code,
IBillable billable,
OutputFormat format,
bool asBill,
SiteSettings settings
)
{
var di = new DirectoryInfo(settings.Bills);
var dia = new DirectoryInfo(settings.Avatars);
ViewBag.BillsDir = di.FullName;
ViewBag.AvatarsDir = dia.FullName;
ViewBag.AsBill = asBill; // vrai pour une facture, sinon, c'est un devis
ViewBag.Acquitted = billable.GetIsAcquitted();
ViewBag.BillingCode = code;
var client = await dbContext.Users
.Include(u=>u.PostalAddress)
.SingleAsync(u=>u.Id == billable.ClientId);
ViewBag.Client = client;
var performer = await dbContext.Users
.Include(u=>u.BankInfo)
.Include(u=>u.PostalAddress)
.SingleAsync(u=>u.Id == billable.PerformerId);
ViewBag.Performer = performer;
string clientAddress = client.PostalAddress?.Address ?? null;
ViewBag.ClientAddress = clientAddress.SplitAddressToTeX();
var profile = await dbContext.Performers
.Include(p=>p.OrganizationAddress)
.SingleAsync(p=>p.PerformerId == billable.PerformerId);
ViewBag.PerformerProfile = profile;
ViewBag.ActivityLabel = (await dbContext.Activities.SingleAsync(a => a.Code == billable.ActivityCode)).Name;
var proAddr = profile.OrganizationAddress.Address;
ViewBag.PerformerOrganizationAddress = proAddr.SplitAddressToTeX() ;
ViewBag.FooterPerformerOrganizationAddress = proAddr.SplitAddressToTeX(", ");
ViewBag.PerformerAddress = performer.PostalAddress?.Address.SplitAddressToTeX() ;
switch (format) {
case OutputFormat.LaTeX :
return this.View("Bill_tex", billable);
case OutputFormat.Pdf :
string tex = null;
var oldWriter = ViewComponentContext.ViewContext.Writer;
using (var writer = new StringWriter())
{
this.ViewComponentContext.ViewContext.Writer = writer;
var resultTex = View("Bill_tex", billable);
await resultTex.ExecuteAsync(this.ViewComponentContext);
tex = writer.ToString();
}
ViewComponentContext.ViewContext.Writer = oldWriter;
var pdfGenerationViewModel = new PdfGenerationViewModel
{
Temp = siteSettings.TempDir,
TeXSource = tex,
DestDir = siteSettings.Bills,
BaseFileName = billable.GetFileBaseName(billing)
};
if (settings.GenerateEstimatePdf(pdfGenerationViewModel)) {
return this.View(new { Generated = pdfGenerationViewModel.BaseFileName+".pdf" });
} else {
return View(new { Error = pdfGenerationViewModel.GenerationErrorMessage } );
}
}
ViewBag.BillFileInfo = billable.GetBillInfo(billing, siteSettings);
ViewBag.Settings = settings;
return View("Default",billable);
}
}
}

View file

@ -18,7 +18,10 @@ namespace Yavsc.ViewComponents
} }
public async Task<IViewComponentResult> InvokeAsync(long id) public async Task<IViewComponentResult> InvokeAsync(long id)
{ {
var comment = await context.Comment.Include(c=>c.Children).FirstOrDefaultAsync(c => c.Id==id); var comment = await context.Comment
.Include(c => c.Children)
.Include(c => c.Author)
.FirstOrDefaultAsync(c => c.Id==id);
if (comment == null) if (comment == null)
throw new InvalidOperationException(); throw new InvalidOperationException();
ViewBag.apictlr = "/api/v1/blogcomments"; ViewBag.apictlr = "/api/v1/blogcomments";

View file

@ -0,0 +1,21 @@
@model UnregisterViewModel
@{
ViewBag.Title = "Supprimer mon compte";
}
<h2>@ViewBag.Title</h2>
<div class="row">
<div class="col-md-8">
<p>
Cette action est <strong>immédiate et irréversible</strong>. Toutes les données
associées à votre compte seront supprimées, et vous serez déconnecté.
</p>
<form asp-controller="Account" asp-action="Delete" method="post" class="form-horizontal" role="form">
@Html.Hidden("UserId")
<button type="submit" class="btn btn-danger">Confirmer la suppression</button>
<a asp-controller="Home" asp-action="Index" class="btn btn-default">Annuler</a>
</form>
</div>
</div>

View file

@ -1,88 +0,0 @@
@model BlogPostEditViewModel
@{
ViewBag.Title = "Blog post edition";
}
@section header {
<link href="~/css/main/dropzone.css" rel="stylesheet">
<link href="~/css/dropzone-basic.min.css" rel="stylesheet">
<script src="~/js/dropzone.bundle.min.js" asp-append-version="true"></script>
@{ await Html.RenderPartialAsync("_FSScriptsPartial"); }
}
<h2>Blog post</h2>
<label><input type="checkbox" id="vcbtn" />Editer le code source Markdown</label>
<div asp-validation-summary="All" class="text-danger"></div>
<img class="blogphoto" alt="" src="@Model.Photo" title="Photo associée au post">
<h2 title="Titre du post" class="blogtitle" id="titleview" >@Model.Title</h2>
<div title="Contenu du post" id="contentview">@Model.Article</div>
<hr>
<form asp-controller="Blogspot" asp-action="Create">
<div class="form-horizontal">
<div asp-validation-summary="ModelOnly" class="text-danger"></div>
<div class="form-group mdcoding">
<label asp-for="Title" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="Title" class="form-control" data-from="titleview"/>
<span asp-validation-for="Title" class="text-danger" >
</span>
</div>
</div>
<div class="form-group">
<label asp-for="Photo" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="Photo" class="form-control" />
<span asp-validation-for="Photo" class="text-danger" >
</span>
</div>
</div>
<div class="form-group mdcoding">
<label asp-for="Article" class="col-md-2 control-label" ></label>
<div class="col-md-10">
<textarea asp-for="Article" class="form-control" id="Article" data-from="contentview">
</textarea>
<span asp-validation-for="Article" class="text-danger" >
</span>
</div>
</div>
<div class="form-group">
<label asp-for="ACL" class="col-md-2 control-label"></label>
<div class="col-md-10">
@await Component.InvokeAsync("CirclesControl", Model)
</div>
</div>
<div class="form-group">
<label asp-for="Publish" class="col-md-2 control-label"></label>
<div class="col-md-10">
<input asp-for="Publish" class="form-control" />
</div>
</div>
<div class="form-group">
<div class="col-md-offset-2 col-md-10">
<button class="btn btn-primary" >Save</button>
</div>
</div>
</div>
</form>
@await Component.InvokeAsync(name: "Directory",arguments: new {subdir="."})
<div >
@{ await Html.RenderPartialAsync("_PostFilesPartial"); }
</div>
<div>
<a asp-action="Index">Back to List</a>
</div>

View file

@ -1,105 +1,235 @@
@model BlogPost @model BlogPost
@{ @{
ViewBag.Title=Model.Title; ViewBag.Title = Model.Title;
} }
@section scripts { @section header {
<script src="~/js/comment.js" asp-append-version="true"></script>
<script>
$.psc.blogcomment.prototype.options.lang = '@System.Globalization.CultureInfo.CurrentUICulture.Name';
$.psc.blogcomment.prototype.options.apictrlr = '/api/v1/blogcomments';
$.psc.blogcomment.prototype.options.authorId = '@User.GetUserId()';
$.psc.blogcomment.prototype.options.authorName = '@User.GetUserName()';
$(document).ready(function() {
$('#cmtBtn').click(function() {
$.ajax({
async: true,
cache: false,
type: 'POST',
method: 'POST',
contentType: "application/json",
data: JSON.stringify({
Article: $('#Comment').val(),
ReceiverId: @Model.Id
}),
error: function(xhr,data) {
if (xhr.status === 400)
{
if (xhr.responseJSON)
{
$('#commentValidation').html(
xhr.responseJSON.Article);
} else {
$('#commentValidation').html(
"Une erreur est survenue : "+xhr.status+"<br/>"+
"<code><pre>"+xhr.responseText+"</pre></code>"
)
}
}
},
success: function (data) {
var comment = $('#Comment').val();
$('#Comment').val('');
$('#commentValidation').empty();
var nnode = '<div data-type="blogcomment" data-id="'+data.id+'" data-allow-edit="True" data-allow-moderate="@ViewBag.moderatoFlag" data-date="'+data.dateCreated+'" data-username="@User.GetUserName()">'+comment+'</div>';
$('#comments').append($(nnode).blogcomment())
},
url:'/api/v1/blogcomments'
});
});
})
</script>
<style> <style>
.avatar, .commentmeta, .blogcomment p , .subcomments { .blog-details .post {
display: inline-block; display: grid;
grid-template-columns: auto 1fr;
gap: 1rem;
align-items: start;
margin-bottom: 1.5rem;
}
.blog-details .post-photo {
width: 128px;
height: 128px;
object-fit: cover;
border-radius: .375rem;
}
.blog-details .post-title {
margin: 0 0 .25rem;
}
.blog-details .post-meta {
font-size: .85rem;
color: var(--bs-secondary-color, #6c757d);
margin-bottom: .5rem;
}
.blog-details .post-article {
grid-column: 1 / -1;
}
#comments {
margin-top: 1.5rem;
} }
.blogcomment { .blogcomment {
display: inline-block; margin: .3em 0;
margin:.3em; padding: .5rem .75rem;
border-left: 3px solid var(--bs-border-color, #dee2e6);
/* Quartz ships white body text on a coloured page bg, so
use a semi-transparent black overlay rather than
--bs-tertiary-bg (which is near-white in the quartz
light variant and would render white-on-white). */
background: rgba(0, 0, 0, 0.15);
border-radius: .25rem;
}
.blogcomment .comment-body > :last-child {
margin-bottom: 0;
}
.blogcomment .commentmeta {
display: block;
font-size: .75rem;
color: var(--bs-secondary-color, #6c757d);
margin-top: .25rem;
}
.blogcomment .reply-btn {
padding: 0;
font-size: .75rem;
}
.subcomments {
margin-left: 1.25rem;
margin-top: .5rem;
}
.reply-form {
display: flex;
align-items: center;
gap: .5rem;
margin: .5rem 0;
max-width: 40rem;
}
.reply-form .form-control {
flex: 1;
}
.comment-form .input-group {
max-width: 40rem;
}
#commentValidation,
.reply-validation {
color: var(--bs-danger, #dc3545);
font-size: .8rem;
} }
</style> </style>
} }
@section scripts {
<script>
$(document).ready(function () {
var api = '/api/v1/blogcomments';
var receiverId = @Model.Id;
var userName = @Json.Serialize(User.GetUserName());
// Reply buttons are rendered server-side only when the
// user is authenticated, so a non-empty userName here
// means freshly appended nodes may carry one too.
var canReply = !!userName;
<div class="post"> // Build a plain, escaped comment node from a just-posted
<div class="float-left"> // comment. The server-side Comment view component is what
<img class="photo" src="@Model.Photo" > // markdown-renders and decorates persisted comments; a
// newly created one is plain text until the page reloads.
function buildCommentNode(text, data, parentId) {
var $node = $('<div class="blogcomment"></div>')
.attr('data-type', 'blogcomment')
.attr('data-id', data.id)
.attr('data-receiver-id', receiverId);
if (parentId != null) $node.attr('data-parent-id', parentId);
$node.append($('<div class="comment-body"></div>').append($('<p></p>').text(text)));
$node.append($('<span class="commentmeta"></span>')
.text('— ' + (userName || 'Vous') + ', posté à ' + new Date(data.dateCreated).toLocaleTimeString()));
if (canReply) {
$node.append($('<button type="button" class="btn btn-link btn-sm reply-btn">Répondre</button>'));
}
$node.append($('<div class="subcomments"></div>'));
return $node;
}
function postComment(text, parentId, $validation) {
return $.ajax({
type: 'POST',
contentType: 'application/json',
url: api,
data: JSON.stringify({
Article: text,
ReceiverId: receiverId,
ParentId: parentId
})
}).fail(function (xhr) {
var msg;
if (xhr.status === 400 && xhr.responseJSON) {
msg = xhr.responseJSON.Article || xhr.responseJSON.title || 'Validation error';
} else {
msg = 'Une erreur est survenue : ' + xhr.status;
}
($validation || $('#commentValidation')).text(msg);
});
}
// Top-level comment form.
$('#cmtBtn').click(function () {
var text = $('#Comment').val();
if (!text) return;
$('#commentValidation').empty();
postComment(text, null, $('#commentValidation')).done(function (data) {
$('#comments').append(buildCommentNode(text, data, null));
$('#Comment').val('');
});
});
// Tree replies — delegated so it works for every nested
// comment, including ones appended at runtime.
$('#comments').on('click', '.reply-btn', function () {
var $comment = $(this).closest('[data-type="blogcomment"]');
// Toggle: if a reply form is already open for this
// comment, close it instead of opening a second one.
var $existing = $comment.children('.reply-form');
if ($existing.length) { $existing.remove(); return; }
var $form = $('<div class="reply-form"></div>');
$form.append('<input class="form-control reply-input" placeholder="..." />');
$form.append('<button type="button" class="btn btn-secondary reply-submit">Envoyer</button>');
$form.append('<span class="reply-validation"></span>');
// Insert before this comment's .subcomments so nested
// replies land in the right place.
$comment.children('.subcomments').before($form);
$form.find('.reply-input').focus();
});
$('#comments').on('click', '.reply-submit', function () {
var $form = $(this).closest('.reply-form');
var $comment = $form.closest('[data-type="blogcomment"]');
var parentId = parseInt($comment.attr('data-id'), 10);
var text = $form.find('.reply-input').val();
if (!text) return;
var $val = $form.find('.reply-validation');
$val.empty();
postComment(text, parentId, $val).done(function (data) {
var $sub = $comment.children('.subcomments');
$sub.append(buildCommentNode(text, data, parentId));
$form.remove();
});
});
});
</script>
}
<div class="blog-details">
<div class="post card">
@if (!string.IsNullOrEmpty(Model.Photo))
{
<img class="post-photo" src="@Model.Photo" alt="@Model.Title" />
}
<div>
<h1 class="post-title">@Model.Title</h1>
<div class="post-meta">
@Html.DisplayFor(m => m.Author)
@if (Model.DateModified > Model.DateCreated)
{
<span> · modifié le @Model.DateModified.ToString("dddd d MMM yyyy à H:mm")</span>
}
else
{
<span> · posté le @Model.DateCreated.ToString("dddd d MMM yyyy à H:mm")</span>
}
</div>
</div>
<div class="post-body post-article">
<markdown>@Model.Article</markdown>
</div> </div>
<h1 ismarkdown>@Model.Title</h1>
@Html.DisplayFor(m=>m.Author)
<asciidoc>@Html.DisplayFor(m=>m.Article)</asciidoc>
</div> </div>
<div id="comments"> <div id="comments">
@if (Model.Comments!=null) { @if (Model.Comments != null)
foreach (var comment in Model.Comments.Where(c=>c.ParentId==null)) { {
foreach (var comment in Model.Comments.Where(c => c.ParentId == null))
{
@await Component.InvokeAsync("Comment", new { id = comment.Id }) @await Component.InvokeAsync("Comment", new { id = comment.Id })
} }
} }
</div> </div>
@if (User.GetUserId()!=null) {
<div class="form-horizontal">
<div class="input-group" >
<input name="Comment" id="Comment" class="form-control" placeholder="..."/>
<span class="input-group-btn">
<span id="commentValidation"></span>
<input type="button" value="Comment" class="btn btn-secondary"
data-receiverId="@Model.Id" id="cmtBtn"
/>
</span>
</div>
<span asp-validation-for="Article" class="text-danger" ></span>
</div>
}
else {
<p><i>Vous devez être identifié pour commenter.</i> </p>
}
@if ((await AuthorizationService.AuthorizeAsync(User, Model, new EditPermission())).Succeeded) {
<a asp-action="Edit" asp-route-id="@Model.Id" class="btn btn-link">Edit</a>
}
<a asp-action="Index" class="btn btn-link">Back to List</a>
@if (User.GetUserId() != null)
{
<div class="comment-form form-horizontal">
<div class="input-group">
<input name="Comment" id="Comment" class="form-control" placeholder="..." />
<span class="input-group-btn">
<span id="commentValidation"></span>
<input type="button" value="Comment" class="btn btn-secondary" id="cmtBtn" />
</span>
</div>
</div>
}
else
{
<p><i>Vous devez être identifié pour commenter.</i></p>
}
<a asp-action="Index" class="btn btn-link">Back to List</a>
</div>

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