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
4bca36906e optimisation of workflows : Skiasharp deps already installed
All checks were successful
Forgejo Release / release (push) Successful in 5m35s
2026-09-19 16:37:57 +01:00
4b7ac2dec8 test(blogs): configure SiteSettings.Blog in MappedClaimsBlogsWebServerFixture
BlogSpotService's constructor throws InvalidOperationException
("SiteSettings.Blog is not configured.") when IOptions<SiteSettings>.Blog
is null, so every request to BlogApiController 500'd resolving the
service — all 3 BlogApiMappedClaimsTests were failing with
InternalServerError.

The mapped-claims fixture never configured SiteSettings (the main
BlogsWebServerFixture does, pointing Blog at a per-run temp dir).
Mirror that: Configure<SiteSettings>(s => s.Blog = <temp dir>)
before AddScoped<BlogSpotService>(). These tests are JSON-only
(AttachFiles short-circuits), but the service must be constructible.

Verified: BlogApiMappedClaimsTests 3/3 green; full Yavsc.Blogs.Tests
37/37 green.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 14:14:28 +01:00
92af3c0428 build: disable StaticWebAssets cache to stop MSB4018 race
DefineStaticWebAssets.ReadOrCreateCache opens the *.dswa.cache.json
files under obj/.../ via File.OpenRead with no try/catch, so under
parallel solution builds a sharing-violation / TOCTOU race on the cache
file escapes as MSB4018 and fails the whole build (seen on
Yavsc.Blogs during the rc15 solution build).

Set StaticWebAssetsCacheDefineStaticWebAssetsEnabled=false so
CacheManifestPath is empty and ReadOrCreateCache short-circuits to a
no-file-I/O path. The cache is a pure build-speed optimization; no
effect on output correctness, only on incremental rebuild speed of the
wwwroot-light web projects.

Verified: Yavsc.Api (transitive web deps) and Yavsc.Blogs.Tests build
clean, and zero *.dswa.cache.json files are produced.

Refs:
  https://source.dot.net/Microsoft.NET.Sdk.StaticWebAssets.Tasks/DefineStaticWebAssets.Cache.cs.html
  https://github.com/dotnet/sdk/issues/49039

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 13:53:25 +01:00
5470924cdb changelog: add 1.0.8-rc15 entry
Cover the 60 commits since 1.0.8-rc14: blog attachments + Markdown
preview, the Estimate pipeline (list/edition/validation + API + EF
migrations), provider ongoing requests, file-ACL and genericEstimate
migrations, POSTIT_SETTINGS_JSON, billing filtered by user, shared
YavscMessageSender, Gitleaks CI, and the test stabilization wave.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-19 12:56:02 +01:00
289626bd89 preview the Markdown 2026-09-18 19:45:35 +01:00
f038c08276 +MarkdownViewer 2026-09-18 13:58:31 +01:00
98be57f798 post the file 2026-09-18 13:38:42 +01:00
64df506f3d corrige la création du lien 2026-09-18 12:13:00 +01:00
6a1bd23ca5 test: cover BlogApiClient.CreateMultipartContent shape + server round-trip
Expose CreateMultipartContent as public so the blogs backend tests can
assert on its wire shape directly and round-trip it through the live
BlogsWebServerFixture host. New CreateMultipartContentTests:
- shape: blog part is camelCase JSON with WhenWritingNull, one file
  part per attachment (filename, Content-Type, octet-stream fallback);
- round-trip: POST the built body to /api/v1/blogspot, assert 201
  Created + attachment row + file landed under BlogFilesRoot.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-09-18 11:39:45 +01:00
74cfa10b6b -Config.Temp 2026-09-18 11:13:44 +01:00
3160eada93 refacto site settings 2026-09-17 23:54:36 +01:00
af5c1a3e92 BlogAttachmentLinkTests 2026-09-17 00:57:52 +01:00
4d17a39258 Supprime un faux message d'erreur 2026-09-16 19:48:13 +01:00
6ae814ef8a better indications 2026-09-16 18:53:17 +01:00
2787939ad7 new defaults 2026-09-16 18:23:52 +01:00
07a2986769 code cleanup 2026-09-16 18:23:42 +01:00
8bf6f20048 fixes the upload 2026-09-16 18:23:27 +01:00
eb22e4a5ab Estimate lists 2026-09-15 00:19:23 +01:00
6e92c57c1d install native dependencies 2026-09-14 10:07:10 +01:00
cdd963e8b7 fixes the pipeline 2026-09-14 00:47:28 +01:00
812a3301e7 removes the ItemGroup.Using Include=Xunit 2026-09-14 00:40:30 +01:00
c6d75f1d77 build before testing (sunit not found by MSBuild task) 2026-09-14 00:02:54 +01:00
d172f00c5b Merge remote-tracking branch 'origin/main' into feat/files-control 2026-09-13 23:39:41 +01:00
99a8707504 The Estimate is validated 2026-09-13 23:29:06 +01:00
027c0e1a03 Estimate UI 2026-09-13 18:54:27 +01:00
9550f9fe2d better and simpler UI 2026-09-13 17:51:42 +01:00
3cb98f6603 POSTIT_SETTINGS_JSON env var 2026-09-13 13:34:39 +01:00
37300206fb list only my billing codes 2026-09-13 03:49:07 +01:00
3003e6fe93 take the scanning in account 2026-09-13 03:25:09 +01:00
ee373f81dc use Sqlite 2026-09-13 03:22:22 +01:00
9a77209304 got saved a RdvQuery 2026-09-13 03:09:12 +01:00
9cae5b1f55 tests en vert 2026-09-12 17:31:18 +01:00
128735a237 fixe tests 2026-09-12 16:49:36 +01:00
9f55ef84da GetOpenIdConfiguration_returns_ok against testing host 2026-09-12 14:30:50 +01:00
80f9040ac3 Use a shared YavscMessageSender 2026-09-12 13:57:25 +01:00
bcf36709d7 Add provider ongoing requests flow and sort persistence 2026-09-12 13:29:49 +01:00
9d3e4c8b78 generic estimate 2026-09-12 12:59:44 +01:00
4b2e2a1c08 fixes the tests 2026-09-11 18:37:28 +01:00
4a28bc63f9 MainPage => BlogsPage 2026-09-11 18:37:26 +01:00
15317207c6 Bad test, fix the test 2026-09-11 18:37:25 +01:00
1e0dc07a2c Test the release, don't ignore test return code 2026-09-11 18:37:24 +01:00
69d404bc49 Bad test, fix the test 2026-09-11 13:42:29 +01:00
2cd5c6b68a Test the release, don't ignore test return code 2026-09-11 13:25:32 +01:00
2ee8b3dbec implements a dico 2026-09-11 12:45:13 +01:00
7bd169bf61 We don't need these "release" branches 2026-09-11 11:01:46 +01:00
78721a34de user the CommandBar 2026-09-07 20:53:23 +01:00
d3392f1e08 db testing 2026-09-07 20:02:56 +01:00
b356c17a2c Removes an abusive assertion, that went wrong 2026-09-07 19:16:23 +01:00
7b4538b03a Post Files 2026-09-07 15:41:33 +01:00
cf5c2f4b39 code reorg 2026-09-07 14:39:53 +01:00
17fd68b474 code format 2026-09-07 14:12:34 +01:00
82b5d06230 scan the commit 2026-09-07 13:20:50 +01:00
ed456ab3d1 Gitleaks integration 2026-09-07 12:56:48 +01:00
867d756a26 cleanup 2026-09-07 12:28:24 +01:00
f079165212 test 2026-09-07 12:20:55 +01:00
e877a59606 picket scan 2026-09-07 12:08:57 +01:00
5bd0e998a2 Secret scan 2026-09-07 11:57:00 +01:00
e2d1953faf CircleAuthorizationToFile EF migration 2026-09-07 11:02:41 +01:00
31a723f5fa unique email handling 2026-09-07 10:58:20 +01:00
92aa68c03c code reorg 2026-09-07 10:30:45 +01:00
73 changed files with 161 additions and 8776 deletions

View file

@ -44,7 +44,7 @@ jobs:
- name: Secret scan - name: Secret scan
run: | run: |
echo "🔍 Scanning for secrets..." echo "🔍 Scanning for secrets..."
cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts main..HEAD \ cd /src/_src && dotnet tool restore && dotnet picket git --verbose --redact --exit-code 1 --log-opts -n10 \
- name: Build - name: Build
run: | run: |
echo "🏗️ Building the solution..." echo "🏗️ Building the solution..."

6
.gitignore vendored
View file

@ -43,9 +43,3 @@ DataDir/
*.log *.log
# gitleaks scan output (local secret-scan report, not for the repo)
/gitleaks-report.json
/test-report.html
/test-report.xml

View file

@ -1,6 +0,0 @@
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.24.2
hooks:
- id: gitleaks

10
.vscode/launch.json vendored
View file

