Skip to content

Commit 41fb62a

Browse files
committed
feat(account): esportazione dati GDPR
1 parent b1a01a4 commit 41fb62a

9 files changed

Lines changed: 536 additions & 4 deletions

File tree

backend/src/Accanto.Api/Controllers/AccountController.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ public class AccountController : ControllerBase
1313
{
1414
private readonly IAccountService _svc;
1515
private readonly INotificationPreferenceService _prefs;
16+
private readonly IGdprExportService _export;
1617
private readonly ICurrentUser _currentUser;
1718

18-
public AccountController(IAccountService svc, INotificationPreferenceService prefs, ICurrentUser currentUser)
19+
public AccountController(IAccountService svc, INotificationPreferenceService prefs, IGdprExportService export, ICurrentUser currentUser)
1920
{
2021
_svc = svc;
2122
_prefs = prefs;
23+
_export = export;
2224
_currentUser = currentUser;
2325
}
2426

@@ -56,4 +58,11 @@ public async Task<IActionResult> UpdateLanguage([FromBody] UpdateLanguageRequest
5658
await _svc.UpdateLanguageAsync(_currentUser.RequireUserId(), request, ct);
5759
return NoContent();
5860
}
61+
62+
[HttpGet("export")]
63+
public async Task<IActionResult> Export(CancellationToken ct)
64+
{
65+
var result = await _export.ExportAsync(_currentUser.RequireUserId(), ct);
66+
return File(result.Content, "application/zip", result.FileName);
67+
}
5968
}
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
using System.IO.Compression;
2+
using System.Text;
3+
using System.Text.Json;
4+
using Accanto.Application.Audit;
5+
using Accanto.Application.Common.Exceptions;
6+
using Accanto.Application.Common.Persistence;
7+
using Accanto.Application.Common.Storage;
8+
using Accanto.Domain.Enums;
9+
using Microsoft.EntityFrameworkCore;
10+
11+
namespace Accanto.Application.Account;
12+
13+
/// <summary>
14+
/// Esporta in un singolo archivio ZIP tutti i dati personali dell'utente: profilo,
15+
/// preferenze, voci di diario / domande / aggiornamenti / documenti caricati,
16+
/// audit log delle proprie azioni e metadati dei cerchi a cui partecipa. I documenti
17+
/// vengono inclusi in chiaro nella cartella "documents/".
18+
/// </summary>
19+
public class GdprExportService : IGdprExportService
20+
{
21+
private static readonly JsonSerializerOptions JsonOptions = new()
22+
{
23+
WriteIndented = true,
24+
DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull
25+
};
26+
27+
private readonly IAccantoDbContext _db;
28+
private readonly IFileStorage _storage;
29+
private readonly IAuditLog _audit;
30+
31+
public GdprExportService(IAccantoDbContext db, IFileStorage storage, IAuditLog audit)
32+
{
33+
_db = db;
34+
_storage = storage;
35+
_audit = audit;
36+
}
37+
38+
public async Task<GdprExportResult> ExportAsync(Guid userId, CancellationToken cancellationToken = default)
39+
{
40+
var user = await _db.Users.FirstOrDefaultAsync(u => u.Id == userId, cancellationToken)
41+
?? throw new NotFoundException("Utente non trovato.");
42+
43+
var memberCircleIds = await _db.CareCircleMembers
44+
.Where(m => m.UserId == userId)
45+
.Select(m => m.CareCircleId)
46+
.ToListAsync(cancellationToken);
47+
48+
var circles = await _db.CareCircles
49+
.Where(c => memberCircleIds.Contains(c.Id))
50+
.OrderBy(c => c.CreatedAt)
51+
.ToListAsync(cancellationToken);
52+
53+
var timeline = await _db.TimelineEntries
54+
.Where(t => t.CreatedByUserId == userId)
55+
.OrderBy(t => t.CreatedAt)
56+
.ToListAsync(cancellationToken);
57+
58+
var documents = await _db.MedicalDocuments
59+
.Where(d => d.UploadedByUserId == userId)
60+
.OrderBy(d => d.CreatedAt)
61+
.ToListAsync(cancellationToken);
62+
63+
var questions = await _db.DoctorQuestions
64+
.Where(q => q.CreatedByUserId == userId)
65+
.OrderBy(q => q.CreatedAt)
66+
.ToListAsync(cancellationToken);
67+
68+
var updates = await _db.SharedUpdates
69+
.Where(s => s.CreatedByUserId == userId)
70+
.OrderBy(s => s.CreatedAt)
71+
.ToListAsync(cancellationToken);
72+
73+
var auditEntries = await _db.AuditLogEntries
74+
.Where(a => a.PerformedByUserId == userId)
75+
.OrderBy(a => a.Timestamp)
76+
.ToListAsync(cancellationToken);
77+
78+
var prefs = await _db.UserNotificationPreferences
79+
.Where(p => p.UserId == userId)
80+
.OrderBy(p => p.Topic)
81+
.ToListAsync(cancellationToken);
82+
83+
var generatedAt = DateTimeOffset.UtcNow;
84+
85+
var profilePayload = new
86+
{
87+
user.Id,
88+
user.Email,
89+
user.DisplayName,
90+
user.Language,
91+
user.CreatedAt,
92+
ExportedAt = generatedAt
93+
};
94+
95+
var circlesPayload = circles.Select(c => new
96+
{
97+
c.Id,
98+
c.Name,
99+
c.Description,
100+
Status = c.Status.ToString(),
101+
c.CreatedAt,
102+
c.UpdatedAt
103+
});
104+
105+
var timelinePayload = timeline.Select(t => new
106+
{
107+
t.Id,
108+
t.CareCircleId,
109+
t.OccurredAt,
110+
Type = t.Type.ToString(),
111+
t.Title,
112+
t.Content,
113+
t.Tags,
114+
Visibility = t.Visibility.ToString(),
115+
t.CreatedAt,
116+
t.UpdatedAt
117+
});
118+
119+
var documentsPayload = documents.Select(d => new
120+
{
121+
d.Id,
122+
d.CareCircleId,
123+
d.OriginalFileName,
124+
d.ContentType,
125+
d.SizeInBytes,
126+
Category = d.Category.ToString(),
127+
d.Notes,
128+
d.Tags,
129+
d.CreatedAt,
130+
ArchivePath = $"documents/{d.Id}-{SanitizeFileName(d.OriginalFileName)}"
131+
});
132+
133+
var questionsPayload = questions.Select(q => new
134+
{
135+
q.Id,
136+
q.CareCircleId,
137+
q.Question,
138+
Category = q.Category.ToString(),
139+
Status = q.Status.ToString(),
140+
q.AnswerNotes,
141+
q.CreatedAt,
142+
q.UpdatedAt
143+
});
144+
145+
var updatesPayload = updates.Select(s => new
146+
{
147+
s.Id,
148+
s.CareCircleId,
149+
Audience = s.Audience.ToString(),
150+
s.Content,
151+
s.CreatedAt
152+
});
153+
154+
var auditPayload = auditEntries.Select(a => new
155+
{
156+
a.Id,
157+
a.CareCircleId,
158+
ActionType = a.ActionType.ToString(),
159+
ResourceType = a.ResourceType.ToString(),
160+
a.ResourceId,
161+
a.Summary,
162+
a.Timestamp
163+
});
164+
165+
var prefsPayload = prefs.Select(p => new
166+
{
167+
Topic = p.Topic.ToString(),
168+
p.EmailEnabled,
169+
p.UpdatedAt
170+
});
171+
172+
using var buffer = new MemoryStream();
173+
using (var zip = new ZipArchive(buffer, ZipArchiveMode.Create, leaveOpen: true))
174+
{
175+
await WriteJsonEntryAsync(zip, "profile.json", profilePayload, cancellationToken);
176+
await WriteJsonEntryAsync(zip, "care-circles.json", circlesPayload, cancellationToken);
177+
await WriteJsonEntryAsync(zip, "timeline.json", timelinePayload, cancellationToken);
178+
await WriteJsonEntryAsync(zip, "documents.json", documentsPayload, cancellationToken);
179+
await WriteJsonEntryAsync(zip, "doctor-questions.json", questionsPayload, cancellationToken);
180+
await WriteJsonEntryAsync(zip, "shared-updates.json", updatesPayload, cancellationToken);
181+
await WriteJsonEntryAsync(zip, "audit-log.json", auditPayload, cancellationToken);
182+
await WriteJsonEntryAsync(zip, "notification-preferences.json", prefsPayload, cancellationToken);
183+
await WriteTextEntryAsync(zip, "README.txt", BuildReadme(user.DisplayName, generatedAt), cancellationToken);
184+
185+
foreach (var doc in documents)
186+
{
187+
cancellationToken.ThrowIfCancellationRequested();
188+
try
189+
{
190+
await using var src = await _storage.OpenReadAsync(doc.StoragePath, cancellationToken);
191+
var entry = zip.CreateEntry($"documents/{doc.Id}-{SanitizeFileName(doc.OriginalFileName)}", CompressionLevel.Optimal);
192+
await using var dst = entry.Open();
193+
await src.CopyToAsync(dst, cancellationToken);
194+
}
195+
catch (FileNotFoundException)
196+
{
197+
// File mancante sul filesystem: lasciamo solo il metadato in documents.json.
198+
}
199+
}
200+
}
201+
202+
var bytes = buffer.ToArray();
203+
var fileName = $"accanto-export-{generatedAt:yyyyMMdd-HHmmss}.zip";
204+
205+
// Audit: una entry per cerchio dell'utente, così la traccia è visibile a tutti i membri.
206+
foreach (var circleId in memberCircleIds)
207+
{
208+
await _audit.LogAsync(
209+
circleId,
210+
userId,
211+
AuditActionType.DataExported,
212+
AuditResourceType.CareCircle,
213+
circleId,
214+
"Esportazione dati GDPR",
215+
cancellationToken);
216+
}
217+
218+
return new GdprExportResult(fileName, bytes);
219+
}
220+
221+
private static async Task WriteJsonEntryAsync<T>(ZipArchive zip, string path, T payload, CancellationToken ct)
222+
{
223+
var entry = zip.CreateEntry(path, CompressionLevel.Optimal);
224+
await using var stream = entry.Open();
225+
await JsonSerializer.SerializeAsync(stream, payload, JsonOptions, ct);
226+
}
227+
228+
private static async Task WriteTextEntryAsync(ZipArchive zip, string path, string content, CancellationToken ct)
229+
{
230+
var entry = zip.CreateEntry(path, CompressionLevel.Optimal);
231+
await using var stream = entry.Open();
232+
var bytes = Encoding.UTF8.GetBytes(content);
233+
await stream.WriteAsync(bytes, ct);
234+
}
235+
236+
private static string BuildReadme(string displayName, DateTimeOffset generatedAt)
237+
{
238+
return
239+
$@"Esportazione dati Accanto
240+
==========================
241+
242+
Intestatario: {displayName}
243+
Generata il : {generatedAt:O}
244+
245+
Contenuto:
246+
- profile.json Profilo utente e lingua preferita.
247+
- care-circles.json Cerchi di cura di cui sei membro.
248+
- timeline.json Voci di diario che hai creato.
249+
- documents.json Metadati dei documenti che hai caricato.
250+
- documents/ File originali in chiaro (corrispondenti a documents.json).
251+
- doctor-questions.json Domande per il medico che hai creato.
252+
- shared-updates.json Aggiornamenti condivisi che hai pubblicato.
253+
- audit-log.json Tracce delle azioni che hai compiuto.
254+
- notification-preferences.json Preferenze per le notifiche email.
255+
";
256+
}
257+
258+
private static string SanitizeFileName(string name)
259+
{
260+
if (string.IsNullOrWhiteSpace(name)) return "file";
261+
var invalid = Path.GetInvalidFileNameChars();
262+
var sb = new StringBuilder(name.Length);
263+
foreach (var c in name)
264+
{
265+
sb.Append(Array.IndexOf(invalid, c) >= 0 ? '_' : c);
266+
}
267+
var clean = sb.ToString().Trim();
268+
return clean.Length == 0 ? "file" : clean;
269+
}
270+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
namespace Accanto.Application.Account;
2+
3+
public interface IGdprExportService
4+
{
5+
Task<GdprExportResult> ExportAsync(Guid userId, CancellationToken cancellationToken = default);
6+
}
7+
8+
public sealed record GdprExportResult(string FileName, byte[] Content);

backend/src/Accanto.Application/ApplicationServiceCollectionExtensions.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public static IServiceCollection AddAccantoApplication(this IServiceCollection s
2121

2222
services.AddScoped<IAuthService, AuthService>();
2323
services.AddScoped<IAccountService, AccountService>();
24+
services.AddScoped<IGdprExportService, GdprExportService>();
2425
services.AddScoped<ICareCircleService, CareCircleService>();
2526
services.AddScoped<ITimelineService, TimelineService>();
2627
services.AddScoped<IDocumentService, DocumentService>();

0 commit comments

Comments
 (0)