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:
parent
eb58fc8862
commit
70ab05522b
31 changed files with 6515 additions and 20 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
Loading…
Reference in a new issue