@ -43,15 +43,9 @@
}, },
{ {
"name": "Yavsc Org", "name": "Yavsc Org",
"type": "coreclr", "type": "dotnet",
"request": "launch", "request": "launch",
"program": "${workspaceFolder}/src/Yavsc.Org/bin/Debug/net10.0/Yavsc.Org.dll", "projectPath": "${workspaceFolder}/src/Yavsc.Org/Yavsc.Org.csproj",
"env": {
"ASPNETCORE_ENVIRONMENT": "Development"
},
"cwd": "${workspaceFolder}/src/Yavsc.Org",
"preLaunchTask": "build-web-yavsc-org"
}, },
{ {
"name": "Yavsc Blogs", "name": "Yavsc Blogs",

28
.vscode/tasks.json vendored
View file

@ -89,34 +89,6 @@
}, },
"isBackground": true "isBackground": true
}, },
{
"label": "build-web-yavsc-org",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Org"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{
"label": "build-web-yavsc-blogs",
"type": "process",
"problemMatcher": ["$msCompile"],
"command": "dotnet",
"args": ["build"],
"options": {
"cwd": "src/Yavsc.Blogs"
},
"group": {
"kind": "build"
},
"isBackground": true
},
{ {
"label": "dotnet: build-postit-desktop", "label": "dotnet: build-postit-desktop",
"type": "process", "type": "process",

View file

@ -1,68 +1,5 @@
# Changelog # Changelog
## [1.0.8-rc17] - unstable
Fichiers utilisateur et pièces jointes sur les devis/demandes (PostIt +
API), système de génération `contrib` (vhosts nginx HTTP/3 edge +
appsettings jq + QUIC logiciel Kestrel), et correction du bug de
production où `IsPublished` disparaissait du payload de liste du blog API.
### Added
* [PostIt] Espace de stockage personnel (« MyFiles ») : page de gestion
des fichiers de l'utilisateur, entrée depuis la page d'accueil.
* [PostIt] Pièces jointes aux devis et aux demandes : attachement depuis
le détail de demande et la page d'édition de devis.
* [Yavsc.Api] Endpoints d'attachement de fichiers sur les devis
(`EstimateApiController`) et les demandes front-office
(`FrontOfficeApiController`).
* [Yavsc.Api.Client] `UserFilesApiClient` + DTOs associés ; méthodes
d'attachement sur `EstimateApiClient` et `FrontOfficeApiClient`.
* [Yavsc.Server] Modèles `EstimateAttachedFile`, `QueryAttachedFile`,
`BlogAttachedFile`, `AttachFileRequest` ; migration EF Core
`UserFileAttachments`.
* [contrib] Système de génération `Makefile` : vhosts nginx HTTP/3 edge
(`template.vhost`, `QUIC_OPTS`), appsettings générés via jq
(`template.appsettings-{org,api,blogs}.json`, échappement JSON-safe des
secrets), QUIC logiciel Kestrel (`Http1AndHttp2AndHttp3`), et
`README.nginx.md`.
* [Tests] Projet `Yavsc.Server.Tests` : tests du service
`BlogSpotService.Details` + `ServerServicesFixture` (SQLite `:memory:`
par défaut, Npgsql opt-in via `YAVSC_SERVER_TEST_DB_PROVIDER`).
* [Tests] `IBlogPostWireShapeTests` (forme du wire blog),
`EstimateAttachmentsApiTests`, `FrontOfficeQueryAttachmentsApiTests`,
`FileSystemApiTests` (pièces jointes et stockage perso).
* [CI] Intégration Gitleaks / analyse de secrets (`.pre-commit-config.yaml`).
### Changed
* [contrib] `QUIC_OPTS` (`reuseport`) désormais activé uniquement en
production sur le vhost Org. La preprod et la prod partageant la même
IP, `reuseport` — légal une seule fois par `addr:port` (443/udp) —
serait sinon en conflit entre preprod-Org et prod-Org et ferait refuser
la config par nginx.
* [Yavsc.Api.Test] `ApiWebServerFixture` : la connexion admin PostgreSQL
provient de la variable d'environnement
`YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION` ; plus de mot de passe en
source.
### Fixed
* [Yavsc.Blogs] Bug de production : `IsPublished` était absent du payload
de la liste du blog API. Le endpoint `GetBlogspot` retourne
`IEnumerable<IBlogPost>` sérialisé par System.Text.Json d'après le type
déclaré (l'interface), qui omet les propriétés du type concret —
`IsPublished` vivait sur `BlogPost` / `BlogPostDto` mais pas sur
`IBlogPost`. PostIt lisait donc `IsPublished == false` pour tout billet,
y compris publiés (https://yavsc.pschneider.fr/BlogSpot/Details/13).
Corrigé en déclarant `IsPublished` sur `IBlogPost`.
* [Yavsc.Org] Accès au Swagger UI : les actions MVC scaffoldées de
`CommentsController` (Index/Details/…) atterrissaient sur la même route
`POST api/v1/blogcomments` que l'action d'API `Post`, provoquant une
`SwaggerGeneratorException` (conflit method/path) au chargement de
`/swagger`. Corrigé par `[ApiExplorerSettings(IgnoreApi = true)]` sur
les actions scaffoldées.
## [1.0.8-rc16] - unstable ## [1.0.8-rc16] - unstable
Flux de signature et de génération de devis : un devis peut désormais être Flux de signature et de génération de devis : un devis peut désormais être

View file

@ -24,7 +24,7 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
@cat template.service | APP_NAME="$*" \ @cat template.service | APP_NAME="$*" \
DESTDIR="$(DESTDIR)" \ DESTDIR="$(DESTDIR)" \
HTTP_HOST="$(HTTP_HOST)" \ HTTP_HOST="$(HTTP_HOST)" \
HTTP_PORT="$($*_PORT)" \ HTTP_PORT="$*_$(HTTP_PORT)" \
APPDIR="$(APPDIR)" \ APPDIR="$(APPDIR)" \
APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \ APP_SYSLOG_ID="$(APP_INSTALL_PREFIX)yavsc$*" \
POSTGRES_HOST="$(POSTGRES_HOST)" \ POSTGRES_HOST="$(POSTGRES_HOST)" \
@ -45,76 +45,6 @@ generated/$(APP_INSTALL_PREFIX)yavsc%.service: generated/ template.service $(SLN
generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service generate-service-units: generated/$(APP_INSTALL_PREFIX)yavscOrg.service generated/$(APP_INSTALL_PREFIX)yavscBlogs.service generated/$(APP_INSTALL_PREFIX)yavscApi.service
# --- Vhosts nginx (générés par envsubst, à l'image des unités systemd) ---
# make generate-nginx -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
# make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX= -> prod (yavsc/api/blogs.pschneider.fr)
#
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert nginx
# avec http_v3_module + UDP 443 ouvert. reuseport sur Org uniquement
# (QUIC_OPTS). Les vhosts générés vont dans generated/nginx/ (gitignoré).
NGINX_TEMPLATE := template.vhost
NGINX_OUT_DIR := generated/nginx
NGINX_HOSTS := Org Blogs Api
DOMAIN_ROOT ?= pschneider.fr
# Schéma backend Kestrel : https en prod (Kestrel écoute HTTPS, cert Let's
# Encrypt), http en preprod (Kestrel écoute HTTP simple, cf. .preprod.env).
BACKEND_SCHEME ?= $(if $(filter production,$(APP_INSTALL_ENV)),https,http)
# sous-domaine par hôte : Org -> yavsc, Api -> api, Blogs -> blogs
nginx_subdomain = $(if $(filter Org,$1),yavsc,$(if $(filter Api,$1),api,$(if $(filter Blogs,$1),blogs,$1)))
# domaine complet : <prefix><subdomain>.<DOMAIN_ROOT>
# prod Org -> yavsc.pschneider.fr
# preprod Org -> preprod-yavsc.pschneider.fr
nginx_domain = $(APP_INSTALL_PREFIX)$(call nginx_subdomain,$1).$(DOMAIN_ROOT)
# QUIC_OPTS : "reuseport" n'est légal qu'une seule fois par addr:port
# (443/udp). preprod et prod partagent aujourd'hui la même IP, donc on ne
# l'active qu'en production, sur Org uniquement. En preprod, QUIC_OPTS est
# vide sur tous les vhosts (sinon nginx refuse la config : duplicate
# reuseport sur le même socket 443/udp entre preprod-Org et prod-Org).
$(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc%: $(NGINX_OUT_DIR) $(NGINX_TEMPLATE) .$(APP_INSTALL_ENV).env
@cat $(NGINX_TEMPLATE) \
| DOMAIN="$(call nginx_domain,$*)" \
BACKEND_SCHEME="$(BACKEND_SCHEME)" \
HTTP_HOST="$(HTTP_HOST)" \
BACKEND_PORT="$($*_PORT)" \
QUIC_OPTS="$(if $(filter production,$(APP_INSTALL_ENV)),$(if $(filter Org,$*), reuseport,),)" \
envsubst '$$DOMAIN $$BACKEND_SCHEME $$HTTP_HOST $$BACKEND_PORT $$QUIC_OPTS' > $@
@echo Created nginx vhost: $@
generate-nginx: $(addprefix $(NGINX_OUT_DIR)/$(APP_INSTALL_PREFIX)yavsc,$(NGINX_HOSTS))
generate-nginx-prod:
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
$(NGINX_OUT_DIR):
@mkdir -p $@
# --- appsettings (générés depuis template.appsettings-*.json) ---
# make generate-appsettings -> generated/appsettings/appsettings-{org,api,blogs}.json
#
# Substitue $DOMAIN (racine du domaine, cf. DOMAIN_ROOT) dans toutes les
# chaînes, et $SMTP_PASSWORD (secret du .env) dans Smtp.Password.
# On utilise jq (et non envsubst) car envsubst n'échappe pas le JSON : un
# secret contenant \ ou " casserait le JSON (le mot de passe SMTP actuel
# contient un \). jq lit le secret depuis l'environnement (export) ->
# échappement JSON correct, aucun shell-quoting ni exposition en ligne de cmd.
# Les templates actuels sont issus de la preprod (préfixe preprod-, ports
# 88-93) ; pour la prod il faudra un template prod (ports 82-87, sans préfixe).
export ASPNETCORE_Smtp__Password
APPSETTINGS_OUT_DIR := generated/appsettings
APPSETTINGS_HOSTS := org api blogs
$(APPSETTINGS_OUT_DIR)/appsettings-%.json: $(APPSETTINGS_OUT_DIR) template.appsettings-%.json .$(APP_INSTALL_ENV).env
@jq --arg domain "$(DOMAIN_ROOT)" \
'walk(if type=="string" then gsub("\\$$DOMAIN"; $$domain) else . end) | if has("Smtp") then .Smtp.Password = env["ASPNETCORE_Smtp__Password"] else . end' \
template.appsettings-$*.json > $@
@echo Created appsettings: $@
generate-appsettings: $(addprefix $(APPSETTINGS_OUT_DIR)/appsettings-,$(addsuffix .json,$(APPSETTINGS_HOSTS)))
$(APPSETTINGS_OUT_DIR):
@mkdir -p $@
install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service install-service-%: /etc/systemd/system/$(APP_INSTALL_PREFIX)yavsc%.service
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$* sudo systemctl enable $(APP_INSTALL_PREFIX)yavsc$*

View file

@ -1,134 +0,0 @@
# Vhosts nginx — génération par `make generate-nginx`
Les vhosts nginx sont **générés** à l'image des unités de service systemd
(`template.service` + `envsubst` → `generated/*.service`) : un template
unique `contrib/template.vhost` est substitué par `envsubst` vers
`contrib/generated/nginx/`, qui est **gitignoré**.
## Rôle de nginx : edge HTTP/3 (plan `contrib/quic.md`)
nginx termine HTTP/3 (QUIC) au edge sur 443/udp, en coexistence avec
h1/h2 sur 443/tcp. Kestrel reste backend h1/h2 (`proxy_http_version 1.1`).
Le « QUIC logiciel » Kestrel (`Protocols: Http1AndHttp2AndHttp3` dans les
`template.appsettings-*.json`) est une capacité complémentaire — non
joignable par les clients derrière nginx (nginx OSS = upstream h1.1
uniquement), mais disponible si Kestrel est un jour exposé en direct.
Directives générées (par vhost) :
```nginx
listen 443 ssl; listen [::]:443 ssl; http2 on;
listen 443 quic $QUIC_OPTS; listen [::]:443 quic $QUIC_OPTS; http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400' always;
ssl_protocols TLSv1.3;
```
`$QUIC_OPTS = reuseport` sur **Org uniquement**, vide sur Api/Blogs (règle
`contrib/quic.md` : `reuseport` une seule fois par addr:port, tous vhosts
confondus).
## Prérequis côté host
1. **nginx avec `http_v3_module`** (≥ 1.25 ; la directive `http3 on;`
exige ≥ 1.27.4 — sur une version 1.25–1.27.3, retirer `http3 on;`,
`listen ... quic` suffit à activer h3) :
```bash
nginx -V 2>&1 | grep -o 'http_v3_module' # doit être non vide
```
Debian 13 « trixie » fournit `nginx` avec `http_v3_module`. Sinon,
backports ou `nginx-full`.
2. **UDP 443 ouvert** :
```bash
sudo ufw allow 443/udp
sudo ufw status | grep '443/udp' # → ALLOW
```
3. **IPv6** : le template émet `listen [::]:443 ...` (et `[::]:80`). Si le
host n'a pas d'IPv6, retirer/commenter ces lignes (sinon `nginx -t`
échoue sur le bind IPv6).
4. Certbot / certs Let's Encrypt déjà en place (chemins
`/etc/letsencrypt/live/<domain>/{fullchain,privkey}.pem` référencés).
5. (Optionnel, pour le QUIC logiciel Kestrel) `apt install libmsquic` —
sinon Kestrel désactive h3 silencieusement (fallback h1/h2 gracieux).
## Génération
```bash
cd contrib
# Preprod (défaut) -> generated/nginx/preprod-yavsc{Org,Blogs,Api}
make generate-nginx
# Production -> generated/nginx/yavsc{Org,Blogs,Api}
make generate-nginx-prod
# ou manuellement :
make generate-nginx APP_INSTALL_ENV=production APP_INSTALL_PREFIX=
```
Ports backend et `HTTP_HOST` depuis `.preprod.env` / `.production.env`
(prod Org=83, Api=87, Blogs=85 ; preprod 89/93/91). Schéma backend
`https` en prod, `http` en preprod.
| Hôte | Sous-domaine | `reuseport` | Backend prod | Backend preprod |
|-------|--------------|-------------|-------------------------|------------------------|
| Org | yavsc | **oui** | `https://localhost:83` | `http://localhost:89` |
| Api | api | non | `https://localhost:87` | `http://localhost:93` |
| Blogs | blogs | non | `https://localhost:85` | `http://localhost:91` |
Domaines : `<prefix><subdomain>.pschneider.fr`
(prod `yavsc.pschneider.fr` ; preprod `preprod-yavsc.pschneider.fr`).
## Déploiement sur le host
```bash
sudo cp generated/nginx/yavscOrg /etc/nginx/sites-available/yavscOrg
sudo ln -sf /etc/nginx/sites-available/yavscOrg /etc/nginx/sites-enabled/yavscOrg
# (idem yavscApi, yavscBlogs)
sudo nginx -t && sudo systemctl reload nginx
```
## Vérification (sur le host)
```bash
# 1. Module h3 présent
nginx -V 2>&1 | grep -o 'http_v3_module'
# 2. UDP 443 ouvert
sudo ufw status | grep '443/udp'
# 3. En-tête Alt-Svc annoncé
curl -sI https://yavsc.pschneider.fr/ | grep -i alt-svc # → h3=":443"
# 4. Requête HTTP/3 effective (curl compilé avec HTTP/3)
curl -v --http3 https://yavsc.pschneider.fr/ 2>&1 | grep -i 'HTTP/3'
# 5. Fallback h2 et h1 toujours fonctionnels
curl --http2 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
curl --http1.1 https://yavsc.pschneider.fr/ -o /dev/null -w '%{http_code}\n'
```
Répéter 3-5 pour `api.pschneider.fr` et `blogs.pschneider.fr` (prod) et
les trois domaines preprod. Sans curl HTTP/3, un navigateur : DevTools →
Network → colonne « Protocole » → `h3` après mise à niveau.
## Comment ça marche (template + envsubst)
`contrib/template.vhost` contient les placeholders `$DOMAIN`,
`$BACKEND_SCHEME`, `$HTTP_HOST`, `$BACKEND_PORT`, `$QUIC_OPTS`. La règle
`generated/nginx/$(APP_INSTALL_PREFIX)yavsc%` du `Makefile` les substitue
via `envsubst` avec une **allowlist** :
```makefile
envsubst '$DOMAIN $BACKEND_SCHEME $HTTP_HOST $BACKEND_PORT $QUIC_OPTS'
```
Cette allowlist limite la substitution aux 5 variables citées, de sorte
que les variables nginx intrinsèques (`$host`, `$scheme`, `$remote_addr`,
`$proxy_add_x_forwarded_for`, `$request_uri`) survivent intactes. Sans
allowlist, `envsubst` détruirait ces variables nginx.
## Hors périmètre
- `docker-compose` / `Dockerfile` : non touchés (déploiement bare-metal
systemd + nginx).
- Backend HTTP/2 vers Kestrel : nginx open-source ne supporte que
HTTP/1.1 en upstream ; on garde `proxy_http_version 1.1`.
- `ssl_protocols TLSv1.3` (suivant `contrib/quic.md`) désactive TLS 1.2
côté TCP aussi. Pour conserver la compat TLS 1.2 sur le fallback TCP,
relâcher en `ssl_protocols TLSv1.2 TLSv1.3;` (QUIC reste en 1.3).

View file

@ -1,33 +0,0 @@
{
"Site": {
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["api"],
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN"
],
"Bills": "bills",
"TempDir": "temp"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information"
}
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:92"
},
"Https": {
"Url": "https://localhost:93",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-api.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-api.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -1,40 +0,0 @@
{
"Site": {
"Title": "Yavsc Blogs",
"Description": "A collection of blogs about software development, technology, and programming.",
"Keywords": "software development, technology, programming, coding, blogs",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": ["blogs"],
"CorsAllowedOrigins": [
"https://preprod-api.$DOMAIN"
],
"Blog": "blog"
},
"ConnectionStrings": {
"YavscConnection": "Server=localhost;Port=5432;Database=lame-db-name;Username=lame-user-name;Password=lame-password;"
},
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft": "Warning",
"Microsoft.Hosting.Lifetime": "Information",
"Microsoft.AspNetCore.Authentication": "Debug"
}
},
"AllowedHosts": "*",
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:90"
},
"Https": {
"Url": "https://localhost:91",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-blogs.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -1,74 +0,0 @@
{
"Logging": {
"LogLevel": {
"Default": "Information"
}
},
"AllowedHosts": "*",
"Site": {
"Title": "yavsc",
"Slogan": "Yavsc!",
"StyleSheet": "/css/default.css",
"Authority": "https://preprod-yavsc.$DOMAIN",
"Audience": [
"blogs",
"api"
],
"ExternalUrl": "https://preprod-yavsc.$DOMAIN",
"ApiUrl": "https://preprod-api.$DOMAIN",
"CorsAllowedOrigins": [
"https://preprod-blogs.$DOMAIN",
"https://preprod-api.$DOMAIN"
],
"Banner": "/images/banner-dewang.jpg",
"Owner": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Admin": {
"Name": "Paul Schneider",
"EMail": "paul@pschneider.fr"
},
"Quota": 200000000,
"Blog": "blog",
"Avatars": "avatars",
"Bills": "bills",
"TempDir": "temp",
"DataDir": "data",
"GitRepository": "git"
},
"Smtp": {
"Host": "mercure.pschneider.fr",
"Port": 465,
"SenderName": "Paul Schneider",
"SenderEmail": "paul@pschneider.fr",
"UserName": "paul",
"Password": "$SMTP_PASSWORD"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"Moderation": {
"AutoApproveThreshold": 0.7,
"AutoRejectThreshold": 0.9
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:88"
},
"Https": {
"Url": "https://localhost:89",
"Certificate": {
"Path": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/fullchain.pem",
"KeyPath": "/etc/letsencrypt/live/preprod-yavsc.$DOMAIN/privkey.pem"
},
"Protocols": "Http1AndHttp2AndHttp3"
}
}
}
}

View file

@ -1,61 +0,0 @@
# Généré depuis contrib/template.vhost par `make generate-nginx` — NE PAS ÉDITER.
# Vhost $DOMAIN — backend $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT
#
# HTTP/3 (QUIC) termine au edge nginx (plan contrib/quic.md). Requiert un
# nginx compilé avec http_v3_module (≥ 1.25 ; `http3 on` ≥ 1.27.4) + UDP 443
# ouvert. Kestrel reste backend h1/h2 (mais configure aussi h3 côté logiciel,
# cf. template.appsettings-*.json — capacité, non joignable derrière nginx).
#
# Variables substituées par envsubst (allowlist) :
# DOMAIN BACKEND_SCHEME HTTP_HOST BACKEND_PORT QUIC_OPTS
# $QUIC_OPTS = "reuseport" sur un seul vhost (Org) ET en production
# uniquement ; vide en preprod et sur les autres hôtes. Règle : reuseport
# une seule fois par addr:port (443/udp) — or preprod et prod partagent la
# même IP, donc seule la prod (Org) l'active.
# Les variables nginx intrinsèques ($host, $scheme, $remote_addr, …) sont
# préservées (hors allowlist).
# Port 80 : redirection vers HTTPS. (Certbot/nginx-plugin gère les challenges
# ACME ; ne pas servir l'app en HTTP clair.)
server {
listen 80;
listen [::]:80;
server_name $DOMAIN;
return 301 https://$host$request_uri;
}
server {
# 1. TCP : HTTPS classique + HTTP/2
listen 443 ssl;
listen [::]:443 ssl;
http2 on;
# 2. UDP : HTTP/3 (QUIC). reuseport sur UN seul vhost (Org via QUIC_OPTS).
listen 443 quic$QUIC_OPTS;
listen [::]:443 quic$QUIC_OPTS;
http3 on;
server_name $DOMAIN;
# 3. Certificats Let's Encrypt (Certbot).
ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem; # managed by Certbot
# QUIC exige TLS 1.3. (TLS 1.2 conservable pour le fallback TCP :
# `ssl_protocols TLSv1.2 TLSv1.3;` — QUIC reste en 1.3.)
ssl_protocols TLSv1.3;
# 4. Annonce h3 au navigateur sur 443/udp. `always` = émis même en erreur.
add_header Alt-Svc 'h3=":443"; ma=86400' always;
location / {
proxy_pass $BACKEND_SCHEME://$HTTP_HOST:$BACKEND_PORT;
proxy_http_version 1.1;
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;
proxy_read_timeout 100s;
proxy_buffering off;
}
include server-status.conf;
}

View file

@ -11,7 +11,7 @@
<AndroidEnableProfiledAot>false</AndroidEnableProfiledAot> <AndroidEnableProfiledAot>false</AndroidEnableProfiledAot>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -6,7 +6,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>

View file

@ -22,8 +22,7 @@ public class EstimateEditionPageViewModelTests
private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null) private static EstimateEditionPageViewModel CreateViewModel(StubEstimateApi api, BillingQuerySummaryDto? query = null)
{ {
var client = new EstimateApiClient(api, "https://business.example/api/v1/"); var client = new EstimateApiClient(api, "https://business.example/api/v1/");
var fsClient = new UserFilesApiClient(api, "https://blogs.example/api/v1/"); return new EstimateEditionPageViewModel(query ?? SampleQuery(), client);
return new EstimateEditionPageViewModel(query ?? SampleQuery(), client, fsClient);
} }
[Fact] [Fact]
@ -144,8 +143,10 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null); await vm.SendCommand.ExecuteAsync(null);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); Assert.Equal("https://business.example/api/v1/estimate", api.LastPath);
var payload = Assert.IsType<EstimateDto>(postCall.Body); Assert.Equal(HttpMethod.Post, api.LastMethod);
var payload = Assert.IsType<EstimateDto>(api.LastBody);
Assert.Equal(42, payload.CommandId); Assert.Equal(42, payload.CommandId);
Assert.Equal("cli-1", payload.ClientId); Assert.Equal("cli-1", payload.ClientId);
Assert.Equal("Brush", payload.CommandType); Assert.Equal("Brush", payload.CommandType);
@ -215,8 +216,7 @@ public class EstimateEditionPageViewModelTests
await vm.SendCommand.ExecuteAsync(null); await vm.SendCommand.ExecuteAsync(null);
var postCall = Assert.Single(api.Calls, c => c.Method == HttpMethod.Post && c.Path == "https://business.example/api/v1/estimate"); var payload = Assert.IsType<EstimateDto>(api.LastBody);
var payload = Assert.IsType<EstimateDto>(postCall.Body);
Assert.Equal(2, payload.Bill.Count); Assert.Equal(2, payload.Bill.Count);
Assert.Equal(-5m, payload.Bill[1].UnitaryCost); Assert.Equal(-5m, payload.Bill[1].UnitaryCost);
Assert.True(vm.HasSent); Assert.True(vm.HasSent);
@ -230,20 +230,11 @@ public class EstimateEditionPageViewModelTests
public object? LastBody { get; private set; } public object? LastBody { get; private set; }
public Exception? Failure { get; init; } public Exception? Failure { get; init; }
/// <summary>Historique complet des appels (le plus récent en dernier).</summary> public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
public List<(HttpMethod Method, string Path, object? Body)> Calls { get; } = new();
private void Record(HttpMethod method, string path, object? body)
{ {
LastMethod = method; LastMethod = method;
LastPath = path; LastPath = path;
LastBody = body; LastBody = body;
Calls.Add((method, path, body));
}
public Task<T> CallAsync<T>(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{
Record(method, path, body);
if (Failure is not null) if (Failure is not null)
{ {
@ -262,7 +253,9 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, object? body = null, CancellationToken ct = default)
{ {
Record(method, path, body); LastMethod = method;
LastPath = path;
LastBody = body;
return Task.CompletedTask; return Task.CompletedTask;
} }
@ -272,12 +265,6 @@ public class EstimateEditionPageViewModelTests
public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default) public Task CallAsync(HttpMethod method, string path, Func<HttpContent> contentFactory, CancellationToken ct = default)
=> CallAsync(method, path, (object?)null, ct); => CallAsync(method, path, (object?)null, ct);
public Task<byte[]> DownloadAsync(HttpMethod method, string path, CancellationToken ct = default)
{
Record(method, path, null);
return Task.FromResult(Array.Empty<byte>());
}
public ValueTask DisposeAsync() => ValueTask.CompletedTask; public ValueTask DisposeAsync() => ValueTask.CompletedTask;
} }
} }

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -22,7 +22,6 @@ public static class ServiceCollectionHelpers
var api = new YavscApiClient(settings, tokenStore); var api = new YavscApiClient(settings, tokenStore);
var client = new BlogApiClient(api, settings.BlogsApiUrl); var client = new BlogApiClient(api, settings.BlogsApiUrl);
var userFilesClient = new UserFilesApiClient(api, settings.BlogsApiUrl);
var circleClient = new CircleApiClient(api, settings.BlogsApiUrl); var circleClient = new CircleApiClient(api, settings.BlogsApiUrl);
var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl); var blogAclClient = new BlogAclApiClient(api, settings.BlogsApiUrl);
var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl); var userSearchClient = new UserSearchClient(api, settings.BlogsApiUrl);
@ -66,7 +65,6 @@ public static class ServiceCollectionHelpers
services.AddTransient<EstimateEditionPage>(); services.AddTransient<EstimateEditionPage>();
services.AddTransient<EstimateListPage>(); services.AddTransient<EstimateListPage>();
services.AddTransient<EstimateValidationPage>(); services.AddTransient<EstimateValidationPage>();
services.AddTransient<MyFilesPage>();
// ViewModels // ViewModels
services.AddSingleton(settings); services.AddSingleton(settings);
@ -79,7 +77,6 @@ public static class ServiceCollectionHelpers
services.AddSingleton(billingClient); services.AddSingleton(billingClient);
services.AddSingleton(estimateClient); services.AddSingleton(estimateClient);
services.AddSingleton(frontClient); services.AddSingleton(frontClient);
services.AddSingleton(userFilesClient);
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,7 +5,7 @@
<LangVersion>latest</LangVersion> <LangVersion>latest</LangVersion>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -56,7 +56,6 @@ public class ViewLocator : IDataTemplate
EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(), EstimateEditionPageViewModel => services.GetRequiredService<EstimateEditionPage>(),
EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(), EstimateListPageViewModel => services.GetRequiredService<EstimateListPage>(),
EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(), EstimateValidationPageViewModel => services.GetRequiredService<EstimateValidationPage>(),
MyFilesViewModel => services.GetRequiredService<MyFilesPage>(),
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

@ -132,18 +132,14 @@ public partial class BillingQueriesPageViewModel : ViewModelBase, IActionStatusV
{ {
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 frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
Activity, Activity,
Performer, Performer,
Form, Form,
_billingClient, _billingClient,
frontClient, frontClient,
fsClient,
details, details,
IsReadOnly, IsReadOnly);
canAttach: !IsReadOnly);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)

View file

