Gestion des fichiers utilisateur et pièces jointes (demande/devis)

Espace de stockage personnel géré depuis PostIt, avec attachement par
référence (sans duplication) des fichiers à la demande du client
(NominativeServiceCommand) et au devis du fournisseur (Estimate).

Backend:
- UploadedFile.OwnerId (FK ApplicationUser, nullable) ; les téléversements
  vers api/v1/fs créent/mettent à jour une ligne propriétaire.
- Tables de jointure EstimateAttachedFile et QueryAttachedFile (PK composite),
  migration UserFileAttachments.
- EstimateApiController: GET/POST/DELETE estimate/{id}/attachments
  (fournisseur uniquement, fichier devant appartenir au caller).
- FrontOfficeApiController: GET/POST/DELETE front/query/{id}/attachments
  (client uniquement).
- FileSystemApiController: GET fs/file/{fileId} sert les octets depuis le
  host Blogs (autorisation propriétaire ou partie au devis/demande via DB
  partagée) — le host API ne gère que les métadonnées, contrainte des
  conteneurs docker à disques distincts.

Client .NET:
- UserFilesApiClient (list/upload/delete/download) pointant vers BlogsApiUrl.
- DTOs UserDirectoryDto/FileEntryDto/AttachmentDto/FileReceivedDto/UploadFile.
- Méthodes d'attachement sur EstimateApiClient et FrontOfficeApiClient.

PostIt (Avalonia):
- Page « Mes fichiers » (naviguer/téléverser/télécharger/supprimer), servant
  aussi de sélecteur pour l'attachement.
- Section pièces jointes dans l'édition du devis (fournisseur) et le détail
  de la demande (client), avec ajout depuis My Files, détachement,
  téléchargement.
- Enregistrement DI + mapping ViewLocator + bouton d'accès.

Tests:
- EstimateEditionPageViewModelTests mis à jour (nouveau ctor fsClient,
  historique d'appels du stub pour les assertions post-envoi).

Nommage HTTP/méthodes en anglais, documentation en français.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Paul Schneider 2026-09-24 16:35:36 +01:00
commit 70ab05522b
Signed by: notazof
GPG key ID: AD4B9B545FBD7D12
31 changed files with 6515 additions and 20 deletions

View file

@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Server.Helpers;
namespace Yavsc.Controllers
@ -237,6 +238,96 @@ namespace Yavsc.Controllers
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)
{
return _context.Estimates.Count(e => e.Id == id) > 0;

View file

@ -8,6 +8,7 @@ using Yavsc.Abstract.Workflow;
using Yavsc.Helpers;
using Yavsc.Models;
using Yavsc.Models.Billing;
using Yavsc.Models.Blog;
using Yavsc.Services;
using Yavsc.Server.Helpers;
using Yavsc.ViewModels.FrontOffice;
@ -366,6 +367,95 @@ namespace Yavsc.ApiControllers
private static bool HasSignature(QueryAcceptanceRequest? body)
=> 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 }