@ -1,5 +1,4 @@
using System; using System;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
@ -16,17 +15,8 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
{ {
private readonly BillingApiClient _billingClient; private readonly BillingApiClient _billingClient;
private readonly FrontOfficeApiClient _frontClient; private readonly FrontOfficeApiClient _frontClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQueryDetailsDto _details; private readonly BillingQueryDetailsDto _details;
/// <summary>
/// Vrai lorsque l'utilisateur courant est le client de la demande
/// et peut y attacher/détacher des fichiers de son espace perso.
/// Le fournisseur consulte et télécharge les pièces mais n'attache
/// pas (il joint ses documents au devis, côté <c>EstimateEditionPage</c>).
/// </summary>
public bool CanAttach { get; }
public ActivityInfo Activity { get; } public ActivityInfo Activity { get; }
public ActivityUserDisplayItem Performer { get; } public ActivityUserDisplayItem Performer { get; }
public CommandFormSummary Form { get; } public CommandFormSummary Form { get; }
@ -62,10 +52,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
[ObservableProperty] [ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Pret.");
/// <summary>Pièces jointes rattachées à la demande (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public override bool CanNavigateNext public override bool CanNavigateNext
{ {
get => false; get => false;
@ -84,27 +70,20 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
CommandFormSummary form, CommandFormSummary form,
BillingApiClient billingClient, BillingApiClient billingClient,
FrontOfficeApiClient frontClient, FrontOfficeApiClient frontClient,
UserFilesApiClient fsClient,
BillingQueryDetailsDto details, BillingQueryDetailsDto details,
bool isReadOnly, bool isReadOnly)
bool canAttach)
{ {
Activity = activity ?? throw new ArgumentNullException(nameof(activity)); Activity = activity ?? throw new ArgumentNullException(nameof(activity));
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)); _frontClient = frontClient ?? throw new ArgumentNullException(nameof(frontClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
_details = details ?? throw new ArgumentNullException(nameof(details)); _details = details ?? throw new ArgumentNullException(nameof(details));
IsReadOnly = isReadOnly; IsReadOnly = isReadOnly;
CanAttach = canAttach;
this.SetInfoStatus("Details de commande charges."); this.SetInfoStatus("Details de commande charges.");
} }
/// <summary>Constructeur pour le designer Avalonia.</summary>
public BillingQueryDetailsPageViewModel() : this(null!, null!, null!, null!, null!, null!, null!, false, false) { }
[RelayCommand] [RelayCommand]
private async Task OpenEditorAsync() private async Task OpenEditorAsync()
{ {
@ -155,110 +134,6 @@ public partial class BillingQueryDetailsPageViewModel : ViewModelBase, IActionSt
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>Charge les pièces jointes de la demande.</summary>
public async Task InitializeAsync()
{
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task LoadQueryAttachmentsAsync()
{
try
{
var list = await _frontClient
.GetQueryAttachmentsAsync(BillingCode, Id)
.ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
// Non bloquant : les pièces jointes sont un complément.
this.SetWarningStatus($"Pièces jointes indisponibles : {ex.Message}");
}
}
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché à la demande par référence via
/// <c>POST front/query/{id}/attachments</c>. Réservé au client
/// (<see cref="CanAttach"/>).
/// </summary>
[RelayCommand]
private async Task AddQueryAttachmentAsync()
{
if (!CanAttach) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var billingCode = BillingCode;
var queryId = Id;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _frontClient.AttachQueryFileAsync(billingCode, queryId, fileId).ConfigureAwait(true);
await LoadQueryAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée à la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachQueryFileAsync(AttachmentDto? attachment)
{
if (attachment is null || !CanAttach) return;
IsBusy = true;
try
{
await _frontClient.DetachQueryFileAsync(BillingCode, Id, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée de la demande.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadQueryAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
/// <summary> /// <summary>
/// Download the estimate linked to this command as a LaTeX source /// Download the estimate linked to this command as a LaTeX source
/// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is /// (<c>GET api/v1/front/query/{Id}/estimate.tex</c>). The estimate is

View file

@ -25,7 +25,6 @@ namespace PostIt.ViewModels;
public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatusViewModel
{ {
private readonly EstimateApiClient _estimateClient; private readonly EstimateApiClient _estimateClient;
private readonly UserFilesApiClient _fsClient;
private readonly BillingQuerySummaryDto _query; private readonly BillingQuerySummaryDto _query;
public long QueryId => _query.Id; public long QueryId => _query.Id;
@ -69,14 +68,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
[ObservableProperty] [ObservableProperty]
public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt."); public partial StatusNotice ActionStatus { get; set; } = StatusNotice.Info("Prêt.");
/// <summary>Identifiant du devis, renseigné après l'envoi.</summary>
[ObservableProperty, NotifyCanExecuteChangedFor(nameof(AddAttachmentCommand))]
public partial long? EstimateId { get; set; }
/// <summary>Pièces jointes rattachées au devis (par référence).</summary>
[ObservableProperty]
public partial ObservableCollection<AttachmentDto> Attachments { get; set; } = new();
public decimal Total => Lines.Sum(line => line.LineTotal); public decimal Total => Lines.Sum(line => line.LineTotal);
public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}"; public string TotalLabel => $"{Total:0.00} {Lines.FirstOrDefault()?.Currency ?? "EUR"}";
@ -95,14 +86,10 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
protected set { _ = value; } protected set { _ = value; }
} }
public EstimateEditionPageViewModel( public EstimateEditionPageViewModel(BillingQuerySummaryDto query, EstimateApiClient estimateClient)
BillingQuerySummaryDto query,
EstimateApiClient estimateClient,
UserFilesApiClient fsClient)
{ {
_query = query ?? throw new ArgumentNullException(nameof(query)); _query = query ?? throw new ArgumentNullException(nameof(query));
_estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient)); _estimateClient = estimateClient ?? throw new ArgumentNullException(nameof(estimateClient));
_fsClient = fsClient ?? throw new ArgumentNullException(nameof(fsClient));
EstimateDescription = query.Description ?? string.Empty; EstimateDescription = query.Description ?? string.Empty;
Lines.CollectionChanged += OnLinesCollectionChanged; Lines.CollectionChanged += OnLinesCollectionChanged;
@ -111,9 +98,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée."); this.SetInfoStatus("Complétez le devis puis envoyez-le. La demande associée sera validée.");
} }
/// <summary>Constructeur pour le designer Avalonia.</summary>
public EstimateEditionPageViewModel() : this(null!, null!, null!) { }
[RelayCommand] [RelayCommand]
private void AddLine() private void AddLine()
{ {
@ -157,11 +141,9 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true); var created = await _estimateClient.CreateAsync(payload).ConfigureAwait(true);
HasSent = true; HasSent = true;
EstimateId = created.Id;
OnPropertyChanged(nameof(SendLabel)); OnPropertyChanged(nameof(SendLabel));
this.SetInfoStatus( this.SetInfoStatus(
$"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée."); $"Devis #{created.Id} envoyé ({created.Bill.Count} ligne(s)). La demande #{QueryId} est validée.");
await LoadAttachmentsAsync().ConfigureAwait(true);
} }
catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden) catch (HttpRequestException ex) when (ex.StatusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{ {
@ -189,106 +171,6 @@ public partial class EstimateEditionPageViewModel : ViewModelBase, IActionStatus
await app.GoBackAsync().ConfigureAwait(true); await app.GoBackAsync().ConfigureAwait(true);
} }
/// <summary>
/// Charge les pièces jointes rattachées au devis courant
/// (<c>GET estimate/{id}/attachments</c>).
/// </summary>
[RelayCommand]
private async Task LoadAttachmentsAsync()
{
if (EstimateId is null || _estimateClient is null) return;
try
{
var list = await _estimateClient.GetAttachmentsAsync(EstimateId.Value).ConfigureAwait(true);
Attachments.Clear();
if (list is not null)
foreach (var a in list) Attachments.Add(a);
}
catch (Exception ex)
{
this.SetWarningStatus($"Impossible de charger les pièces jointes : {ex.Message}");
}
}
private bool CanAddAttachment() => EstimateId is not null && !IsBusy;
/// <summary>
/// Ouvre la page « My Files » en mode sélecteur ; le fichier choisi
/// est rattaché au devis par référence via
/// <c>POST estimate/{id}/attachments</c>.
/// </summary>
[RelayCommand(CanExecute = nameof(CanAddAttachment))]
private async Task AddAttachmentAsync()
{
if (EstimateId is null) return;
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var estimateId = EstimateId.Value;
var picker = new MyFilesViewModel(_fsClient, async fileId =>
{
try
{
await _estimateClient.AttachFileAsync(estimateId, fileId).ConfigureAwait(true);
await LoadAttachmentsAsync().ConfigureAwait(true);
this.SetInfoStatus("Pièce jointe ajoutée au devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de l'attachement : {ex.Message}");
}
});
await picker.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(picker).ConfigureAwait(true);
}
[RelayCommand]
private async Task DetachFileAsync(AttachmentDto? attachment)
{
if (attachment is null || EstimateId is null) return;
IsBusy = true;
try
{
await _estimateClient.DetachFileAsync(EstimateId.Value, attachment.FileId).ConfigureAwait(true);
Attachments.Remove(attachment);
this.SetInfoStatus("Pièce jointe détachée du devis.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du détachement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadAttachmentAsync(AttachmentDto? attachment)
{
if (attachment is null) return;
IsBusy = true;
try
{
var bytes = await _fsClient.DownloadFileAsync(attachment.FileId).ConfigureAwait(true);
var name = System.IO.Path.GetFileName(attachment.Path ?? "file");
var saved = await FileSaveHelpers.SaveAsync(name, "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
internal EstimateDto BuildPayload() internal EstimateDto BuildPayload()
{ {
return new EstimateDto return new EstimateDto

View file

@ -155,7 +155,6 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
.ConfigureAwait(true); .ConfigureAwait(true);
var (activity, performer, form) = BuildNavigationContext(SelectedQuery); var (activity, performer, form) = BuildNavigationContext(SelectedQuery);
var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>(); var frontClient = app.ServiceProvider?.GetRequiredService<FrontOfficeApiClient>();
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>();
var vm = new BillingQueryDetailsPageViewModel( var vm = new BillingQueryDetailsPageViewModel(
activity, activity,
@ -163,12 +162,9 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
form, form,
_billingClient, _billingClient,
frontClient, frontClient,
fsClient,
details, details,
isReadOnly: false, isReadOnly: false);
canAttach: false);
await vm.InitializeAsync().ConfigureAwait(true);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }
catch (Exception ex) catch (Exception ex)
@ -247,13 +243,7 @@ public partial class ProviderOngoingRequestsPageViewModel : ViewModelBase, IActi
throw new InvalidOperationException("Application PostIt indisponible."); throw new InvalidOperationException("Application PostIt indisponible.");
} }
var fsClient = app.ServiceProvider?.GetRequiredService<UserFilesApiClient>(); var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient);
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new EstimateEditionPageViewModel(SelectedQuery, _estimateClient, fsClient);
await app.PushPageAsync(vm).ConfigureAwait(true); await app.PushPageAsync(vm).ConfigureAwait(true);
} }

View file

@ -35,14 +35,12 @@ public class HomePageViewModel : ViewModelBase
OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client)); OpenClientEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Client));
OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider)); OpenProviderEstimates = new AsyncRelayCommand(() => OpenEstimateListAsync(EstimateListPerspective.Provider));
OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync); OpenBlogs = new AsyncRelayCommand(App.PushBlogsPageAsync);
OpenMyFiles = new AsyncRelayCommand(OpenMyFilesAsync);
} }
public IAsyncRelayCommand OpenBlogs { get; } public IAsyncRelayCommand OpenBlogs { get; }
public IAsyncRelayCommand OpenActivities { get; } public IAsyncRelayCommand OpenActivities { get; }
public IAsyncRelayCommand OpenProviderRequests { get; } public IAsyncRelayCommand OpenProviderRequests { get; }
public IAsyncRelayCommand OpenClientEstimates { get; } public IAsyncRelayCommand OpenClientEstimates { get; }
public IAsyncRelayCommand OpenProviderEstimates { get; } public IAsyncRelayCommand OpenProviderEstimates { get; }
public IAsyncRelayCommand OpenMyFiles { get; }
private async Task OpenActivitiesAsync() private async Task OpenActivitiesAsync()
{ {
@ -103,29 +101,6 @@ public class HomePageViewModel : ViewModelBase
await app.PushPageAsync(vm); await app.PushPageAsync(vm);
} }
/// <summary>
/// Ouvre la page « Mes fichiers » (espace de stockage personnel),
/// servie par le host Blogs via <c>api/v1/fs</c>.
/// </summary>
private async Task OpenMyFilesAsync()
{
var app = (App?)Application.Current;
if (app is null)
{
throw new InvalidOperationException("Application PostIt indisponible.");
}
var fsClient = app.ServiceProvider?.GetRequiredService<Yavsc.Api.Client.UserFilesApiClient>();
if (fsClient is null)
{
throw new InvalidOperationException("Client fichiers indisponible.");
}
var vm = new MyFilesViewModel(fsClient);
await vm.InitializeAsync();
await app.PushPageAsync(vm);
}
/// <summary> /// <summary>
/// Avalonia designer constructor. Builds a self-contained VM /// Avalonia designer constructor. Builds a self-contained VM
/// with a freshly-constructed Settings so the XAML preview can /// with a freshly-constructed Settings so the XAML preview can

View file

@ -1,229 +0,0 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using PostIt.Helpers;
using Yavsc.Api.Client;
namespace PostIt.ViewModels;
/// <summary>
/// Gestion de l'espace de stockage personnel (« My Files ») :
/// navigation dans l'arbre, téléversement, suppression, téléchargement.
/// Sert aussi de **sélecteur** pour attacher un fichier à un devis
/// (fournisseur) ou à une demande (client) par référence : lorsque
/// <see cref="IsPickerMode"/> est vrai, la sélection d'un fichier invoque
/// <see cref="_onFilePicked"/> avec l'identifiant <see cref="FileEntryDto.Id"/>
/// puis revient à la page précédente.
/// </summary>
public partial class MyFilesViewModel : ViewModelBase, IActionStatusViewModel
{
private readonly UserFilesApiClient _fs;
private readonly Action<long>? _onFilePicked;
/// <summary>
/// Sous-répertoire courant, relatif à la racine de l'espace perso
/// (chaîne vide à la racine). Servi tel quel à <c>GET/POST fs/{subdir}</c>.
/// </summary>
[ObservableProperty]
public partial string CurrentPath { get; set; } = string.Empty;
public string PathLabel => string.IsNullOrWhiteSpace(CurrentPath)
? "Mes fichiers"
: $"Mes fichiers / {CurrentPath}";
public bool IsPickerMode => _onFilePicked is not null;
public bool CanGoUp => !string.IsNullOrWhiteSpace(CurrentPath);
[ObservableProperty]
public partial ObservableCollection<FileEntryDto> Files { get; set; } = new();
[ObservableProperty]
public partial ObservableCollection<DirEntryDto> SubDirectories { get; set; } = new();
[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.");
public override bool CanNavigateNext { get => false; protected set { _ = value; } }
public override bool CanNavigatePrevious { get => true; protected set { _ = value; } }
public MyFilesViewModel(UserFilesApiClient fs, Action<long>? onFilePicked = null)
{
_fs = fs ?? throw new ArgumentNullException(nameof(fs));
_onFilePicked = onFilePicked;
}
/// <summary>Constructeur pour le designer Avalonia.</summary>
public MyFilesViewModel() : this(null!) { }
public async Task InitializeAsync()
{
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task RefreshAsync()
{
IsBusy = true;
try
{
var dir = await _fs.ListAsync(CurrentPath).ConfigureAwait(true);
Files.Clear();
SubDirectories.Clear();
if (dir?.Files is not null)
foreach (var f in dir.Files) Files.Add(f);
if (dir?.SubDirectories is not null)
foreach (var d in dir.SubDirectories) SubDirectories.Add(d);
this.SetInfoStatus($"{Files.Count} fichier(s), {SubDirectories.Count} dossier(s).");
}
catch (Exception ex)
{
this.SetErrorStatus($"Erreur de listage : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task OpenDirAsync(DirEntryDto? dir)
{
if (dir is null) return;
CurrentPath = CombinePath(CurrentPath, dir.Name ?? string.Empty);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
[RelayCommand]
private async Task UpAsync()
{
if (!CanGoUp) return;
CurrentPath = ParentPath(CurrentPath);
OnPropertyChanged(nameof(PathLabel));
OnPropertyChanged(nameof(CanGoUp));
await RefreshAsync().ConfigureAwait(true);
}
/// <summary>Téléversement déclenché par le code-behind (picker OS).</summary>
public async Task UploadAsync(System.Collections.Generic.IReadOnlyCollection<UploadFile> uploads)
{
if (uploads is null || uploads.Count == 0) return;
IsBusy = true;
try
{
var received = await _fs.UploadAsync(CurrentPath, uploads).ConfigureAwait(true);
this.SetInfoStatus($"{received.Count} fichier(s) téléversé(s).");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléversement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DeleteFileAsync(FileEntryDto? file)
{
if (file is null) return;
IsBusy = true;
try
{
await _fs.DeleteAsync(CombinePath(CurrentPath, file.Name ?? string.Empty)).ConfigureAwait(true);
this.SetInfoStatus($"Fichier supprimé.");
await RefreshAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec de la suppression : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task DownloadFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null) return;
IsBusy = true;
try
{
var bytes = await _fs.DownloadFileAsync(file.Id.Value).ConfigureAwait(true);
var saved = await FileSaveHelpers.SaveAsync(file.Name ?? "file", "bin", bytes).ConfigureAwait(true);
this.SetInfoStatus(saved ? "Fichier enregistré." : "Téléchargement annulé.");
}
catch (Exception ex)
{
this.SetErrorStatus($"Échec du téléchargement : {ex.Message}");
}
finally
{
IsBusy = false;
}
}
[RelayCommand]
private async Task PickFileAsync(FileEntryDto? file)
{
if (file is null || file.Id is null)
{
this.SetWarningStatus("Ce fichier n'est pas référencé (aucun id). Téléversez-le d'abord.");
return;
}
var callback = _onFilePicked;
if (callback is null)
{
// Mode gestion : le téléchargement est l'action par défaut.
await DownloadFileAsync(file).ConfigureAwait(true);
return;
}
callback(file.Id.Value);
await GoBackAsync().ConfigureAwait(true);
}
[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);
}
private static string CombinePath(string current, string name)
{
if (string.IsNullOrWhiteSpace(current)) return name;
return $"{current}/{name}";
}
private static string ParentPath(string current)
{
if (string.IsNullOrWhiteSpace(current)) return string.Empty;
var idx = current.LastIndexOf('/');
return idx < 0 ? string.Empty : current[..idx];
}
}

View file

@ -1,7 +1,6 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.BillingQueryDetailsPage" x:Class="PostIt.Views.BillingQueryDetailsPage"
x:DataType="vm:BillingQueryDetailsPageViewModel" x:DataType="vm:BillingQueryDetailsPageViewModel"
@ -81,53 +80,6 @@
</Grid> </Grid>
</Border> </Border>
<!-- Pièces jointes (par référence à l'espace perso du client) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddQueryAttachmentCommand}"
IsVisible="{Binding CanAttach}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DownloadQueryAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[vm:BillingQueryDetailsPageViewModel].DetachQueryFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[vm:BillingQueryDetailsPageViewModel].CanAttach}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
<Expander Header="Metadonnees techniques" <Expander Header="Metadonnees techniques"
IsExpanded="False"> IsExpanded="False">
<Border BorderThickness="1" <Border BorderThickness="1"

View file

@ -1,7 +1,6 @@
<ContentPage xmlns="https://github.com/avaloniaui" <ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels" xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls" xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.EstimateEditionPage" x:Class="PostIt.Views.EstimateEditionPage"
x:DataType="vm:EstimateEditionPageViewModel" x:DataType="vm:EstimateEditionPageViewModel"
@ -134,52 +133,6 @@
</Grid> </Grid>
</StackPanel> </StackPanel>
</Border> </Border>
<!-- Pièces jointes (par référence à l'espace perso du fournisseur) -->
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="8"
Padding="10"
IsVisible="{Binding HasSent}">
<StackPanel Spacing="8">
<Grid ColumnDefinitions="*,Auto">
<TextBlock Grid.Column="0"
Text="Pièces jointes"
FontWeight="SemiBold"
VerticalAlignment="Center" />
<Button Grid.Column="1"
Content="Ajouter depuis My Files"
Command="{Binding AddAttachmentCommand}" />
</Grid>
<ItemsControl ItemsSource="{Binding Attachments}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:AttachmentDto">
<Border BorderThickness="1"
BorderBrush="#11000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Path}"
VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" />
<Button Grid.Column="1"
Content="Télécharger"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DownloadAttachmentCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="3"
Content="Détacher"
Command="{Binding $parent[vm:EstimateEditionPageViewModel].DetachFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</Border>
</StackPanel> </StackPanel>
</ScrollViewer> </ScrollViewer>

View file

@ -34,9 +34,5 @@
Command="{Binding OpenProviderEstimates}" Command="{Binding OpenProviderEstimates}"
HorizontalAlignment="Center" HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/> IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
<Button Content="Mes fichiers"
Command="{Binding OpenMyFiles}"
HorizontalAlignment="Center"
IsEnabled="{Binding SessionStatus.IsLoggedIn}"/>
</StackPanel> </StackPanel>
</ContentPage> </ContentPage>

View file

@ -1,90 +0,0 @@
<ContentPage xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:PostIt.ViewModels"
xmlns:client="using:Yavsc.Api.Client"
xmlns:postitControls="using:PostIt.Controls"
x:Class="PostIt.Views.MyFilesPage"
x:DataType="vm:MyFilesViewModel"
Header="Mes fichiers">
<Grid RowDefinitions="Auto,Auto,*,Auto" Margin="12">
<StackPanel Grid.Row="0" Spacing="3">
<TextBlock Text="{Binding PathLabel}"
FontSize="20"
FontWeight="Bold" />
<TextBlock Text="Espace de stockage personnel. Téléversez vos fichiers puis attachez-les à un devis ou une demande."
Opacity="0.7"
TextWrapping="Wrap" />
</StackPanel>
<Grid Grid.Row="1" ColumnDefinitions="Auto,8,Auto,8,*" Margin="0,10,0,8">
<Button Grid.Column="0"
Content="Retour"
Command="{Binding BackCommand}" />
<Button Grid.Column="2"
Content="Remonter"
Command="{Binding UpCommand}"
IsEnabled="{Binding CanGoUp}" />
<Button Grid.Column="4"
Content="Téléverser…"
Click="Upload_Click"
HorizontalAlignment="Right" />
</Grid>
<ScrollViewer Grid.Row="2">
<StackPanel Spacing="10">
<ItemsControl ItemsSource="{Binding SubDirectories}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:DirEntryDto">
<Button Content="📁 {Binding Name}"
Command="{Binding $parent[vm:MyFilesViewModel].OpenDirCommand}"
CommandParameter="{Binding}"
HorizontalAlignment="Left"
Margin="0,0,0,4" />
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<ItemsControl ItemsSource="{Binding Files}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="client:FileEntryDto">
<Border BorderThickness="1"
BorderBrush="#22000000"
CornerRadius="6"
Padding="8"
Margin="0,0,0,6">
<Grid ColumnDefinitions="*,Auto,8,Auto,8,Auto">
<TextBlock Grid.Column="0"
Text="{Binding Name}"
VerticalAlignment="Center"
TextWrapping="NoWrap" />
<Button Grid.Column="1"
Content="Joindre"
Command="{Binding $parent[vm:MyFilesViewModel].PickFileCommand}"
CommandParameter="{Binding}"
IsVisible="{Binding $parent[vm:MyFilesViewModel].IsPickerMode}" />
<Button Grid.Column="3"
Content="Télécharger"
Command="{Binding $parent[vm:MyFilesViewModel].DownloadFileCommand}"
CommandParameter="{Binding}" />
<Button Grid.Column="5"
Content="Supprimer"
Command="{Binding $parent[vm:MyFilesViewModel].DeleteFileCommand}"
CommandParameter="{Binding}" />
</Grid>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</StackPanel>
</ScrollViewer>
<Grid Grid.Row="3" ColumnDefinitions="Auto,8,*,Auto" Margin="0,12,0,0">
<postitControls:StatusBar Grid.Column="0"
DataContext="{Binding ActionStatus}" />
<ProgressBar Grid.Column="3"
Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsBusy}" />
</Grid>
</Grid>
</ContentPage>

View file

@ -1,58 +0,0 @@
using System;
using System.IO;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Interactivity;
using PostIt.ViewModels;
namespace PostIt.Views;
public partial class MyFilesPage : ContentPage
{
public MyFilesPage()
{
InitializeComponent();
}
private async void Upload_Click(object? sender, RoutedEventArgs e)
{
var topLevel = TopLevel.GetTopLevel(this);
if (topLevel is null || DataContext is not MyFilesViewModel vm)
return;
var files = await topLevel.StorageProvider.OpenFilePickerAsync(new FilePickerOpenOptions
{
Title = "Choisir des fichiers à téléverser dans Mes fichiers",
AllowMultiple = true,
});
if (files.Count == 0)
return;
var uploads = new List<Yavsc.Api.Client.UploadFile>(files.Count);
foreach (var file in files)
{
await using var stream = await file.OpenReadAsync();
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
uploads.Add(new Yavsc.Api.Client.UploadFile(file.Name, memory.ToArray(), GetMimeType(file.Name)));
}
await vm.UploadAsync(uploads);
}
private static string GetMimeType(string fileName)
{
var ext = Path.GetExtension(fileName)?.ToLowerInvariant();
return ext switch
{
".png" => "image/png",
".jpg" or ".jpeg" => "image/jpeg",
".webp" => "image/webp",
".gif" => "image/gif",
".pdf" => "application/pdf",
_ => "application/octet-stream"
};
}
}

View file

@ -15,23 +15,5 @@ namespace Yavsc.Blogspot
// BlogPost entity implements this getter by mapping its // BlogPost entity implements this getter by mapping its
// ApplicationUser navigation to a BlogPostAuthorDto. // ApplicationUser navigation to a BlogPostAuthorDto.
BlogPostAuthorDto? Author { get; } BlogPostAuthorDto? Author { get; }
/// <summary>
/// Whether this post is published. The server derives this
/// from the existence of a row in <c>BlogSpotPublication</c>
/// and serves it on the wire so clients (PostIt) can render
/// the current publication state without a follow-up request.
///
/// <para>This MUST be declared on the interface — not just on
/// the concrete <c>BlogPost</c> entity / <c>BlogPostDto</c> —
/// because the blog API's list endpoint returns
/// <c>IEnumerable&lt;IBlogPost&gt;</c>, and the blog API host
/// serialises with System.Text.Json, which emits only the
/// members of the declared (interface) type. When
/// <c>IsPublished</c> lived only on the concrete classes, STJ
/// dropped it from the list payload and PostIt read
/// <c>false</c> for every post, including published ones.</para>
/// </summary>
bool IsPublished { get; set; }
} }
} }

View file

@ -8,17 +8,7 @@
public DateTime CreationTime { get; set; } public DateTime CreationTime { get; set; }
public DateTime LastModified { get; set; } public DateTime LastModified { get; set; }
/// <summary>
/// Identifiant de la ligne <c>UploadedFile</c> associée, quand le
/// fichier a été téléversé via <c>api/v1/fs</c> (donc référencé en
/// base). <c>null</c> pour un fichier présent sur disque sans ligne
/// de métadonnées (fichier antérieur à la fonctionnalité, ou non
/// encore backfillé). C'est cette valeur que le client lourd
/// (PostIt) envoie pour attacher le fichier à un devis/demande.
/// </summary>
public long? Id { get; set; }
} }

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-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
</Project> </Project>

View file

@ -1,66 +0,0 @@
using System;
using System.Collections.Generic;
namespace Yavsc.Api.Client;
/// <summary>
/// Représentation d'un répertoire de l'espace de stockage personnel,
/// renvoyée par <c>GET api/v1/fs/{subdir}</c> (host Blogs). Ne dépend pas
/// des helpers serveur : c'est un pur DTO de désérialisation.
/// </summary>
public sealed class UserDirectoryDto
{
public string? UserName { get; set; }
public string? SubPath { get; set; }
public FileEntryDto[] Files { get; set; } = Array.Empty<FileEntryDto>();
public DirEntryDto[] SubDirectories { get; set; } = Array.Empty<DirEntryDto>();
}
public sealed class FileEntryDto
{
/// <summary>Identifiant de la ligne UploadedFile, pour l'attachement par référence.</summary>
public long? Id { get; set; }
public string? Name { get; set; }
public long Size { get; set; }
public DateTime LastModified { get; set; }
}
public sealed class DirEntryDto
{
public string? Name { get; set; }
}
/// <summary>
/// Métadonnées d'une pièce jointe (fichier de l'espace perso rattaché à
/// un devis ou une demande), renvoyées par les endpoints d'attachement.
/// </summary>
public sealed class AttachmentDto
{
public long FileId { get; set; }
/// <summary>Chemin relatif dans l'espace perso du propriétaire.</summary>
public string? Path { get; set; }
public long Size { get; set; }
public string? ContentType { get; set; }
public string? OwnerId { get; set; }
}
/// <summary>
/// Métadonnées d'un fichier reçu après un téléversement multipart vers
/// <c>POST api/v1/fs/{subdir}</c>. Porte notamment l'identifiant
/// <see cref="FileId"/> de la ligne UploadedFile créée côté serveur.
/// </summary>
public sealed class FileReceivedDto
{
public long? FileId { get; set; }
public string? FileName { get; set; }
public long Length { get; set; }
public string? ContentType { get; set; }
public bool QuotaOffense { get; set; }
public bool Overridden { get; set; }
}
/// <summary>
/// Contenu d'un fichier à téléverser (nom, octets, type MIME), à l'image
/// de <see cref="BlogUploadFile"/> mais pour l'espace perso générique.
/// </summary>
public sealed record UploadFile(string FileName, byte[] Content, string? ContentType);

View file

@ -113,41 +113,6 @@ public sealed class EstimateApiClient
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct); return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}"), ct: ct);
} }
/// <summary>
/// Liste les pièces jointes (fichiers de l'espace perso du fournisseur)
/// rattachées au devis <paramref name="id"/> par référence
/// (<c>GET estimate/{id}/attachments</c>).
/// </summary>
public Task<List<AttachmentDto>> GetAttachmentsAsync(long id, CancellationToken ct = default)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute($"{PathPrefix}/{id}/attachments"), ct: ct);
}
/// <summary>
/// Attache un fichier de l'espace perso du fournisseur au devis
/// <paramref name="id"/> par référence (<c>POST estimate/{id}/attachments</c>).
/// Réservé au fournisseur côté serveur.
/// </summary>
public Task AttachFileAsync(long id, long fileId, CancellationToken ct = default)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
return _api.CallAsync(HttpMethod.Post, Absolute($"{PathPrefix}/{id}/attachments"),
body: new { fileId }, ct: ct);
}
/// <summary>
/// Détache un fichier du devis <paramref name="id"/>
/// (<c>DELETE estimate/{id}/attachments/{fileId}</c>). Réservé au fournisseur.
/// </summary>
public Task DetachFileAsync(long id, long fileId, CancellationToken ct = default)
{
if (id <= 0) throw new ArgumentOutOfRangeException(nameof(id));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
return _api.CallAsync(HttpMethod.Delete, Absolute($"{PathPrefix}/{id}/attachments/{fileId}"), ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString(); private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress() private Uri ResolveBusinessBaseAddress()

View file

@ -122,48 +122,6 @@ public sealed class FrontOfficeApiClient
return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct); return _api.DownloadAsync(HttpMethod.Get, Absolute(path), ct);
} }
/// <summary>
/// Liste les pièces jointes (fichiers de l'espace perso du client)
/// rattachées à la demande <paramref name="queryId"/> par référence
/// (<c>GET front/query/{queryId}/attachments</c>).
/// </summary>
public Task<List<AttachmentDto>> GetQueryAttachmentsAsync(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/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
return _api.CallAsync<List<AttachmentDto>>(HttpMethod.Get, Absolute(path), ct: ct);
}
/// <summary>
/// Attache un fichier de l'espace perso du client à sa demande
/// (<c>POST front/query/{queryId}/attachments</c>). Réservé au client.
/// </summary>
public Task AttachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
var path = $"{PathPrefix}/query/{queryId}/attachments?billingCode={Uri.EscapeDataString(billingCode)}";
return _api.CallAsync(HttpMethod.Post, Absolute(path), body: new { fileId }, ct: ct);
}
/// <summary>
/// Détache un fichier de la demande
/// (<c>DELETE front/query/{queryId}/attachments/{fileId}</c>). Réservé au client.
/// </summary>
public Task DetachQueryFileAsync(string billingCode, long queryId, long fileId, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(billingCode))
throw new ArgumentException("billingCode is required.", nameof(billingCode));
if (queryId <= 0) throw new ArgumentOutOfRangeException(nameof(queryId));
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
var path = $"{PathPrefix}/query/{queryId}/attachments/{fileId}?billingCode={Uri.EscapeDataString(billingCode)}";
return _api.CallAsync(HttpMethod.Delete, Absolute(path), ct: ct);
}
private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString(); private string Absolute(string relativePath) => new Uri(ResolveBusinessBaseAddress(), relativePath).ToString();
private Uri ResolveBusinessBaseAddress() private Uri ResolveBusinessBaseAddress()

View file

@ -1,112 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading;
using System.Threading.Tasks;
namespace Yavsc.Api.Client;
/// <summary>
/// Client HTTP de l'espace de stockage personnel
/// (<c>api/v1/fs</c>, servi par le host <c>Yavsc.Blogs</c>). Même forme
/// que <see cref="BlogApiClient"/> : le transport (URL de base, JSON,
/// Bearer, refresh silencieux sur 401) est délégué à
/// <see cref="IYavscApiClient"/> ; cette classe n'est qu'un mappeur
/// DTO↔chemin.
///
/// <para><b>Convention d'URL.</b> Le <c>pathPrefix</c> est relatif au
/// segment de version déjà porté par l'URL de base
/// (<c>…/api/v1/</c>) : <c>"fs"</c> résout en <c>…/api/v1/fs</c>, qui
/// correspond au <c>[Route(APIPrefix + "/fs")]</c> de
/// <c>FileSystemApiController</c>. Ne pas ré-inclure <c>api/</c>.</para>
/// </summary>
public sealed class UserFilesApiClient
{
private const string PathPrefix = "fs";
private readonly IYavscApiClient _api;
private readonly Uri _baseAddress;
public UserFilesApiClient(IYavscApiClient api, string blogsBaseAddress)
{
_api = api ?? throw new ArgumentNullException(nameof(api));
if (string.IsNullOrEmpty(blogsBaseAddress))
throw new ArgumentException("Base address is required.", nameof(blogsBaseAddress));
_baseAddress = new Uri(blogsBaseAddress);
_api.Http.BaseAddress = _baseAddress;
}
/// <summary>
/// Liste le contenu du sous-répertoire <paramref name="subdir"/> de
/// l'espace perso de l'utilisateur (<c>GET fs/{subdir}</c>).
/// </summary>
public Task<UserDirectoryDto> ListAsync(string? subdir = null, CancellationToken ct = default)
{
var path = string.IsNullOrWhiteSpace(subdir) ? PathPrefix : $"{PathPrefix}/{subdir.TrimStart('/')}";
return _api.CallAsync<UserDirectoryDto>(HttpMethod.Get, path, ct: ct);
}
/// <summary>
/// Téléverse des fichiers dans le sous-répertoire
/// <paramref name="subdir"/> (<c>POST fs/{subdir}</c> multipart).
/// Renvoie les métadonnées des fichiers reçus, dont l'identifiant
/// <see cref="FileReceivedDto.FileId"/> servant à l'attachement.
/// </summary>
public Task<List<FileReceivedDto>> UploadAsync(string subdir, IReadOnlyCollection<UploadFile> files, CancellationToken ct = default)
{
if (files is null || files.Count == 0)
throw new ArgumentException("At least one file is required.", nameof(files));
var path = string.IsNullOrWhiteSpace(subdir)
? PathPrefix
: $"{PathPrefix}/{subdir.TrimStart('/')}";
return _api.CallAsync<List<FileReceivedDto>>(HttpMethod.Post, path, () => CreateMultipartContent(files), ct);
}
/// <summary>
/// Supprime un fichier ou répertoire de l'espace perso
/// (<c>DELETE fs/{path}</c>).
/// </summary>
public Task DeleteAsync(string path, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(path))
throw new ArgumentException("Path is required.", nameof(path));
return _api.CallAsync(HttpMethod.Delete, $"{PathPrefix}/{path.TrimStart('/')}", ct: ct);
}
/// <summary>
/// Télécharge une pièce jointe référencée par son identifiant
/// <see cref="FileReceivedDto.FileId"/> / <see cref="AttachmentDto.FileId"/>
/// (<c>GET fs/file/{fileId}</c>). Renvoie les octets bruts ; le serveur
/// (host Blogs) autorise l'appelant (propriétaire ou partie au
/// devis/demande auquel le fichier est rattaché).
/// </summary>
public Task<byte[]> DownloadFileAsync(long fileId, CancellationToken ct = default)
{
if (fileId <= 0) throw new ArgumentOutOfRangeException(nameof(fileId));
return _api.DownloadAsync(HttpMethod.Get, $"{PathPrefix}/file/{fileId}", ct);
}
/// <summary>
/// Construit le corps <c>multipart/form-data</c> d'un téléversement :
/// une part <c>file</c> par fichier (type MIME retombant sur
/// <c>application/octet-stream</c>). Exposé <c>public</c> pour les
/// tests backend qui assertent la forme filaire.
/// </summary>
public static HttpContent CreateMultipartContent(IReadOnlyCollection<UploadFile> files)
{
var content = new MultipartFormDataContent();
foreach (var file in files)
{
var fileContent = new ByteArrayContent(file.Content);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(
string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType);
content.Add(fileContent, "file", file.FileName);
}
return content;
}
}

View file

@ -17,7 +17,7 @@
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,303 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
/// <summary>
/// Integration tests for the estimate attachment endpoints
/// (<c>GET/POST/DELETE api/v1/estimate/{id}/attachments</c>), which
/// attach a file from the provider's personal storage to an estimate
/// **by reference** (<see cref="EstimateAttachedFile"/>).
///
/// <para>Coverage mirrors the authorization contract documented on the
/// controller: only the provider (<c>estimate.OwnerId</c>) may attach or
/// detach, the file must belong to the caller, and listing is open to
/// both parties (provider/client) plus admin. A third party is refused.
/// The download of the bytes themselves is served by the Blogs host and
/// exercised in <c>Yavsc.Blogs.Tests</c>; these tests cover only the
/// metadata/relationship endpoints that live on the API host.</para>
/// </summary>
[Collection("Yavsc Api")]
public sealed class EstimateAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public EstimateAttachmentsApiTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
/// <summary>
/// Reset the graph (seeding alice the provider and bob the client),
/// create an estimate owned by alice for bob, and seed two personal
/// <see cref="UploadedFile"/> rows: one owned by alice (the provider's
/// own file) and one owned by bob (a file that does NOT belong to the
/// provider, used to assert the ownership guard on attach).
/// </summary>
private (long estimateId, long aliceFileId, long bobFileId) Seed()
{
_fixture.ResetAndSeedActivityGraph();
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var estimate = new Estimate
{
ClientId = "bob",
OwnerId = "alice",
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();
var aliceFile = new UploadedFile
{
OwnerId = "alice",
Path = "provider-doc.pdf",
ContentType = "application/pdf",
Length = 2048,
};
var bobFile = new UploadedFile
{
OwnerId = "bob",
Path = "client-note.txt",
ContentType = "text/plain",
Length = 64,
};
db.UploadedFiles.AddRange(aliceFile, bobFile);
db.SaveChanges();
return (estimate.Id, aliceFile.Id, bobFile.Id);
}
private int CountAttachments(long estimateId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.EstimateAttachedFiles.Count(a => a.EstimateId == estimateId);
}
[Fact]
public async Task Provider_attaching_own_file_creates_the_link()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
Assert.Equal(1, CountAttachments(estimateId));
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var link = Assert.Single(db.EstimateAttachedFiles.Where(a => a.EstimateId == estimateId));
Assert.Equal(aliceFileId, link.FileId);
}
[Fact]
public async Task Attaching_the_same_file_twice_is_idempotent()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
var second = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, second.StatusCode);
Assert.Equal(1, CountAttachments(estimateId));
}
[Fact]
public async Task Non_provider_cannot_attach_to_an_estimate()
{
var (estimateId, aliceFileId, _) = Seed();
// bob is the client of the estimate, not the provider.
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(estimateId));
}
[Fact]
public async Task Provider_cannot_attach_a_file_they_do_not_own()
{
var (estimateId, _, bobFileId) = Seed();
// alice is the provider, but the file belongs to bob.
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(estimateId));
}
[Fact]
public async Task Provider_detaches_a_file()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(1, CountAttachments(estimateId));
var response = await http.DeleteAsync(
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(0, CountAttachments(estimateId));
}
[Fact]
public async Task Non_provider_cannot_detach()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
using var bob = NewClient("bob");
var response = await bob.DeleteAsync(
$"/api/v1/estimate/{estimateId}/attachments/{aliceFileId}",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(1, CountAttachments(estimateId));
}
[Fact]
public async Task Provider_lists_attachments()
{
var (estimateId, aliceFileId, _) = Seed();
using var http = NewClient("alice");
await http.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
var response = await http.GetAsync(
$"/api/v1/estimate/{estimateId}/attachments",
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var item = Assert.Single(doc.RootElement.EnumerateArray());
Assert.Equal(aliceFileId, item.GetProperty("fileId").GetInt64());
Assert.Equal("provider-doc.pdf", item.GetProperty("path").GetString());
Assert.Equal("alice", item.GetProperty("ownerId").GetString());
}
[Fact]
public async Task Client_can_list_attachments_as_a_party()
{
var (estimateId, aliceFileId, _) = Seed();
using var alice = NewClient("alice");
await alice.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
// bob is the client of the estimate: a party, may list.
using var bob = NewClient("bob");
var response = await bob.GetAsync(
$"/api/v1/estimate/{estimateId}/attachments",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task Third_party_cannot_list_attachments()
{
var (estimateId, aliceFileId, _) = Seed();
using var alice = NewClient("alice");
await alice.PostAsJsonAsync(
$"/api/v1/estimate/{estimateId}/attachments",
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
// carol is neither the provider nor the client.
using var carol = NewClient("carol");
var response = await carol.GetAsync(
$"/api/v1/estimate/{estimateId}/attachments",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
[Fact]
public async Task Listing_an_unknown_estimate_returns_404()
{
_fixture.ResetAndSeedActivityGraph();
using var http = NewClient("alice");
var response = await http.GetAsync(
"/api/v1/estimate/999999/attachments",
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -24,6 +24,7 @@ public sealed class ApiWebServerFixture : WebHostFixture
private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER"; private const string DbProviderEnvVar = "YAVSC_API_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION"; private const string NpgsqlAdminConnectionEnvVar = "YAVSC_API_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscTestDb"; private const string DedicatedNpgsqlDatabaseName = "yavscTestDb";
private const string DefaultDevelopmentConnectionString = "Server=localhost;Port=5432;Database=yavscdev;Username=yavscdev;Password=8*5idas;Include Error Detail=true";
protected override int HttpsPort => 5104; protected override int HttpsPort => 5104;
@ -167,32 +168,25 @@ public sealed class ApiWebServerFixture : WebHostFixture
private static string BuildAdminConnectionString() private static string BuildAdminConnectionString()
{ {
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar); var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
if (string.IsNullOrWhiteSpace(configured)) var source = string.IsNullOrWhiteSpace(configured)
throw new InvalidOperationException( ? DefaultDevelopmentConnectionString
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test " : configured;
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(configured) var builder = new NpgsqlConnectionStringBuilder(source)
{ {
Pooling = false, Pooling = false,
IncludeErrorDetail = true IncludeErrorDetail = true
}; };
// The admin connection targets the maintenance database if (string.IsNullOrWhiteSpace(configured))
// (postgres) so we can CREATE/DROP the dedicated test database; {
// whatever database the env var names is overridden here.
if (string.IsNullOrWhiteSpace(builder.Database))
builder.Database = "postgres"; builder.Database = "postgres";
}
else if (string.IsNullOrWhiteSpace(builder.Database))
{
builder.Database = "postgres";
}
return builder.ToString(); return builder.ToString();
} }

View file

@ -1,256 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Api.Test.Fixtures;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Api.Test;
/// <summary>
/// Integration tests for the query (demande) attachment endpoints
/// (<c>GET/POST/DELETE api/v1/front/query/{queryId}/attachments</c>),
/// which attach a file from the client's personal storage to their
/// <c>NominativeServiceCommand</c> **by reference**
/// (<see cref="QueryAttachedFile"/>).
///
/// <para>Coverage mirrors the controller contract: only the client
/// (the demande's <c>ClientId</c>) may attach or detach, the file must
/// belong to the caller, and listing is open to both parties
/// (client/provider) plus admin. A third party is refused.</para>
/// </summary>
[Collection("Yavsc Api")]
public sealed class FrontOfficeQueryAttachmentsApiTests : IClassFixture<ApiWebServerFixture>
{
private readonly ApiWebServerFixture _fixture;
public FrontOfficeQueryAttachmentsApiTests(ApiWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "bob", string scope = "api")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.BaseAddress)
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject, scope));
return http;
}
/// <summary>
/// Seed an Rdv query from bob (client) to alice (provider) and two
/// personal <see cref="UploadedFile"/> rows: one owned by bob (the
/// client's own file) and one owned by alice (a file that does NOT
/// belong to the client, used to assert the ownership guard).
/// </summary>
private (long queryId, long bobFileId, long aliceFileId) Seed()
{
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 = "bob",
PerformerId = "alice",
Consent = true,
UserCreated = "bob",
UserModified = "bob",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
EventDate = DateTime.UtcNow.AddDays(1),
Location = location,
Reason = "Demande de devis",
Status = QueryStatus.InProgress,
Description = "Demande en attente",
};
db.RdvQueries.Add(query);
db.SaveChanges();
var bobFile = new UploadedFile
{
OwnerId = "bob",
Path = "demande-photo.jpg",
ContentType = "image/jpeg",
Length = 4096,
};
var aliceFile = new UploadedFile
{
OwnerId = "alice",
Path = "provider-spec.pdf",
ContentType = "application/pdf",
Length = 1024,
};
db.UploadedFiles.AddRange(bobFile, aliceFile);
db.SaveChanges();
return (query.Id, bobFile.Id, aliceFile.Id);
}
private int CountAttachments(long queryId)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
return db.QueryAttachedFiles.Count(a => a.CommandId == queryId);
}
private string AttachUrl(long queryId) =>
$"/api/v1/front/query/{queryId}/attachments?billingCode=Rdv";
private string DetachUrl(long queryId, long fileId) =>
$"/api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=Rdv";
[Fact]
public async Task Client_attaching_own_file_creates_the_link()
{
var (queryId, bobFileId, _) = Seed();
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
Assert.Equal(1, CountAttachments(queryId));
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var link = Assert.Single(db.QueryAttachedFiles.Where(a => a.CommandId == queryId));
Assert.Equal(bobFileId, link.FileId);
}
[Fact]
public async Task Provider_cannot_attach_to_a_query()
{
var (queryId, bobFileId, _) = Seed();
// alice is the provider of the query; only the client attaches.
using var http = NewClient("alice");
var response = await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(queryId));
}
[Fact]
public async Task Client_cannot_attach_a_file_they_do_not_own()
{
var (queryId, _, aliceFileId) = Seed();
// bob is the client, but the file belongs to alice.
using var http = NewClient("bob");
var response = await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = aliceFileId },
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(0, CountAttachments(queryId));
}
[Fact]
public async Task Client_detaches_a_file()
{
var (queryId, bobFileId, _) = Seed();
using var http = NewClient("bob");
await http.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
Assert.Equal(1, CountAttachments(queryId));
var response = await http.DeleteAsync(
DetachUrl(queryId, bobFileId),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
Assert.Equal(0, CountAttachments(queryId));
}
[Fact]
public async Task Provider_cannot_detach()
{
var (queryId, bobFileId, _) = Seed();
using var bob = NewClient("bob");
await bob.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
using var alice = NewClient("alice");
var response = await alice.DeleteAsync(
DetachUrl(queryId, bobFileId),
TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
Assert.Equal(1, CountAttachments(queryId));
}
[Fact]
public async Task Provider_lists_attachments_as_a_party()
{
var (queryId, bobFileId, _) = Seed();
using var bob = NewClient("bob");
await bob.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
// alice is the provider: a party, may list.
using var alice = NewClient("alice");
var response = await alice.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var item = Assert.Single(doc.RootElement.EnumerateArray());
Assert.Equal(bobFileId, item.GetProperty("fileId").GetInt64());
Assert.Equal("bob", item.GetProperty("ownerId").GetString());
}
[Fact]
public async Task Third_party_cannot_list_attachments()
{
var (queryId, bobFileId, _) = Seed();
using var bob = NewClient("bob");
await bob.PostAsJsonAsync(
AttachUrl(queryId),
new { fileId = bobFileId },
TestContext.Current.CancellationToken);
using var carol = NewClient("carol");
var response = await carol.GetAsync(AttachUrl(queryId), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -5,7 +5,6 @@ using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json; using Newtonsoft.Json;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Billing; using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
namespace Yavsc.Controllers namespace Yavsc.Controllers
@ -238,96 +237,6 @@ namespace Yavsc.Controllers
base.Dispose(disposing); base.Dispose(disposing);
} }
// GET: api/v1/estimate/{id}/attachments
//
// Liste les fichiers de l'espace perso du fournisseur rattachés au
// devis, par référence (UploadedFile). Lecture ouverte aux deux
// parties (fournisseur/client) du devis et aux admin, comme
// GetEstimate : la contrepartie doit pouvoir voir et télécharger
// les pièces (le téléchargement des octets reste servi par le host
// Blogs, qui autorise via cette même table de jointure).
[HttpGet("{id}/attachments")]
public IActionResult GetAttachments(long id)
{
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
if (estimate == null) return NotFound();
if (!UserIsAdminOrInThese(estimate.ClientId, estimate.OwnerId))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var attached = _context.EstimateAttachedFiles
.Where(a => a.EstimateId == id)
.Select(a => new
{
fileId = a.FileId,
path = a.File.Path,
size = a.File.Length,
contentType = a.File.ContentType,
ownerId = a.File.OwnerId
})
.ToList();
return Ok(attached);
}
// POST: api/v1/estimate/{id}/attachments { fileId }
//
// Attache un fichier de l'espace perso du fournisseur au devis, par
// référence. Réservé au fournisseur (estimate.OwnerId == caller) :
// seul le fournisseur constitue le devis et y joint ses documents
// (dont les documents à faire signer par le client, per README).
// Le fichier doit appartenir au fournisseur (UploadedFile.OwnerId).
[HttpPost("{id}/attachments")]
public IActionResult AttachFile(long id, [FromBody] AttachFileRequest body)
{
if (!ModelState.IsValid) return BadRequest(ModelState);
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
if (estimate == null) return NotFound();
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
if (body == null || body.FileId <= 0)
return BadRequest(new { error = "fileId required" });
var file = _context.UploadedFiles.FirstOrDefault(f => f.Id == body.FileId);
if (file == null) return BadRequest(new { error = "file not found" });
if (file.OwnerId != uid)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var existing = _context.EstimateAttachedFiles
.FirstOrDefault(a => a.EstimateId == id && a.FileId == body.FileId);
if (existing != null) return Ok(new { estimateId = id, fileId = body.FileId });
_context.EstimateAttachedFiles.Add(new EstimateAttachedFile
{
EstimateId = id,
FileId = body.FileId
});
_context.SaveChanges(User.GetUserId());
return Ok(new { estimateId = id, fileId = body.FileId });
}
// DELETE: api/v1/estimate/{id}/attachments/{fileId}
//
// Détache un fichier du devis. Réservé au fournisseur.
[HttpDelete("{id}/attachments/{fileId}")]
public IActionResult DetachFile(long id, long fileId)
{
var estimate = _context.Estimates.FirstOrDefault(e => e.Id == id);
if (estimate == null) return NotFound();
var uid = User.GetUserId();
if (!User.IsInRole(Constants.AdminGroupName) && uid != estimate.OwnerId)
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var link = _context.EstimateAttachedFiles
.FirstOrDefault(a => a.EstimateId == id && a.FileId == fileId);
if (link == null) return NotFound();
_context.EstimateAttachedFiles.Remove(link);
_context.SaveChanges(User.GetUserId());
return Ok(new { estimateId = id, fileId });
}
private bool EstimateExists(long id) private bool EstimateExists(long id)
{ {
return _context.Estimates.Count(e => e.Id == id) > 0; return _context.Estimates.Count(e => e.Id == id) > 0;

View file

@ -8,7 +8,6 @@ using Yavsc.Abstract.Workflow;
using Yavsc.Helpers; using Yavsc.Helpers;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Billing; using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Services; using Yavsc.Services;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice; using Yavsc.ViewModels.FrontOffice;
@ -367,95 +366,6 @@ namespace Yavsc.ApiControllers
private static bool HasSignature(QueryAcceptanceRequest? body) private static bool HasSignature(QueryAcceptanceRequest? body)
=> body is not null && body.Strokes is not null && body.Strokes.Length > 0; => body is not null && body.Strokes is not null && body.Strokes.Length > 0;
// GET: api/v1/front/query/{queryId}/attachments?billingCode=…
//
// Liste les fichiers de l'espace perso du client rattachés à la
// demande, par référence (UploadedFile). Lecture ouverte aux deux
// parties (client/fournisseur) de la demande et aux admin ; le
// téléchargement des octets est servi par le host Blogs.
[HttpGet("query/{queryId}/attachments")]
public async Task<IActionResult> GetQueryAttachments(
string billingCode, long queryId, CancellationToken token)
{
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" });
var attached = await dbContext.QueryAttachedFiles
.Where(a => a.CommandId == queryId)
.Select(a => new
{
fileId = a.FileId,
path = a.File.Path,
size = a.File.Length,
contentType = a.File.ContentType,
ownerId = a.File.OwnerId
})
.ToListAsync(token);
return Ok(attached);
}
// POST: api/v1/front/query/{queryId}/attachments?billingCode=… { fileId }
//
// Attache un fichier de l'espace perso du client à sa demande, par
// référence. Réservé au client (rôle Client) : c'est lui qui
// documente sa demande. Le fichier doit lui appartenir.
[HttpPost("query/{queryId}/attachments")]
public async Task<IActionResult> AttachQueryFile(
string billingCode, long queryId, [FromBody] AttachFileRequest? body, CancellationToken token)
{
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" });
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
if (body == null || body.FileId <= 0)
return BadRequest(new { error = "fileId required" });
var uid = User.GetUserId();
var file = await dbContext.UploadedFiles
.FirstOrDefaultAsync(f => f.Id == body.FileId, token);
if (file == null) return BadRequest(new { error = "file not found" });
if (file.OwnerId != uid && !User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var existing = await dbContext.QueryAttachedFiles
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == body.FileId, token);
if (existing != null) return Ok(new { queryId, fileId = body.FileId });
dbContext.QueryAttachedFiles.Add(new QueryAttachedFile
{
CommandId = queryId,
FileId = body.FileId
});
await dbContext.SaveChangesAsync(User.GetUserId(), token);
return Ok(new { queryId, fileId = body.FileId });
}
// DELETE: api/v1/front/query/{queryId}/attachments/{fileId}?billingCode=…
//
// Détache un fichier de la demande. Réservé au client.
[HttpDelete("query/{queryId}/attachments/{fileId}")]
public async Task<IActionResult> DetachQueryFile(
string billingCode, long queryId, long fileId, CancellationToken token)
{
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" });
if (role != ActorRole.Client && !User.IsInRole(Constants.AdminGroupName))
return new StatusCodeResult(StatusCodes.Status403Forbidden);
var link = await dbContext.QueryAttachedFiles
.FirstOrDefaultAsync(a => a.CommandId == queryId && a.FileId == fileId, token);
if (link == null) return NotFound();
dbContext.QueryAttachedFiles.Remove(link);
await dbContext.SaveChangesAsync(User.GetUserId(), token);
return Ok(new { queryId, fileId });
}
} }
internal enum ActorRole { None, Provider, Client, Admin } internal enum ActorRole { None, Provider, Client, Admin }

View file

@ -7,7 +7,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,294 +0,0 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Yavsc.Blogs.Tests.Fixtures;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Models.Workflow;
using Yavsc.Tests.Shared;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Integration tests for the personal-storage file endpoints
/// (<c>api/v1/fs</c>) served by <c>FileSystemApiController</c> on the
/// Blogs host.
///
/// <para>Two concerns:</para>
/// <list type="number">
/// <item><description><b>Upload records metadata</b>: a multipart
/// <c>POST api/v1/fs</c> persists an <see cref="UploadedFile"/> row
/// owned by the caller and returns its <c>fileId</c>, so the client
/// can later attach that file to an estimate/query by
/// reference.</description></item>
/// <item><description><b>Download authorization</b>:
/// <c>GET api/v1/fs/file/{fileId}</c> serves the bytes from the
/// owner's personal space. The owner may always read; a party
/// (client or provider) of an estimate/query the file is attached
/// to may read; a third party is refused. The bytes live on the
/// Blogs host's disk — this is the cross-container reason the
/// download endpoint lives here and not on the API host.</description></item>
/// </list>
/// </summary>
[Collection("Yavsc Blogs")]
public sealed class FileSystemApiTests : IClassFixture<BlogsWebServerFixture>
{
private readonly BlogsWebServerFixture _fixture;
public FileSystemApiTests(BlogsWebServerFixture fixture)
{
_fixture = fixture;
}
private HttpClient NewClient(string subject = "alice")
{
var handler = new HttpClientHandler
{
ServerCertificateCustomValidationCallback = (_, _, _, _) => true
};
var http = new HttpClient(handler)
{
BaseAddress = new Uri(_fixture.Addresses.First(a => a.StartsWith("https://")))
};
http.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", TestTokenIssuer.Issue(subject));
return http;
}
private static string PhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName) =>
Path.Combine(fixture.BlogFilesRoot, owner, fileName);
/// <summary>Write a physical file into the owner's personal space,
/// so <c>GetFile</c> can stream it back. Creates the directory if
/// needed.</summary>
private static void WritePhysicalFile(BlogsWebServerFixture fixture, string owner, string fileName, byte[] content)
{
var path = PhysicalFile(fixture, owner, fileName);
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllBytes(path, content);
}
private long SeedUploadedFile(string ownerId, string path, string contentType, long length)
{
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var file = new UploadedFile
{
OwnerId = ownerId,
Path = path,
ContentType = contentType,
Length = length,
};
db.UploadedFiles.Add(file);
db.SaveChanges();
return file.Id;
}
[Fact]
public async Task Post_fs_creates_an_UploadedFile_row_owned_by_the_caller()
{
_fixture.ResetDatabase();
// DiskQuota must be non-zero: ReceiveUserFile flags QuotaOffense
// once usage >= DiskQuota, and the controller skips the metadata
// row when QuotaOffense is set. A fresh user has quota 0.
_fixture.SeedUser("alice", u => u.DiskQuota = 10_000_000);
var payload = System.Text.Encoding.UTF8.GetBytes("hello personal storage");
using var form = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(payload);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain");
form.Add(fileContent, "file", "note.txt");
using var http = NewClient("alice");
var response = await http.PostAsync("/api/v1/fs", form, TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
using var doc = JsonDocument.Parse(body);
var item = Assert.Single(doc.RootElement.EnumerateArray());
var fileId = item.GetProperty("fileId").GetInt64();
Assert.True(fileId > 0, $"Expected a positive fileId, got {fileId}. Body: {body}");
Assert.Equal("note.txt", item.GetProperty("fileName").GetString());
Assert.False(item.GetProperty("quotaOffense").GetBoolean());
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var row = Assert.Single(db.UploadedFiles.Where(u => u.OwnerId == "alice"));
Assert.Equal("note.txt", row.Path);
Assert.Equal("text/plain", row.ContentType);
Assert.Equal(payload.Length, row.Length);
// The file physically landed under {BlogFilesRoot}/alice/note.txt.
Assert.True(File.Exists(PhysicalFile(_fixture, "alice", "note.txt")),
"Expected the uploaded file on disk under the owner's personal root");
}
[Fact]
public async Task GetFile_owner_can_download_their_own_file()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var bytes = System.Text.Encoding.UTF8.GetBytes("owner payload");
WritePhysicalFile(_fixture, "alice", "owner-note.txt", bytes);
var fileId = SeedUploadedFile("alice", "owner-note.txt", "text/plain", bytes.Length);
using var http = NewClient("alice");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
Assert.Equal(bytes, downloaded);
}
[Fact]
public async Task GetFile_refuses_a_third_party()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("carol");
var bytes = System.Text.Encoding.UTF8.GetBytes("private to alice");
WritePhysicalFile(_fixture, "alice", "secret.txt", bytes);
var fileId = SeedUploadedFile("alice", "secret.txt", "text/plain", bytes.Length);
// carol is neither the owner nor a party to any estimate/query
// this file is attached to.
using var http = NewClient("carol");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
/// <summary>
/// Seed the graph needed to prove a party (the client of an
/// estimate the file is attached to) can download a file they do
/// not own: alice owns the file and is the provider; bob is the
/// client; the file is attached to the estimate by reference.
/// </summary>
private long SeedPartyDownloadGraph(out long estimateId, out string fileName)
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
fileName = "party-spec.pdf";
var bytes = System.Text.Encoding.UTF8.GetBytes("party payload");
using var scope = _fixture.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
// Location + PerformerProfile(alice): Estimate.OwnerId is a FK
// to PerformerProfile.PerformerId, so the provider profile must
// exist before the estimate can be inserted.
var location = new Location { Address = "1 rue du Test", Latitude = 48.8, Longitude = 2.3 };
db.Locations.Add(location);
db.SaveChanges();
db.Performers.Add(new PerformerProfile
{
PerformerId = "alice",
SIREN = "12345678901234",
OrganizationAddressId = location.Id,
ExerciseCountryCode = "fr",
AcceptNotifications = true,
AcceptPublicContact = true,
Active = true,
Rate = 5,
WebSite = "https://alice.dev",
});
db.SaveChanges();
var estimate = new Estimate
{
OwnerId = "alice",
ClientId = "bob",
CommandType = "Rdv",
Title = "Devis party test",
Description = "Devis",
AttachedFiles = new List<string>(),
AttachedGraphics = new List<string>(),
Bill = new List<CommandLine>
{
new() { Name = "Prestation", Description = "Prestation", Count = 1, UnitaryCost = 10m, Currency = "EUR" },
},
};
db.Estimates.Add(estimate);
db.SaveChanges();
estimateId = estimate.Id;
var file = new UploadedFile
{
OwnerId = "alice",
Path = fileName,
ContentType = "application/pdf",
Length = bytes.Length,
};
db.UploadedFiles.Add(file);
db.SaveChanges();
db.EstimateAttachedFiles.Add(new EstimateAttachedFile
{
FileId = file.Id,
EstimateId = estimate.Id,
});
db.SaveChanges();
// The bytes must exist on disk for GetFile to stream them.
WritePhysicalFile(_fixture, "alice", fileName, bytes);
return file.Id;
}
[Fact]
public async Task GetFile_party_client_of_an_attached_estimate_can_download()
{
var fileId = SeedPartyDownloadGraph(out _, out _);
// bob is the client of the estimate the file is attached to:
// a party, authorized to read even though alice owns the file.
using var http = NewClient("bob");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
var body = await response.Content.ReadAsStringAsync(TestContext.Current.CancellationToken);
Assert.True(response.StatusCode == HttpStatusCode.OK,
$"Expected 200, got {(int)response.StatusCode} ({response.StatusCode}): {body}");
var downloaded = await response.Content.ReadAsByteArrayAsync(TestContext.Current.CancellationToken);
Assert.Equal(System.Text.Encoding.UTF8.GetBytes("party payload"), downloaded);
}
[Fact]
public async Task GetFile_party_provider_of_an_attached_estimate_can_download()
{
var fileId = SeedPartyDownloadGraph(out _, out _);
// alice is the owner too, so this also exercises the owner path;
// the provider of an attached estimate is authorized regardless.
using var http = NewClient("alice");
var response = await http.GetAsync(
$"/api/v1/fs/file/{fileId}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetFile_unknown_id_returns_404()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
using var http = NewClient("alice");
var response = await http.GetAsync(
"/api/v1/fs/file/999999", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
}

View file

@ -1,87 +0,0 @@
using System.Text.Json;
using Yavsc.Blogspot;
using Yavsc.Models.Blog;
namespace Yavsc.Blogs.Tests;
/// <summary>
/// Wire-shape tests pinning that <see cref="IBlogPost.IsPublished"/>
/// is carried on the JSON the blog API list endpoint emits.
///
/// <para>The blog API host (<c>Yavsc.Blogs/Program.cs</c>) serialises
/// with System.Text.Json (the <c>.AddControllers()</c> default — no
/// <c>AddNewtonsoftJson</c>), and <c>BlogApiController.GetBlogspot</c>
/// returns <c>IEnumerable&lt;IBlogPost&gt;</c>. System.Text.Json emits
/// only the members of the <b>declared</b> type, so a property that
/// exists solely on the concrete <c>BlogPost</c> entity is dropped from
/// the payload. <c>IsPublished</c> used to be one such property: it was
/// on <c>BlogPost</c> and <c>BlogPostDto</c> but not on
/// <see cref="IBlogPost"/>, so the list response omitted it and PostIt
/// read <c>IsPublished == false</c> for every post — including
/// published ones.</para>
///
/// <para>These tests reproduce that serialisation path directly
/// (declared type <c>IEnumerable&lt;IBlogPost&gt;</c> through
/// <see cref="JsonSerializer.Serialize(object, Type)"/>) and assert
/// <c>isPublished</c> survives the round trip. They live in
/// <c>Yavsc.Blogs.Tests</c> — the assembly that owns the blog API
/// contract — so a regression at the wire boundary is caught here,
/// where it manifested in production (PostIt showing a published
/// post's status as <c>false</c>).</para>
/// </summary>
public sealed class IBlogPostWireShapeTests
{
private static readonly JsonSerializerOptions WebDefaults
= new(JsonSerializerDefaults.Web);
[Fact]
public void IsPublished_is_emitted_when_list_is_serialised_as_IEnumerable_IBlogPost()
{
var post = new BlogPost
{
Id = 13,
Title = "Published",
AuthorId = "alice",
Article = "x",
};
post.IsPublished = true;
// Declared type mirrors GetBlogspot's return: IEnumerable<IBlogPost>.
IEnumerable<IBlogPost> list = new List<BlogPost> { post };
// Serialize with the DECLARED type, the way ASP.NET Core's
// SystemTextJsonOutputFormatter hands context.ObjectType (the
// action return type) to JsonSerializer.Serialize.
var json = JsonSerializer.Serialize(list, typeof(IEnumerable<IBlogPost>),
WebDefaults);
using var doc = JsonDocument.Parse(json);
var element = doc.RootElement[0];
// Web defaults use camelCase, so the property is "isPublished".
Assert.True(element.TryGetProperty("isPublished", out var isPublished),
$"isPublished was dropped from the list payload. JSON: {json}");
Assert.True(isPublished.GetBoolean());
}
[Fact]
public void IsPublished_round_trips_through_BlogPostDto_on_the_client()
{
// The shape the client (PostIt) receives and deserialises with
// PropertyNameCaseInsensitive = true (cf. YavscApiClient).
var json = """
[{
"id": 13,
"title": "Published",
"article": "x",
"authorId": "alice",
"isPublished": true
}]
""";
var posts = JsonSerializer.Deserialize<List<BlogPostDto>>(json,
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!;
var post = Assert.Single(posts);
Assert.True(post.IsPublished);
}
}

View file

@ -9,7 +9,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -8,14 +8,10 @@ namespace Yavsc.Blogs.Controllers
{ {
using Yavsc.Attributes.Validation; using Yavsc.Attributes.Validation;
using Yavsc.Models; using Yavsc.Models;
using Yavsc.Models.Blog;
using Yavsc.Models.Billing;
using Yavsc.Exceptions; using Yavsc.Exceptions;
using Yavsc.Server.Helpers; using Yavsc.Server.Helpers;
using Yavsc.Abstract.Helpers; using Yavsc.Abstract.Helpers;
using Yavsc.Server.Models.FileSystem; using Yavsc.Server.Models.FileSystem;
using Yavsc.ViewModels.UserFiles;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
[Authorize,Route(APIPrefix + "/fs")] [Authorize,Route(APIPrefix + "/fs")]
@ -38,55 +34,21 @@ namespace Yavsc.Blogs.Controllers
this.siteSettings = siteSettings.Value; this.siteSettings = siteSettings.Value;
} }
// GET: api/v1/fs
[HttpGet()] [HttpGet()]
public IActionResult Get() public IActionResult Get()
{ {
return GetDir(null); return GetDir(null);
} }
// GET: api/v1/fs/{subdir}
// Liste les fichiers du sous-répertoire personnel de l'utilisateur,
// en enrichissant chaque entrée de l'identifiant de métadonnées
// UploadedFile.Id quand une ligne existe (cf. POST ci-dessous).
[HttpGet("{*subdir}")] [HttpGet("{*subdir}")]
public IActionResult GetDir([ValidRemoteUserFilePath] string subdir="") public IActionResult GetDir([ValidRemoteUserFilePath] string subdir="")
{ {
if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState); if (!ModelState.IsValid) return new BadRequestObjectResult(ModelState);
var uid = User.GetUserId(); // _logger.LogInformation($"listing files from {User.Identity.Name}{subdir}");
var files = FileSystemHelpers.GetUserFiles(siteSettings, uid, subdir); var files = FileSystemHelpers.GetUserFiles(siteSettings, User.GetUserId(), subdir);
EnrichWithFileIds(files, uid, subdir);
return Ok(files); return Ok(files);
} }
// Reporte l'UploadedFile.Id (quand il existe) sur chaque entrée
// retournée par la liste, pour que le client lourd puisse attacher
// le fichier à un devis/demande par référence.
void EnrichWithFileIds(UserDirectoryInfo dir, string ownerId, string subdir)
{
if (dir?.Files == null || dir.Files.Length == 0) return;
var relPaths = dir.Files
.Select(f => RelPath(subdir, f.Name))
.ToHashSet();
var byPath = dbContext.UploadedFiles
.Where(u => u.OwnerId == ownerId && relPaths.Contains(u.Path))
.ToDictionary(u => u.Path, u => u.Id);
foreach (var f in dir.Files)
{
var rel = RelPath(subdir, f.Name);
if (byPath.TryGetValue(rel, out var id)) f.Id = id;
}
}
// Chemin relatif au racine de l'espace perso de l'utilisateur,
// tel que stocké dans UploadedFile.Path (sans segment utilisateur).
static string RelPath(string subdir, string fileName)
{
// Normalize with '/' for cross-platform consistency in DB.
if (string.IsNullOrWhiteSpace(subdir)) return fileName;
return $"{subdir.TrimStart('/')}/{fileName}";
}
[HttpPost("{*subdir}")] [HttpPost("{*subdir}")]
public IActionResult Post([ValidRemoteUserFilePath] string subdir="") public IActionResult Post([ValidRemoteUserFilePath] string subdir="")
{ {
@ -116,38 +78,7 @@ namespace Yavsc.Blogs.Controllers
foreach (var f in Request.Form.Files) foreach (var f in Request.Form.Files)
{ {
var item = user.ReceiveUserFile(destDir, f); var item = user.ReceiveUserFile(destDir, f);
if (!item.QuotaOffense) dbContext.SaveChanges(User.GetUserId());
{
// Persiste une ligne de métadonnées UploadedFile,
// propriétaire = l'utilisateur courant, chemin relatif
// à son espace perso. Upsert sur (OwnerId, Path) pour
// le re-téléversement (même fichier écrasé sur disque).
var rel = RelPath(subdir, item.FileName);
var existing = dbContext.UploadedFiles
.FirstOrDefault(u => u.OwnerId == uid && u.Path == rel);
if (existing == null)
{
var uploaded = new UploadedFile
{
OwnerId = uid,
Path = rel,
ContentType = f.ContentType,
Length = f.Length
};
dbContext.UploadedFiles.Add(uploaded);
dbContext.SaveChanges(User.GetUserId());
item.FileId = uploaded.Id;
}
else
{
existing.ContentType = f.ContentType;
existing.Length = f.Length;
dbContext.SaveChanges(User.GetUserId());
item.FileId = existing.Id;
}
item.Length = f.Length;
item.ContentType = f.ContentType;
}
received.Add(item); received.Add(item);
_logger.LogInformation($"Received '{item.FileName}'."); _logger.LogInformation($"Received '{item.FileName}'.");
if (item.QuotaOffense) if (item.QuotaOffense)
@ -157,62 +88,6 @@ namespace Yavsc.Blogs.Controllers
return Ok(received); return Ok(received);
} }
// GET: api/v1/fs/file/{fileId}
// Télécharge une pièce jointe référencée par son UploadedFile.Id.
// Autorise : le propriétaire du fichier, ou toute partie (client /
// fournisseur) d'un devis ou d'une demande auquel ce fichier est
// rattaché via EstimateAttachedFile / QueryAttachedFile. Les octets
// sont lus depuis l'espace perso du propriétaire, sur le disque de
// ce host Blogs (le host API ne possède pas ces octets).
[HttpGet("file/{fileId:long}")]
public IActionResult GetFile(long fileId)
{
var file = dbContext.UploadedFiles.FirstOrDefault(u => u.Id == fileId);
if (file == null) return NotFound(new { error = "file not found" });
var uid = User.GetUserId();
if (!CanReadFile(file, uid))
return Forbid();
var ownerUserName = dbContext.Users
.Where(u => u.Id == file.OwnerId)
.Select(u => u.UserName)
.FirstOrDefault();
if (string.IsNullOrEmpty(ownerUserName))
return NotFound(new { error = "owner not found" });
var physicalPath = Path.Combine(siteSettings.Blog, ownerUserName, file.Path ?? "");
var fi = new FileInfo(physicalPath);
if (!fi.Exists) return NotFound(new { error = "file not on disk" });
var contentType = string.IsNullOrWhiteSpace(file.ContentType)
? "application/octet-stream" : file.ContentType;
var stream = fi.OpenRead();
return File(stream, contentType, fi.Name);
}
// Le propriétaire peut toujours lire son fichier ; sinon, il faut
// que le fichier soit rattaché à un devis ou une demande dont
// l'appelant est partie (client ou fournisseur). Les admin sont
// autorisés comme pour le reste du workflow.
bool CanReadFile(UploadedFile file, string uid)
{
if (User.IsInRole(Constants.AdminGroupName)) return true;
if (file.OwnerId == uid) return true;
if (dbContext.EstimateAttachedFiles.Any(a =>
a.FileId == file.Id &&
(a.Estimate.ClientId == uid || a.Estimate.OwnerId == uid)))
return true;
if (dbContext.QueryAttachedFiles.Any(a =>
a.FileId == file.Id &&
(a.Command.ClientId == uid || a.Command.PerformerId == uid)))
return true;
return false;
}
[Route("/api/fsc/addquota/{uname}/{len}")] [Route("/api/fsc/addquota/{uname}/{len}")]
[Authorize("AdministratorOnly")] [Authorize("AdministratorOnly")]
public IActionResult AddQuota(string uname, int len) public IActionResult AddQuota(string uname, int len)

View file

@ -8,7 +8,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -11,7 +11,7 @@
<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-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Yavsc.Models; using Yavsc.Models;
@ -20,7 +19,7 @@ namespace Yavsc.Controllers
public CommentsController(ApplicationDbContext context) public CommentsController(ApplicationDbContext context)
{ {
_context = context; _context = context;
} }
[HttpGet("{id:long}", Name = "GetComment")] [HttpGet("{id:long}", Name = "GetComment")]
@ -83,17 +82,8 @@ namespace Yavsc.Controllers
return CreatedAtRoute("GetComment", new { id = comment.Id }, new { id = comment.Id, dateCreated = comment.DateCreated }); return CreatedAtRoute("GetComment", new { id = comment.Id }, new { id = comment.Id, dateCreated = comment.DateCreated });
} }
// --- Actions MVC scaffoldées (vues CRUD) ---------------------
// Ces actions ne font pas partie de la surface d'API documentée
// par Swagger. Elles sont exclues de l'ApiExplorer : sinon, comme
// le contrôleur porte [Route("~/api/v1/blogcomments")], leurs
// [HttpPost] sans template propre atterrissent sur la même route
// "POST api/v1/blogcomments" que l'action Post ci-dessus, et
// Swashbuckle lève une SwaggerGeneratorException (conflicting
// method/path) au démarrage de /swagger.
// GET: Comments // GET: Comments
[HttpGet] [HttpGet]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Index() public async Task<IActionResult> Index()
{ {
var applicationDbContext = _context.Comment.Include(c => c.Post); var applicationDbContext = _context.Comment.Include(c => c.Post);
@ -101,7 +91,6 @@ namespace Yavsc.Controllers
} }
// GET: Comments/Details/5 // GET: Comments/Details/5
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Details(long? id) public async Task<IActionResult> Details(long? id)
{ {
if (id == null) if (id == null)
@ -120,7 +109,6 @@ namespace Yavsc.Controllers
// GET: Comments/Create (MVC form endpoint) // GET: Comments/Create (MVC form endpoint)
[HttpGet("form")] [HttpGet("form")]
[ApiExplorerSettings(IgnoreApi = true)]
public IActionResult Create() public IActionResult Create()
{ {
ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title"); ViewBag.ReceiverId = new SelectList(_context.BlogSpot, "Id", "Title");
@ -130,7 +118,6 @@ namespace Yavsc.Controllers
// POST: Comments/Create (MVC form endpoint) // POST: Comments/Create (MVC form endpoint)
[HttpPost("form")] [HttpPost("form")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Create(Comment comment) public async Task<IActionResult> Create(Comment comment)
{ {
comment.UserCreated = User.GetUserId(); comment.UserCreated = User.GetUserId();
@ -139,7 +126,7 @@ namespace Yavsc.Controllers
// remove the stale binding error so a valid authenticated POST // remove the stale binding error so a valid authenticated POST
// does not fall into the invalid branch. // does not fall into the invalid branch.
ModelState.Remove(nameof(Comment.AuthorId)); ModelState.Remove(nameof(Comment.AuthorId));
if (ModelState.IsValid) if (ModelState.IsValid)
{ {
_context.Comment.Add(comment); _context.Comment.Add(comment);
@ -151,7 +138,6 @@ namespace Yavsc.Controllers
} }
// GET: Comments/Edit/5 // GET: Comments/Edit/5
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Edit(long? id) public async Task<IActionResult> Edit(long? id)
{ {
if (id == null) if (id == null)
@ -171,7 +157,6 @@ namespace Yavsc.Controllers
// POST: Comments/Edit/5 // POST: Comments/Edit/5
[HttpPost] [HttpPost]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Edit(Comment comment) public async Task<IActionResult> Edit(Comment comment)
{ {
if (ModelState.IsValid) if (ModelState.IsValid)
@ -186,7 +171,6 @@ namespace Yavsc.Controllers
// GET: Comments/Delete/5 // GET: Comments/Delete/5
[ActionName("Delete")] [ActionName("Delete")]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> Delete(long? id) public async Task<IActionResult> Delete(long? id)
{ {
if (id == null) if (id == null)
@ -206,7 +190,6 @@ namespace Yavsc.Controllers
// POST: Comments/Delete/5 // POST: Comments/Delete/5
[HttpPost, ActionName("Delete")] [HttpPost, ActionName("Delete")]
[ValidateAntiForgeryToken] [ValidateAntiForgeryToken]
[ApiExplorerSettings(IgnoreApi = true)]
public async Task<IActionResult> DeleteConfirmed(long id) public async Task<IActionResult> DeleteConfirmed(long id)
{ {
Comment comment = await _context.Comment.SingleAsync(m => m.Id == id); Comment comment = await _context.Comment.SingleAsync(m => m.Id == id);

File diff suppressed because it is too large Load diff

View file

@ -1,148 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Yavsc.Migrations
{
/// <inheritdoc />
public partial class UserFileAttachments : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Path",
table: "UploadedFiles",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "UploadedFiles",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AddColumn<string>(
name: "OwnerId",
table: "UploadedFiles",
type: "text",
nullable: true);
migrationBuilder.CreateTable(
name: "EstimateAttachedFiles",
columns: table => new
{
FileId = table.Column<long>(type: "bigint", nullable: false),
EstimateId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_EstimateAttachedFiles", x => new { x.FileId, x.EstimateId });
table.ForeignKey(
name: "FK_EstimateAttachedFiles_Estimates_EstimateId",
column: x => x.EstimateId,
principalTable: "Estimates",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_EstimateAttachedFiles_UploadedFiles_FileId",
column: x => x.FileId,
principalTable: "UploadedFiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "QueryAttachedFiles",
columns: table => new
{
FileId = table.Column<long>(type: "bigint", nullable: false),
CommandId = table.Column<long>(type: "bigint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_QueryAttachedFiles", x => new { x.FileId, x.CommandId });
table.ForeignKey(
name: "FK_QueryAttachedFiles_NominativeServiceCommands_CommandId",
column: x => x.CommandId,
principalTable: "NominativeServiceCommands",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_QueryAttachedFiles_UploadedFiles_FileId",
column: x => x.FileId,
principalTable: "UploadedFiles",
principalColumn: "Id",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_UploadedFiles_OwnerId",
table: "UploadedFiles",
column: "OwnerId");
migrationBuilder.CreateIndex(
name: "IX_EstimateAttachedFiles_EstimateId",
table: "EstimateAttachedFiles",
column: "EstimateId");
migrationBuilder.CreateIndex(
name: "IX_QueryAttachedFiles_CommandId",
table: "QueryAttachedFiles",
column: "CommandId");
migrationBuilder.AddForeignKey(
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
table: "UploadedFiles",
column: "OwnerId",
principalTable: "AspNetUsers",
principalColumn: "Id");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_UploadedFiles_AspNetUsers_OwnerId",
table: "UploadedFiles");
migrationBuilder.DropTable(
name: "EstimateAttachedFiles");
migrationBuilder.DropTable(
name: "QueryAttachedFiles");
migrationBuilder.DropIndex(
name: "IX_UploadedFiles_OwnerId",
table: "UploadedFiles");
migrationBuilder.DropColumn(
name: "OwnerId",
table: "UploadedFiles");
migrationBuilder.AlterColumn<string>(
name: "Path",
table: "UploadedFiles",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<string>(
name: "ContentType",
table: "UploadedFiles",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
}
}
}

View file

@ -1394,21 +1394,6 @@ namespace Yavsc.Migrations
b.ToTable("Estimates"); b.ToTable("Estimates");
}); });
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
{
b.Property<long>("FileId")
.HasColumnType("bigint");
b.Property<long>("EstimateId")
.HasColumnType("bigint");
b.HasKey("FileId", "EstimateId");
b.HasIndex("EstimateId");
b.ToTable("EstimateAttachedFiles");
});
modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b => modelBuilder.Entity("Yavsc.Models.Billing.EstimateTemplate", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -1517,21 +1502,6 @@ namespace Yavsc.Migrations
b.UseTphMappingStrategy(); b.UseTphMappingStrategy();
}); });
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
{
b.Property<long>("FileId")
.HasColumnType("bigint");
b.Property<long>("CommandId")
.HasColumnType("bigint");
b.HasKey("FileId", "CommandId");
b.HasIndex("CommandId");
b.ToTable("QueryAttachedFiles");
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b => modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{ {
b.Property<long>("Id") b.Property<long>("Id")
@ -1704,23 +1674,16 @@ namespace Yavsc.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id")); NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("ContentType") b.Property<string>("ContentType")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.Property<long>("Length") b.Property<long>("Length")
.HasColumnType("bigint"); .HasColumnType("bigint");
b.Property<string>("OwnerId")
.HasColumnType("text");
b.Property<string>("Path") b.Property<string>("Path")
.IsRequired()
.HasColumnType("text"); .HasColumnType("text");
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("OwnerId");
b.ToTable("UploadedFiles"); b.ToTable("UploadedFiles");
}); });
@ -3864,25 +3827,6 @@ namespace Yavsc.Migrations
b.Navigation("Query"); b.Navigation("Query");
}); });
modelBuilder.Entity("Yavsc.Models.Billing.EstimateAttachedFile", b =>
{
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
.WithMany()
.HasForeignKey("EstimateId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
.WithMany()
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Estimate");
b.Navigation("File");
});
modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b => modelBuilder.Entity("Yavsc.Models.Billing.NominativeServiceCommand", b =>
{ {
b.HasOne("Yavsc.Models.Workflow.Activity", "Context") b.HasOne("Yavsc.Models.Workflow.Activity", "Context")
@ -3916,25 +3860,6 @@ namespace Yavsc.Migrations
b.Navigation("Regularization"); b.Navigation("Regularization");
}); });
modelBuilder.Entity("Yavsc.Models.Billing.QueryAttachedFile", b =>
{
b.HasOne("Yavsc.Models.Billing.NominativeServiceCommand", "Command")
.WithMany()
.HasForeignKey("CommandId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Yavsc.Models.Blog.UploadedFile", "File")
.WithMany()
.HasForeignKey("FileId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Command");
b.Navigation("File");
});
modelBuilder.Entity("Yavsc.Models.Billing.Signature", b => modelBuilder.Entity("Yavsc.Models.Billing.Signature", b =>
{ {
b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate") b.HasOne("Yavsc.Models.Billing.Estimate", "Estimate")
@ -4027,15 +3952,6 @@ namespace Yavsc.Migrations
b.Navigation("Post"); b.Navigation("Post");
}); });
modelBuilder.Entity("Yavsc.Models.Blog.UploadedFile", b =>
{
b.HasOne("Yavsc.Models.ApplicationUser", "Owner")
.WithMany()
.HasForeignKey("OwnerId");
b.Navigation("Owner");
});
modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b => modelBuilder.Entity("Yavsc.Models.BlogSpotPublication", b =>
{ {
b.HasOne("Yavsc.Models.Blog.BlogPost", "Post") b.HasOne("Yavsc.Models.Blog.BlogPost", "Post")

View file

@ -2,7 +2,7 @@
style="background-image: url('@SiteSettings.Value.Banner'); style="background-image: url('@SiteSettings.Value.Banner');
background-position: 0%; background-position: 0%;
background-attachment: fixed;"> background-attachment: fixed;">
<a class="navbar-brand logo" href="/"> <a class="navbar-brand" href="/">
<img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" /> <img src="~/images/yavsc-logo.svg" alt="Yavsc" style="height:32px; margin-right:8px; display:inline-block; vertical-align:middle;" />
@SiteSettings.Value.Title @SiteSettings.Value.Title
</a> </a>

View file

@ -9,7 +9,7 @@
<RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl> <RepositoryUrl>https://github.com/pazof/yavsc</RepositoryUrl>
<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-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -0,0 +1,104 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Microsoft.AspNetCore.SignalR": "Debug",
"Microsoft.AspNetCore.Http.Connections": "Debug"
}
},
"AllowedHosts": "*",
"ConnectionStrings": {
"YavscConnection": "*** via dotnet user-secrets ou variable d'environnement *** Server=[YOURSERVERNAME];Port=5432;Database=[YOURDBNAME];Username=[YOURDBUSERNAME];Password=[YOURDBPASSW];"
},
"Site": {
"Title": "yavsc",
"Slogan": "Yavsc!",
"StyleSheet": "/css/default.css",
"Authority": "https://[Your domaine name]",
"ExternalUrl": "https://[Your domaine name]",
"CorsAllowedOrigins": [
"https://your.api.url.com/api/v1",
"https://localhost:5003/api/v1",
"https://localhost:5005/api/v1"
],
"Audience": [ "api", "blogs"],
"Banner": "/images/banner-dewang.jpg",
"Owner": {
"Name": "[Site owner's name]",
"EMail": "[Site owner's e-mail address]"
},
"Admin": {
"Name": "[a name for a site admin]",
"EMail": "[an e-mail for a site admin]"
},
"Avatars": "avatars",
"Quota": 200000000,
"Bills": "bills",
"Blog": "blog",
"TempDir": "temp",
"DataDir": "data"
},
"Smtp": {
"Host": "[YOURSMTPHOST]",
"Port": 465,
"Username": "[YOURSMTPUSERNAME]",
"Password": "[YOURSMTPPASSWORD]"
},
"DataProtection": {
"Keys": {
"Dir": "DataProtection"
},
"RSAParamFile": "RSA-Params.json",
"ExpiresInHours": 168
},
"Authentication": {
"PayPal": {
"Mode": "[sandbox|production]",
"ClientId": "[Your PayPal REST API ClientId]",
"Secret": "[Your PayPal REST API Secret]"
},
"Google": {
"ApiKey": "[Your ApiKey]",
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]",
"ServiceAccount": {
"project_id": "[Yours]",
"private_key_id": "[Yours]",
"private_key": "-----[Yours]---\n",
"client_email": "[Yours]@appspot.gserviceaccount.com",
"client_id": "[Yours]",
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": "https://accounts.google.com/o/oauth2/token",
"auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
"client_x509_cert_url": "https://www41.googleapis.com/robot/v1/[Yours].com"
}
},
"Facebook": {
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]"
},
"Twitter": {
"ClientId": "[Your ClientId]",
"ClientSecret": "[Your ClientSecret]"
}
},
"Moderation": {
"AutoApproveThreshold": 0.7,
"AutoRejectThreshold": 0.9
},
"Kestrel": {
"Endpoints": {
"Http": {
"Url": "http://localhost:82"
},
"Https": {
"Url": "https://localhost:83",
"Certificate": {
"Path": "/etc/letsencrypt/live/[Your domaine name]/cert.pem",
"KeyPath": "/etc/letsencrypt/live/[Your domaine name]/privkey.pem"
}
}
}
}
}

View file

@ -5033,8 +5033,7 @@ fieldset:disabled .btn {
background-color: var(--bs-card-bg); background-color: var(--bs-card-bg);
background-clip: border-box; background-clip: border-box;
border: var(--bs-card-border-width) solid var(--bs-card-border-color); border: var(--bs-card-border-width) solid var(--bs-card-border-color);
border-radius: var(--bs-card-border-radius); border-radius: var(--bs-card-border-radius)
padding: var(--bs-card-spacer-y) var(--bs-card-spacer-x);
} }
.card>hr { .card>hr {

View file

@ -1,222 +0,0 @@
using System.Security.Claims;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models.Blog;
using Yavsc.Server.Exceptions;
using Yavsc.Server.Tests.Fixtures;
namespace Yavsc.Server.Tests;
/// <summary>
/// Service-level tests for <see cref="BlogSpotService.Details"/>.
///
/// <para><see cref="BlogSpotService.Details"/> loads a
/// <see cref="BlogPost"/> (with Author, Tags, Comments, ACL and
/// Publication navigations), runs a resource-based
/// <c>ReadPermission</c> authorization against it, scrubs the ACL for
/// non-owners, derives <see cref="BlogPost.IsPublished"/> from the
/// loaded <c>Publication</c> navigation, and eagerly loads each
/// comment's <c>Author</c>. These tests pin each of those behaviours
/// at the service layer against the real
/// <see cref="Yavsc.Services.PermissionHandler"/> (the public/owner/
/// sponsor/Administrator disjunction) and a real SQLite store — no
/// mocking.</para>
///
/// <para>Seeding and the service call resolve through separate DI
/// scopes so the DbContext that writes the seed rows is disposed
/// before the one <see cref="BlogSpotService.Details"/> reads through,
/// mirroring the per-request DbContext lifetime in production.</para>
/// </summary>
[Collection("Yavsc Server")]
public sealed class BlogSpotServiceDetailsTests : IClassFixture<ServerServicesFixture>
{
private readonly ServerServicesFixture _fixture;
public BlogSpotServiceDetailsTests(ServerServicesFixture fixture)
{
_fixture = fixture;
}
/// <summary>An authenticated principal whose <c>sub</c> claim is
/// <paramref name="userId"/>. <see cref="Yavsc.Server.Helpers.UserHelpers.GetUserId"/>
/// reads <c>sub</c> first, which is what the JwtBearer middleware
/// in production preserves (MapInboundClaims = false), and what
/// <see cref="Yavsc.Services.PermissionHandler"/> compares against
/// <see cref="BlogPost.AuthorId"/>.</summary>
private static ClaimsPrincipal Authenticated(string userId,
params Claim[] extra)
{
var claims = new List<Claim> { new("sub", userId) };
claims.AddRange(extra);
return new ClaimsPrincipal(
new ClaimsIdentity(claims, authenticationType: "Bearer"));
}
/// <summary>An unauthenticated principal: a
/// <see cref="ClaimsIdentity"/> with no authentication type has
/// <see cref="ClaimsIdentity.IsAuthenticated"/> == false. The
/// service still runs authorization — a published post's
/// <c>ReadPermission</c> succeeds purely on IsPublic, with no
/// identity required.</summary>
private static ClaimsPrincipal Anonymous()
=> new(new ClaimsIdentity());
/// <summary>The Microsoft-format role claim
/// <see cref="Yavsc.Server.Helpers.UserHelpers.IsInMsRole"/>
/// reads when checking the "Administrator" bypass in
/// <see cref="Yavsc.Services.PermissionHandler"/>.</summary>
private static Claim AdminRole()
=> new("http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
"Administrator");
[Fact]
public async Task Details_returns_the_post_for_its_owner_and_keeps_the_acl()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var circleId = _fixture.SeedCircle("alice", "alice-private");
var postId = _fixture.SeedBlogPost("alice", "Owner reads own draft");
_fixture.SeedAcl(postId, circleId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), postId));
Assert.Equal(postId, blog.Id);
Assert.Equal("Owner reads own draft", blog.Title);
// The owner is the one viewer ScrubAclForViewer keeps the ACL
// for — a non-owner would see an empty list.
Assert.Single(blog.ACL!);
// No BlogSpotPublication row was seeded, so IsPublished is
// derived from the (null) Publication navigation as false.
Assert.False(blog.IsPublished);
}
[Fact]
public async Task Details_serves_IsPublished_true_when_a_publication_row_exists()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var postId = _fixture.SeedBlogPost("alice", "Published post");
_fixture.Publish(postId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), postId));
// IsPublished is a wire-only value the service derives from
// the loaded Publication navigation (Details Includes it).
Assert.True(blog.IsPublished);
}
[Fact]
public async Task Details_lets_an_anonymous_viewer_read_a_published_post_and_scrubs_its_acl()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
var circleId = _fixture.SeedCircle("alice", "alice-private");
var postId = _fixture.SeedBlogPost("alice", "Public post");
// The ACL is real, but a non-owner viewer must not see it.
_fixture.SeedAcl(postId, circleId);
_fixture.Publish(postId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Anonymous(), postId));
// ReadPermission succeeds purely on IsPublic (the publication
// row) — no identity needed.
Assert.Equal(postId, blog.Id);
Assert.True(blog.IsPublished);
// ScrubAclForViewer: the anonymous viewer is not the owner, so
// the ACL is replaced with an empty list rather than leaked.
Assert.Empty(blog.ACL!);
}
[Fact]
public async Task Details_denies_a_draft_to_a_non_owner_who_is_not_a_sponsor()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
var postId = _fixture.SeedBlogPost("alice", "Alice's draft");
// No publication row (not public), bob is not the owner, bob
// is not a member of any circle alice owns, bob is not an
// Administrator — every branch of ReadPermission fails.
await Assert.ThrowsAsync<AuthorizationFailureException>(() =>
_fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("bob"), postId)));
}
[Fact]
public async Task Details_lets_a_sponsor_read_a_draft_and_scrubs_its_acl()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
// Sponsorship = bob is a member of a circle alice owns.
var circleId = _fixture.SeedCircle("alice", "alice-friends");
_fixture.SeedCircleMember(circleId, "bob");
var postId = _fixture.SeedBlogPost("alice", "Draft for friends");
_fixture.SeedAcl(postId, circleId);
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("bob"), postId));
// ReadPermission succeeds on the IsSponsor branch.
Assert.Equal(postId, blog.Id);
Assert.False(blog.IsPublished);
// bob is not the owner, so the ACL is scrubbed even though he
// was granted read access via the circle.
Assert.Empty(blog.ACL!);
}
[Fact]
public async Task Details_lets_an_administrator_read_a_draft()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("root");
var postId = _fixture.SeedBlogPost("alice", "Draft, admin view");
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("root", AdminRole()), postId));
// The Administrator bypass in PermissionHandler short-circuits
// the public/owner/sponsor checks.
Assert.Equal(postId, blog.Id);
Assert.False(blog.IsPublished);
}
[Fact]
public async Task Details_loads_each_comment_author()
{
_fixture.ResetDatabase();
_fixture.SeedUser("alice");
_fixture.SeedUser("bob");
var postId = _fixture.SeedBlogPost("alice", "Post with comments");
_fixture.SeedComment(postId, "bob", "Nice post!");
var blog = await _fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), postId));
var comment = Assert.Single(blog.Comments);
// Details explicitly loads c.Author from _context.Users for
// every comment; the navigation must not be left null.
Assert.NotNull(comment.Author);
Assert.Equal("bob", comment.Author.UserName);
}
[Fact]
public async Task Details_throws_for_a_missing_post()
{
_fixture.ResetDatabase();
// The query uses SingleAsync, which throws
// InvalidOperationException when no row matches — the
// `if (blog == null) return null` guard below it is unreachable.
// Pin that current behaviour so a future switch to
// SingleOrDefaultAsync (which would make the guard live) is a
// deliberate, reviewed change rather than a silent one.
await Assert.ThrowsAsync<InvalidOperationException>(() =>
_fixture.WithServiceAsync(svc =>
svc.Details(Authenticated("alice"), 424242)));
}
}

View file

@ -1,452 +0,0 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Npgsql;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Models.Blog;
using Yavsc.Models.Relationship;
using Yavsc.Services;
namespace Yavsc.Server.Tests.Fixtures;
/// <summary>
/// Service-level test host for <see cref="BlogSpotService"/> and the
/// other Yavsc.Server services that take an
/// <see cref="ApplicationDbContext"/> + <see cref="IAuthorizationService"/>.
///
/// <para>Unlike <c>BlogsWebServerFixture</c> (in Yavsc.Blogs.Tests),
/// this fixture does <b>not</b> spin up Kestrel, JWT bearer, or MVC
/// controllers. <see cref="BlogSpotService.Details"/> takes the
/// <see cref="System.Security.Claims.ClaimsPrincipal"/> as a parameter,
/// so the caller controls identity directly — there is no HTTP/auth
/// middleware in the path under test. Building only the DI graph the
/// service actually resolves keeps these tests fast and isolates the
/// service layer from the blog API's transport concerns, which
/// Yavsc.Blogs.Tests already covers end-to-end.</para>
///
/// <para><b>Database provider.</b> Mirrors
/// <c>ApiWebServerFixture</c>: SQLite <c>:memory:</c> by default, or
/// real PostgreSQL when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c> is
/// set. Production runs on Npgsql, so the Npgsql path — which applies
/// the real <c>Yavsc.Org</c> migrations against a dedicated test
/// database — is the one that catches provider-specific bugs (FK
/// enforcement, type mappings, migration drift). SQLite stays the
/// default so the suite runs out-of-the-box with no external server.</para>
///
/// <list type="bullet">
/// <item><description>SQLite: a named <c>:memory:</c> database
/// (Cache=Shared) backed by a single connection held open for the
/// fixture's lifetime, schema via <c>EnsureCreated</c>. Reset is
/// <c>EnsureDeleted</c> + <c>EnsureCreated</c>.</description></item>
/// <item><description>Npgsql: a dedicated database
/// <c>yavscServerTestDb</c> (created from the admin connection if it
/// does not exist), schema via <c>Migrate</c> using the
/// <c>Yavsc.Org</c> migrations assembly. Reset purges the blog-graph
/// tables in FK order — children before parents — so no DELETE
/// violates a constraint, instead of dropping the database (which
/// would force a full re-migration per test).</description></item>
/// <item><description>The real <see cref="BlogSpotService"/> and the
/// real <see cref="PermissionHandler"/>: <c>Details</c> calls
/// <c>IAuthorizationService.AuthorizeAsync(user, blog, new
/// ReadPermission())</c>, and <see cref="PermissionHandler"/> answers
/// that requirement with the public/owner/sponsor/Administrator
/// disjunction that the tests below pin. No mocking.</description></item>
/// <item><description>A trivial <see cref="IFileSystemAuthManager"/>
/// stub: <c>Details</c> never touches the file system, but the DI
/// container needs an instance to construct the service.</description></item>
/// </list>
///
/// <para>Marked <see cref="CollectionDefinitionAttribute"/> so a single
/// fixture instance (one store, one DI container) is shared across
/// every <c>[Collection("Yavsc Server")]</c> test class. Each test
/// resets the store via <see cref="ResetDatabase"/>.</para>
/// </summary>
[CollectionDefinition("Yavsc Server")]
public sealed class ServerServicesFixture : IDisposable
{
private const string DbProviderEnvVar = "YAVSC_SERVER_TEST_DB_PROVIDER";
private const string NpgsqlAdminConnectionEnvVar = "YAVSC_SERVER_TEST_NPGSQL_ADMIN_CONNECTION";
private const string DedicatedNpgsqlDatabaseName = "yavscServerTestDb";
private static readonly object _npgsqlLock = new();
private static string? _sharedNpgsqlConnectionString;
private readonly SqliteConnection? _sqliteConnection;
private readonly ServiceProvider _services;
public IServiceProvider Services => _services;
public ServerServicesFixture()
{
var services = new ServiceCollection();
if (UseNpgsqlProvider())
{
var npgsqlConnectionString = EnsureNpgsqlDatabaseCreated();
services.AddDbContext<ApplicationDbContext>(opt =>
opt.UseNpgsql(npgsqlConnectionString,
x => x.MigrationsAssembly("Yavsc.Org")));
}
else
{
// Mode=Memory + Cache=Shared: a named in-memory database
// every connection string referencing "YavscServerTests"
// resolves to the same backing store, as long as at least
// one connection stays open against it. _sqliteConnection
// is held for the fixture's lifetime (disposed in Dispose),
// so the store survives across scoped DbContexts per test.
_sqliteConnection = new SqliteConnection(
"Data Source=YavscServerTests;Mode=Memory;Cache=Shared");
_sqliteConnection.Open();
services.AddDbContext<ApplicationDbContext>(opt =>
// UseSqlite(DbConnection) keeps _sqliteConnection alive
// for the DbContext's lifetime instead of letting EF
// open and close its own — without this each DbContext
// would get a fresh connection pointing at an empty
// store.
opt.UseSqlite(_sqliteConnection));
}
// BlogSpotService.AttachFiles writes under siteSettings.Blog.
// Details itself doesn't read it, but the service constructor
// throws if SiteSettings.Blog is null, so configure a temp
// value to keep construction alive.
services.Configure<SiteSettings>(s => s.Blog = "server-tests-files-root");
// Trivial file-system auth: Details never calls into it, but
// the DI container needs an instance to construct the service.
services.AddSingleton<IFileSystemAuthManager>(
new NoopFileSystemAuthManager());
// The real BlogSpotService — same shape the production host
// builds (ApplicationDbContext, IAuthorizationService,
// IFileSystemAuthManager, IOptions<SiteSettings>,
// ILoggerFactory).
services.AddScoped<BlogSpotService>();
// The real PermissionHandler: Details authorizes with
// ReadPermission, and PermissionHandler resolves it via the
// IsPublic / IsOwner / IsSponsor / Administrator disjunction.
services.AddScoped<IAuthorizationHandler, PermissionHandler>();
// Default authorization services register
// IAuthorizationService (DefaultAuthorizationService), which
// collects the IAuthorizationHandler above and runs it against
// the requirement passed to AuthorizeAsync.
services.AddAuthorization();
services.AddLogging();
_services = services.BuildServiceProvider();
// Create the schema once. Npgsql uses Migrate (the Yavsc.Org
// migrations) so the test schema matches production; SQLite uses
// EnsureCreated (idempotent, model-derived). ResetDatabase
// re-creates / purges per test.
using (var scope = _services.CreateScope())
{
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
if (UseNpgsqlProvider())
db.Database.Migrate();
else
db.Database.EnsureCreated();
}
}
/// <summary>True when <c>YAVSC_SERVER_TEST_DB_PROVIDER=npgsql</c>
/// is set — the fixture then runs against a real PostgreSQL server
/// instead of the SQLite in-memory default.</summary>
public static bool UseNpgsqlProvider()
=> string.Equals(
Environment.GetEnvironmentVariable(DbProviderEnvVar),
"npgsql",
StringComparison.OrdinalIgnoreCase);
/// <summary>Reset the store to a known empty state for the next
/// test. SQLite: <c>EnsureDeleted</c> + <c>EnsureCreated</c> (the
/// named in-memory store survives because _sqliteConnection is held
/// open). Npgsql: purge the blog-graph tables in FK order —
/// children before parents — so no DELETE trips a foreign key,
/// avoiding a full re-migration per test.</summary>
public void ResetDatabase()
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
if (UseNpgsqlProvider())
{
// Children before parents. BlogAttachedFile, Comment and
// BlogSpotPublication reference BlogSpot; CircleAuthorization
// ToBlogPost references BlogSpot + Circle; BlogTag references
// BlogSpot + Tag; BlogSpot references ApplicationUser;
// CircleMember references Circle + ApplicationUser.
db.BlogAttachedFiles.RemoveRange(db.BlogAttachedFiles);
db.Comment.RemoveRange(db.Comment);
db.blogSpotPublications.RemoveRange(db.blogSpotPublications);
db.CircleAuthorizationToBlogPost
.RemoveRange(db.CircleAuthorizationToBlogPost);
db.BlogTag.RemoveRange(db.BlogTag);
db.UploadedFiles.RemoveRange(db.UploadedFiles);
db.BlogSpot.RemoveRange(db.BlogSpot);
db.CircleMembers.RemoveRange(db.CircleMembers);
db.Circle.RemoveRange(db.Circle);
db.Users.RemoveRange(db.Users);
db.SaveChanges();
return;
}
db.Database.EnsureDeleted();
db.Database.EnsureCreated();
}
/// <summary>Resolve a fresh service scope and the
/// <see cref="ApplicationDbContext"/> from it, run
/// <paramref name="body"/>, and dispose the scope (and its
/// DbContext). Use this for seeding writes that should not share
/// a DbContext with the service call under test.</summary>
public T WithDb<T>(Func<ApplicationDbContext, T> body)
{
using var scope = _services.CreateScope();
var db = scope.ServiceProvider
.GetRequiredService<ApplicationDbContext>();
return body(db);
}
public void SeedUser(string userName,
Action<ApplicationUser>? configure = null)
{
WithDb(db =>
{
if (db.Users.Any(u => u.Id == userName)) return false;
var user = new ApplicationUser
{
Id = userName,
UserName = userName,
Email = $"{userName}@example.test",
};
configure?.Invoke(user);
db.Users.Add(user);
db.SaveChanges();
return true;
});
}
public long SeedBlogPost(string authorId, string title,
Action<BlogPost>? configure = null)
{
return WithDb(db =>
{
var post = new BlogPost
{
AuthorId = authorId,
Title = title,
Article = "Test article body.",
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
};
configure?.Invoke(post);
db.BlogSpot.Add(post);
db.SaveChanges();
return post.Id;
});
}
/// <summary>Insert a <see cref="BlogSpotPublication"/> row for
/// <paramref name="postId"/> — the existence of that row is what
/// makes a post "public" (readable by anyone) in
/// <see cref="PermissionHandler"/>. This is the same write
/// <c>SetPublishAsync</c> performs in production.</summary>
public void Publish(long postId)
{
WithDb(db =>
{
db.blogSpotPublications.Add(
new BlogSpotPublication { PostId = postId });
db.SaveChanges();
return true;
});
}
/// <summary>Seed a circle owned by <paramref name="ownerId"/> and
/// return its server-assigned id. Sponsorship in
/// <see cref="PermissionHandler"/> is "the viewer is a member of a
/// circle the post's author owns", so a circle + a
/// <see cref="SeedCircleMember"/> entry is what grants a non-owner
/// read access to a draft.</summary>
public long SeedCircle(string ownerId, string name)
{
return WithDb(db =>
{
var circle = new Circle
{
OwnerId = ownerId,
Name = name,
Public = false,
};
db.Circle.Add(circle);
db.SaveChanges();
return circle.Id;
});
}
public void SeedCircleMember(long circleId, string memberId)
{
WithDb(db =>
{
db.CircleMembers.Add(
new CircleMember { CircleId = circleId, MemberId = memberId });
db.SaveChanges();
return true;
});
}
/// <summary>Attach a <see cref="CircleAuthorizationToBlogPost"/> ACL
/// entry to <paramref name="postId"/> for <paramref name="circleId"/>.
/// The owner always sees the ACL; non-owners have it scrubbed by
/// <see cref="BlogSpotService"/>'s ScrubAclForViewer.</summary>
public void SeedAcl(long postId, long circleId)
{
WithDb(db =>
{
db.CircleAuthorizationToBlogPost.Add(
new CircleAuthorizationToBlogPost
{
BlogPostId = postId,
CircleId = circleId,
});
db.SaveChanges();
return true;
});
}
/// <summary>Seed a comment on <paramref name="postId"/> authored by
/// <paramref name="authorId"/>. <see cref="BlogSpotService.Details"/>
/// loads each comment's <c>Author</c> navigation from
/// <c>_context.Users</c>; seeding the user first keeps the FK
/// valid (SQLite and PostgreSQL both enforce it).</summary>
public void SeedComment(long postId, string authorId, string article)
{
WithDb(db =>
{
db.Comment.Add(new Comment
{
ReceiverId = postId,
AuthorId = authorId,
Article = article,
Visible = true,
UserModified = authorId,
DateCreated = DateTime.UtcNow,
DateModified = DateTime.UtcNow,
});
db.SaveChanges();
return true;
});
}
/// <summary>Resolve the real <see cref="BlogSpotService"/> from the
/// fixture's DI container, run <paramref name="body"/> against it,
/// and dispose the request scope (and its scoped DbContext). The
/// service is scoped, so it must be resolved from a scope — and the
/// scope is what we dispose to avoid leaking a DbContext against
/// the shared connection across tests.</summary>
public async Task<T> WithServiceAsync<T>(
Func<BlogSpotService, Task<T>> body)
{
using var scope = _services.CreateScope();
var service = scope.ServiceProvider
.GetRequiredService<BlogSpotService>();
return await body(service);
}
private static string EnsureNpgsqlDatabaseCreated()
{
lock (_npgsqlLock)
{
if (!string.IsNullOrWhiteSpace(_sharedNpgsqlConnectionString))
return _sharedNpgsqlConnectionString;
var adminConnectionString = BuildAdminConnectionString();
var databaseName = DedicatedNpgsqlDatabaseName;
using (var adminConnection = new NpgsqlConnection(adminConnectionString))
{
adminConnection.Open();
using var existsCommand = adminConnection.CreateCommand();
existsCommand.CommandText =
"SELECT 1 FROM pg_database WHERE datname = @databaseName";
existsCommand.Parameters.AddWithValue("databaseName", databaseName);
if (existsCommand.ExecuteScalar() is null)
{
using var createCommand = adminConnection.CreateCommand();
createCommand.CommandText =
$"CREATE DATABASE \"{databaseName}\"";
createCommand.ExecuteNonQuery();
}
}
var testConnectionBuilder = new NpgsqlConnectionStringBuilder(adminConnectionString)
{
Database = databaseName,
Pooling = false,
IncludeErrorDetail = true
};
_sharedNpgsqlConnectionString = testConnectionBuilder.ToString();
return _sharedNpgsqlConnectionString;
}
}
private static string BuildAdminConnectionString()
{
// The admin connection string (host + credentials) is supplied
// via environment, never embedded in source — committing a
// real dev password would leak it into git history. The Npgsql
// path is opt-in, so requiring the env var is fine: a missing
// value produces a clear failure rather than a silent fallback
// onto someone's local dev database.
var configured = Environment.GetEnvironmentVariable(NpgsqlAdminConnectionEnvVar);
if (string.IsNullOrWhiteSpace(configured))
throw new InvalidOperationException(
$"{NpgsqlAdminConnectionEnvVar} is not set. The Npgsql test "
+ "provider requires an admin PostgreSQL connection string "
+ "in that environment variable (e.g. "
+ "'Server=localhost;Port=5432;Database=postgres;"
+ "Username=yavscdev;Password=...;Include Error Detail=true').");
var builder = new NpgsqlConnectionStringBuilder(configured)
{
Pooling = false,
IncludeErrorDetail = true
};
// The admin connection targets the maintenance database
// (postgres) so we can CREATE/DROP the dedicated test database;
// whatever database the env var names is overridden here.
builder.Database = "postgres";
return builder.ToString();
}
public void Dispose()
{
_services.Dispose();
_sqliteConnection?.Dispose();
}
private sealed class NoopFileSystemAuthManager : IFileSystemAuthManager
{
public FileAccessRight GetFilePathAccess(
System.Security.Claims.ClaimsPrincipal user,
string fileRelativePath)
=> FileAccessRight.None;
public void SetAccess(long circleId, string normalizedFullPath,
FileAccessRight access) { }
}
}

View file

@ -1,32 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>Yavsc.Server.Tests</RootNamespace>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion>
<Version>1.1.0-estimate.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Include="xunit.v3" />
<PackageReference Include="xunit.v3.common" />
<PackageReference Include="xunit.v3.extensibility.core" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Yavsc.Abstract\Yavsc.Abstract.csproj" />
<ProjectReference Include="..\Yavsc.Server\Yavsc.Server.csproj" />
<ProjectReference Include="..\Yavsc.Org\Yavsc.Org.csproj" />
<ProjectReference Include="..\Yavsc.Tests.Shared\Yavsc.Tests.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
</Project>

View file

@ -509,9 +509,5 @@ namespace Yavsc.Models
public DbSet<ModerationLog> ModerationLogs { get; set; } public DbSet<ModerationLog> ModerationLogs { get; set; }
public DbSet<NominativeServiceCommand> NominativeServiceCommands { get; set; } public DbSet<NominativeServiceCommand> NominativeServiceCommands { get; set; }
public DbSet<EstimateAttachedFile> EstimateAttachedFiles { get; set; }
public DbSet<QueryAttachedFile> QueryAttachedFiles { get; set; }
} }
} }

View file

@ -1,13 +0,0 @@
namespace Yavsc.Models.Billing
{
/// <summary>
/// Corps de requête pour attacher un fichier de l'espace perso à un
/// devis (fournisseur) ou à une demande (client), par référence à son
/// identifiant <see cref="Yavsc.Models.Blog.UploadedFile"/>.
/// </summary>
public class AttachFileRequest
{
/// <summary>Identifiant du fichier référencé.</summary>
public long FileId { get; set; }
}
}

View file

@ -1,35 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models.Blog;
namespace Yavsc.Models.Billing
{
/// <summary>
/// Pièce jointe d'un devis, attachée **par référence** à un fichier
/// de l'espace de stockage personnel du fournisseur
/// (<see cref="UploadedFile"/>). Le fichier reste dans l'espace perso
/// du fournisseur (pas de duplication) ; seul le lien est persisté.
/// Le téléchargement est servi par le host Blogs, qui autorise
/// l'appelant (fournisseur ou client du devis) via cette table.
/// </summary>
[PrimaryKey(nameof(FileId), nameof(EstimateId))]
public class EstimateAttachedFile
{
/// <summary>
/// Identifiant du fichier référencé (partie de la clé composite).
/// </summary>
public long FileId { get; set; }
[ForeignKey("FileId")]
public virtual UploadedFile File { get; set; }
/// <summary>
/// Identifiant du devis auquel le fichier est rattaché
/// (partie de la clé composite).
/// </summary>
public long EstimateId { get; set; }
[ForeignKey("EstimateId")]
public virtual Estimate Estimate { get; set; }
}
}

View file

@ -1,36 +0,0 @@
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models.Blog;
namespace Yavsc.Models.Billing
{
/// <summary>
/// Pièce jointe d'une demande (NominativeServiceCommand), attachée
/// **par référence** à un fichier de l'espace de stockage personnel
/// du client (<see cref="UploadedFile"/>). Le fichier reste dans
/// l'espace perso du client (pas de duplication) ; seul le lien est
/// persisté. Le téléchargement est servi par le host Blogs, qui
/// autorise l'appelant (client ou fournisseur de la demande) via
/// cette table.
/// </summary>
[PrimaryKey(nameof(FileId), nameof(CommandId))]
public class QueryAttachedFile
{
/// <summary>
/// Identifiant du fichier référencé (partie de la clé composite).
/// </summary>
public long FileId { get; set; }
[ForeignKey("FileId")]
public virtual UploadedFile File { get; set; }
/// <summary>
/// Identifiant de la demande à laquelle le fichier est rattaché
/// (partie de la clé composite).
/// </summary>
public long CommandId { get; set; }
[ForeignKey("CommandId")]
public virtual NominativeServiceCommand Command { get; set; }
}
}

View file

@ -1,4 +1,3 @@
#nullable enable annotations
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema; using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@ -34,19 +33,6 @@ namespace Yavsc.Models.Blog
/// </summary> /// </summary>
/// <value></value> /// <value></value>
public string ContentType { get; set; } public string ContentType { get; set; }
/// <summary>
/// Propriétaire du fichier dans l'espace de stockage personnel.
/// Renseigné pour les fichiers téléversés via <c>api/v1/fs</c>
/// (référencés par <see cref="EstimateAttachedFile"/> /
/// <see cref="QueryAttachedFile"/>). Reste <c>null</c> pour les
/// pièces jointes de blog historiques (<see cref="BlogAttachedFile"/>),
/// qui ne sont pas adressées par propriétaire.
/// </summary>
public string? OwnerId { get; set; }
[ForeignKey("OwnerId")]
public virtual ApplicationUser? Owner { get; set; }
} }
[PrimaryKey(nameof(FileId), nameof(PostId))] [PrimaryKey(nameof(FileId), nameof(PostId))]

View file

@ -43,29 +43,11 @@ namespace Yavsc.Server.Models.FileSystem
} }
public string DestDir { get; set; } public string DestDir { get; set; }
public string FileName { get; set; } public string FileName { get; set; }
public bool Overridden { get; set; } public bool Overridden { get; set; }
public bool QuotaOffense { get; set; } public bool QuotaOffense { get; set; }
/// <summary>
/// Identifiant de la ligne <see cref="Yavsc.Models.Blog.UploadedFile"/>
/// créée à l'enregistrement du fichier dans l'espace perso
/// (<c>api/v1/fs</c>). Nul si aucune métadonnée n'a été persistée.
/// </summary>
public long? FileId { get; set; }
/// <summary>
/// Taille du fichier reçu (octets), pour le reflet côté métadonnées.
/// </summary>
public long Length { get; set; }
/// <summary>
/// Type MIME du fichier reçu.
/// </summary>
public string ContentType { get; set; }
public string FullName { get => Path.Combine(DestDir, FileName); } public string FullName { get => Path.Combine(DestDir, FileName); }
} }

View file

@ -9,7 +9,7 @@
<Library>true</Library> <Library>true</Library>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -14,7 +14,7 @@
--> -->
<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-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -7,7 +7,7 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally> <ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<AssemblyVersion>1.1.0.0</AssemblyVersion> <AssemblyVersion>1.1.0.0</AssemblyVersion>
<FileVersion>1.1.0.0</FileVersion> <FileVersion>1.1.0.0</FileVersion>
<InformationalVersion>1.1.0-estimate.1+364.Branch.feature-estimate.Sha.3d6ab6e72f9ba4ccfa250a1cce154b550281405e</InformationalVersion> <InformationalVersion>1.1.0-estimate.1+343.Branch.feature-estimate.Sha.ba40698cc9562f0f4ad2dcad4dd20e638c05b07d</InformationalVersion>
<Version>1.1.0-estimate.1</Version> <Version>1.1.0-estimate.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>

View file

@ -41,8 +41,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PostIt.Tests", "src\PostIt\
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}" Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Api.Test", "src\Yavsc.Api.Test\Yavsc.Api.Test.csproj", "{49A42F0E-6E18-4617-9716-3E728966D96F}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Yavsc.Server.Tests", "src\Yavsc.Server.Tests\Yavsc.Server.Tests.csproj", "{899B7240-729A-48BC-A993-300A6CCFCBEA}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU Debug|Any CPU = Debug|Any CPU
@ -245,18 +243,6 @@ Global
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x64.Build.0 = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.ActiveCfg = Release|Any CPU
{49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU {49A42F0E-6E18-4617-9716-3E728966D96F}.Release|x86.Build.0 = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.ActiveCfg = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x64.Build.0 = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.ActiveCfg = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Debug|x86.Build.0 = Debug|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|Any CPU.Build.0 = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.ActiveCfg = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x64.Build.0 = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.ActiveCfg = Release|Any CPU
{899B7240-729A-48BC-A993-300A6CCFCBEA}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
@ -279,6 +265,5 @@ Global
{59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2} {59AF5DEA-D349-495A-BC44-FC7BD4E55099} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
{021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE} {021E6F5A-B81D-42A7-9918-DFE39B424FC2} = {E13D107F-4053-D0DE-6394-453609595BFE}
{49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F} {49A42F0E-6E18-4617-9716-3E728966D96F} = {76A7FBA6-B7EC-4864-85C0-1F470794BA0F}
{899B7240-729A-48BC-A993-300A6CCFCBEA} = {CDB1BDB5-53F9-4B43-864F-60F2E74F44E2}
EndGlobalSection EndGlobalSection
EndGlobal EndGlobal