Skip to content

Commit a74ff5c

Browse files
Merge pull request #46 from AndreaPrestia/feat/mobile-rn-port
# feat(mobile): porting React Native + semplificazione UI cross-platform (Phase 1-4)
2 parents 7dc1529 + 2293eb0 commit a74ff5c

203 files changed

Lines changed: 35346 additions & 2384 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/ci.yml

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,28 @@ jobs:
6161
VITE_API_BASE_URL: /api
6262
run: npm run build
6363

64+
mobile:
65+
name: Mobile (typecheck + jest)
66+
runs-on: ubuntu-latest
67+
steps:
68+
- uses: actions/checkout@v4
69+
70+
- name: Setup Node 22
71+
uses: actions/setup-node@v4
72+
with:
73+
node-version: '22'
74+
75+
- name: Install monorepo deps
76+
run: npm ci --legacy-peer-deps
77+
78+
- name: Typecheck mobile
79+
working-directory: mobile
80+
run: npm run typecheck
81+
82+
- name: Jest mobile
83+
working-directory: mobile
84+
run: npm test -- --ci --reporters=default
85+
6486
e2e:
6587
name: E2E (Playwright)
6688
runs-on: ubuntu-latest
@@ -82,17 +104,22 @@ jobs:
82104
run: docker compose up -d --build
83105

84106
- name: Attendi che backend e frontend rispondano
107+
env:
108+
# Serve anche qui: il fallback esegue `docker compose logs` che re-interpola compose.yml.
109+
Encryption__MasterKey: ${{ secrets.E2E_ENCRYPTION_KEY || 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' }}
85110
run: |
86-
for i in $(seq 1 60); do
87-
if curl -sf http://localhost:8080/health > /dev/null && curl -sf http://localhost:5173/ > /dev/null; then
88-
echo "Stack pronto."
111+
for i in $(seq 1 120); do
112+
if curl -sf http://localhost:8088/health > /dev/null && curl -sf http://localhost:5173/ > /dev/null; then
113+
echo "Stack pronto dopo $((i*2))s."
89114
exit 0
90115
fi
91116
sleep 2
92117
done
93-
echo "Stack non pronto in tempo." >&2
94-
docker compose logs --tail=80 backend
95-
docker compose logs --tail=40 frontend
118+
echo "Stack non pronto in tempo (240s)." >&2
119+
docker compose ps
120+
docker compose logs --tail=200 backend
121+
docker compose logs --tail=80 frontend
122+
docker compose logs --tail=40 db
96123
exit 1
97124
98125
- name: Install frontend deps
@@ -127,6 +154,9 @@ jobs:
127154

128155
- name: Dump container logs on failure
129156
if: failure()
157+
env:
158+
# Serve anche qui per la stessa ragione (docker compose logs re-interpola).
159+
Encryption__MasterKey: ${{ secrets.E2E_ENCRYPTION_KEY || 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=' }}
130160
run: |
131161
docker compose ps
132162
docker compose logs --tail=200 backend

.github/workflows/security.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ jobs:
3131
context: .
3232
dockerfile: ./backend/Dockerfile
3333
- name: frontend
34-
context: ./frontend
34+
context: .
3535
dockerfile: ./frontend/Dockerfile
3636
- name: web
3737
context: ./web

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

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using Accanto.Application.Auth;
44
using Accanto.Application.Auth.TwoFactor;
55
using Accanto.Application.Notifications;
6+
using Accanto.Application.Push;
67
using Accanto.Application.Security;
78
using Accanto.Application.Wellbeing;
89
using Accanto.Domain.Enums;
@@ -25,6 +26,8 @@ public class AccountController : ControllerBase
2526
private readonly ISecurityAuditLog _audit;
2627
private readonly ISecurityAuditQueryService _auditQuery;
2728
private readonly ICheckInService _checkIns;
29+
private readonly IDevicePushTokenService _devicePushTokens;
30+
private readonly ICircleMobilePushNotifier _mobilePush;
2831
private readonly ICurrentUser _currentUser;
2932

3033
public AccountController(
@@ -36,6 +39,8 @@ public AccountController(
3639
ISecurityAuditLog audit,
3740
ISecurityAuditQueryService auditQuery,
3841
ICheckInService checkIns,
42+
IDevicePushTokenService devicePushTokens,
43+
ICircleMobilePushNotifier mobilePush,
3944
ICurrentUser currentUser)
4045
{
4146
_svc = svc;
@@ -46,9 +51,10 @@ public AccountController(
4651
_audit = audit;
4752
_auditQuery = auditQuery;
4853
_checkIns = checkIns;
54+
_devicePushTokens = devicePushTokens;
55+
_mobilePush = mobilePush;
4956
_currentUser = currentUser;
5057
}
51-
5258
[HttpPost("change-password")]
5359
[EnableRateLimiting("auth-sensitive")]
5460
public async Task<IActionResult> ChangePassword([FromBody] ChangePasswordRequest request, CancellationToken ct)
@@ -174,6 +180,57 @@ public async Task<IActionResult> DeleteCheckIn(Guid id, CancellationToken ct)
174180
return NoContent();
175181
}
176182

183+
// ---------- Mobile push devices (Expo) ----------
184+
185+
[HttpGet("push-devices")]
186+
public async Task<ActionResult<IReadOnlyList<DevicePushTokenDto>>> ListPushDevices(CancellationToken ct)
187+
=> Ok(await _devicePushTokens.ListAsync(_currentUser.RequireUserId(), ct));
188+
189+
[HttpPost("push-devices")]
190+
public async Task<ActionResult<DevicePushTokenDto>> RegisterPushDevice([FromBody] RegisterDevicePushTokenRequest request, CancellationToken ct)
191+
{
192+
var dto = await _devicePushTokens.RegisterAsync(_currentUser.RequireUserId(), request, ct);
193+
return Ok(dto);
194+
}
195+
196+
[HttpDelete("push-devices/{id:guid}")]
197+
public async Task<IActionResult> DeletePushDeviceById(Guid id, CancellationToken ct)
198+
{
199+
await _devicePushTokens.RemoveByIdAsync(_currentUser.RequireUserId(), id, ct);
200+
return NoContent();
201+
}
202+
203+
/// <summary>
204+
/// Cancellazione "by token" usata dal client mobile in fase di
205+
/// logout: il device conosce il proprio token Expo ma non il GUID
206+
/// del record DB.
207+
/// </summary>
208+
[HttpDelete("push-devices")]
209+
public async Task<IActionResult> DeletePushDeviceByToken([FromBody] DeletePushDeviceRequest request, CancellationToken ct)
210+
{
211+
if (request is null || string.IsNullOrWhiteSpace(request.Token)) return BadRequest();
212+
await _devicePushTokens.RemoveByTokenAsync(_currentUser.RequireUserId(), request.Token, ct);
213+
return NoContent();
214+
}
215+
216+
/// <summary>
217+
/// Invia una notifica push di prova a tutti i device dell'utente
218+
/// corrente. Bypassa le preferenze (testare la connessione non deve
219+
/// essere silenziato dai topic flags). Utile per validare end-to-end
220+
/// la pipeline (token registrato, Expo push service, APNs/FCM).
221+
/// </summary>
222+
[HttpPost("push-devices/test")]
223+
[EnableRateLimiting("auth-sensitive")]
224+
public async Task<IActionResult> SendTestPush(CancellationToken ct)
225+
{
226+
await _mobilePush.SendTestAsync(
227+
_currentUser.RequireUserId(),
228+
"Accanto",
229+
"Notifica di prova: tutto funziona \ud83c\udf89",
230+
ct);
231+
return Accepted();
232+
}
233+
177234
private ClientInfo BuildClientInfo()
178235
{
179236
var ua = Request.Headers.UserAgent.ToString();

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,5 +51,9 @@ public class SharedUpdateTemplatesController : ControllerBase
5151
public SharedUpdateTemplatesController(ISharedUpdateTemplateProvider provider) { _provider = provider; }
5252

5353
[HttpGet]
54-
public ActionResult<IReadOnlyList<SharedUpdateTemplateDto>> Get() => Ok(_provider.GetTemplates());
54+
public ActionResult<IReadOnlyList<SharedUpdateTemplateDto>> Get()
55+
{
56+
var acceptLanguage = Request.Headers.AcceptLanguage.ToString();
57+
return Ok(_provider.GetTemplates(acceptLanguage));
58+
}
5559
}

backend/src/Accanto.Api/Middleware/RequireTwoFactorForOwnersMiddleware.cs

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,14 @@ public sealed class RequireTwoFactorForOwnersMiddleware
2323
{
2424
// Path che restano accessibili anche con 2FA-required scaduto. Match per
2525
// prefisso, case-insensitive. Tenere il minimo indispensabile.
26+
//
27+
// NB: sia il path esatto (`/api/account/2fa` per la GET dello status)
28+
// sia il subtree (`/api/account/2fa/setup`, `/enable`, `/disable`, ecc.)
29+
// devono essere in bypass; altrimenti l'Owner scaduto non pu\u00f2 nemmeno
30+
// leggere lo stato per uscire dal blocco (deadlock UI).
2631
private static readonly string[] BypassPrefixes =
2732
{
28-
"/api/account/2fa/", // setup, enable, disable, status, recovery codes
33+
"/api/account/2fa", // stato (GET) + tutti i sub-path (setup, enable, ...)
2934
"/api/account/me", // serve per leggere lo stato e fare logout dal frontend
3035
"/api/auth/logout",
3136
"/api/auth/refresh",
@@ -146,8 +151,8 @@ private static async Task WriteForbiddenAsync(HttpContext ctx, DateTimeOffset de
146151
var pd = new ProblemDetails
147152
{
148153
Status = StatusCodes.Status403Forbidden,
149-
Title = "Autenticazione a due fattori obbligatoria.",
150-
Detail = $"In quanto Owner di almeno un care circle devi configurare 2FA. " +
154+
Title = "Verifica in due passaggi obbligatoria.",
155+
Detail = $"In quanto Owner di almeno un care circle devi attivare la verifica in due passaggi. " +
151156
$"Grace scaduta il {deadline:o}. Visita /account/security/2fa per attivarla.",
152157
Type = "https://accanto.care/errors/two-factor-required"
153158
};

backend/src/Accanto.Application/ApplicationServiceCollectionExtensions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
using Accanto.Application.Documents;
1010
using Accanto.Application.Invites;
1111
using Accanto.Application.Notifications;
12+
using Accanto.Application.Push;
1213
using Accanto.Application.Security;
1314
using Accanto.Application.SharedUpdates;
1415
using Accanto.Application.Timeline;
@@ -42,6 +43,7 @@ public static IServiceCollection AddAccantoApplication(this IServiceCollection s
4243
services.AddScoped<IAuditService, AuditService>();
4344
services.AddScoped<ISecurityAuditQueryService, SecurityAuditQueryService>();
4445
services.AddScoped<INotificationPreferenceService, NotificationPreferenceService>();
46+
services.AddScoped<IDevicePushTokenService, DevicePushTokenService>();
4547
services.AddScoped<ICheckInService, CheckInService>();
4648

4749
services.AddSingleton<IDoctorQuestionTemplateProvider, StaticDoctorQuestionTemplateProvider>();

backend/src/Accanto.Application/Common/Persistence/IAccantoDbContext.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ public interface IAccantoDbContext
1515
Microsoft.EntityFrameworkCore.DbSet<PushSubscription> PushSubscriptions { get; }
1616
Microsoft.EntityFrameworkCore.DbSet<AuditLogEntry> AuditLogEntries { get; }
1717
Microsoft.EntityFrameworkCore.DbSet<UserNotificationPreference> UserNotificationPreferences { get; }
18+
Microsoft.EntityFrameworkCore.DbSet<DevicePushToken> DevicePushTokens { get; }
1819
Microsoft.EntityFrameworkCore.DbSet<RefreshToken> RefreshTokens { get; }
1920
Microsoft.EntityFrameworkCore.DbSet<PasswordResetToken> PasswordResetTokens { get; }
2021
Microsoft.EntityFrameworkCore.DbSet<SecurityAuditLogEntry> SecurityAuditLogEntries { get; }

backend/src/Accanto.Application/DoctorQuestions/DoctorQuestionService.cs

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Accanto.Application.Common.Persistence;
55
using Accanto.Application.Common.Validation;
66
using Accanto.Application.Email;
7+
using Accanto.Application.Push;
78
using Accanto.Domain.Entities;
89
using Accanto.Domain.Enums;
910
using FluentValidation;
@@ -17,6 +18,7 @@ public class DoctorQuestionService : IDoctorQuestionService
1718
private readonly ICareCircleAuthorization _auth;
1819
private readonly IAuditLog _audit;
1920
private readonly ICircleEmailNotifier _email;
21+
private readonly ICircleMobilePushNotifier _mobilePush;
2022
private readonly IValidator<CreateDoctorQuestionRequest> _createValidator;
2123
private readonly IValidator<UpdateDoctorQuestionRequest> _updateValidator;
2224

@@ -25,13 +27,15 @@ public DoctorQuestionService(
2527
ICareCircleAuthorization auth,
2628
IAuditLog audit,
2729
ICircleEmailNotifier email,
30+
ICircleMobilePushNotifier mobilePush,
2831
IValidator<CreateDoctorQuestionRequest> createValidator,
2932
IValidator<UpdateDoctorQuestionRequest> updateValidator)
3033
{
3134
_db = db;
3235
_auth = auth;
3336
_audit = audit;
3437
_email = email;
38+
_mobilePush = mobilePush;
3539
_createValidator = createValidator;
3640
_updateValidator = updateValidator;
3741
}
@@ -97,6 +101,18 @@ public async Task<DoctorQuestionDto> UpdateAsync(Guid userId, Guid careCircleId,
97101
$"Domanda al medico aggiornata in {circleName}",
98102
EmailTemplates.DoctorQuestionAnswered(circleName, q.Question),
99103
CancellationToken.None);
104+
_ = _mobilePush.NotifyCircleAsync(
105+
careCircleId,
106+
userId,
107+
NotificationTopic.DoctorQuestionAnswered,
108+
circleName,
109+
"Una domanda al medico è stata aggiornata",
110+
new Dictionary<string, string>
111+
{
112+
["circleId"] = careCircleId.ToString(),
113+
["questionId"] = q.Id.ToString()
114+
},
115+
CancellationToken.None);
100116
}
101117

102118
return Map(q);

backend/src/Accanto.Application/Email/EmailTemplates.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,30 +39,30 @@ public static string PasswordChanged() =>
3939

4040
public static string TwoFactorRequiredForOwner(string circleName, DateTimeOffset deadlineUtc) =>
4141
Layout(
42-
"2FA obbligatorio per ruolo Owner",
42+
"Verifica in due passaggi obbligatoria per ruolo Owner",
4343
$"<p>Sei diventato Owner del cerchio di cura <strong>{H(circleName)}</strong>.</p>" +
44-
$"<p>Per ragioni di sicurezza l'autenticazione a due fattori (2FA) è obbligatoria " +
44+
$"<p>Per ragioni di sicurezza la verifica in due passaggi è obbligatoria " +
4545
$"per chi è Owner di almeno un care circle.</p>" +
4646
$"<p><strong>Hai tempo fino al {H(deadlineUtc.ToString("dd/MM/yyyy HH:mm"))} UTC</strong> " +
4747
$"per attivarla; dopo tale data l'accesso alle funzionalità del cerchio sarà bloccato " +
4848
$"finché non avrai completato la configurazione.</p>" +
49-
$"<p>Vai su <em>Account → Sicurezza → Autenticazione a due fattori</em> per attivarla " +
49+
$"<p>Vai su <em>Account → Verifica in due passaggi</em> per attivarla " +
5050
$"con la tua app authenticator preferita.</p>");
5151

5252
public static string TwoFactorEnabled() =>
5353
Layout(
54-
"2FA attivata",
55-
"<p>L'autenticazione a due fattori è stata appena attivata sul tuo account Accanto.</p>" +
54+
"Verifica in due passaggi attivata",
55+
"<p>La verifica in due passaggi è stata appena attivata sul tuo account Accanto.</p>" +
5656
"<p>Conserva i codici di recupero in un luogo sicuro e offline: ti serviranno se perdi " +
5757
"l'accesso alla tua app authenticator.</p>" +
58-
"<p>Se non sei stato tu, accedi subito e disattiva 2FA (serve la password) oppure " +
58+
"<p>Se non sei stato tu, accedi subito e disattivala (serve la password) oppure " +
5959
"contatta supporto.</p>");
6060

6161
public static string TwoFactorDisabled() =>
6262
Layout(
63-
"2FA disattivata",
64-
"<p>L'autenticazione a due fattori è stata appena disattivata sul tuo account Accanto.</p>" +
65-
"<p>Se non sei stato tu, accedi subito, cambia la password e riattiva 2FA il prima possibile.</p>");
63+
"Verifica in due passaggi disattivata",
64+
"<p>La verifica in due passaggi è stata appena disattivata sul tuo account Accanto.</p>" +
65+
"<p>Se non sei stato tu, accedi subito, cambia la password e riattivala il prima possibile.</p>");
6666

6767
private static string Layout(string title, string innerHtml) =>
6868
"<!doctype html><html><body style=\"font-family:Inter,Segoe UI,Arial,sans-serif;color:#222;max-width:560px;margin:0 auto;padding:24px\">" +

backend/src/Accanto.Application/Invites/InviteService.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Accanto.Application.Common.Exceptions;
66
using Accanto.Application.Common.Persistence;
77
using Accanto.Application.Email;
8+
using Accanto.Application.Push;
89
using Accanto.Domain.Entities;
910
using Accanto.Domain.Enums;
1011
using FluentValidation;
@@ -22,6 +23,7 @@ public class InviteService : IInviteService
2223
private readonly ICareCircleAuthorization _auth;
2324
private readonly IAuditLog _audit;
2425
private readonly ICircleEmailNotifier _email;
26+
private readonly ICircleMobilePushNotifier _mobilePush;
2527
private readonly IOwnerTwoFactorOnboarding _ownerOnboarding;
2628
private readonly IValidator<CreateInviteRequest> _createValidator;
2729

@@ -30,13 +32,15 @@ public InviteService(
3032
ICareCircleAuthorization auth,
3133
IAuditLog audit,
3234
ICircleEmailNotifier email,
35+
ICircleMobilePushNotifier mobilePush,
3336
IOwnerTwoFactorOnboarding ownerOnboarding,
3437
IValidator<CreateInviteRequest> createValidator)
3538
{
3639
_db = db;
3740
_auth = auth;
3841
_audit = audit;
3942
_email = email;
43+
_mobilePush = mobilePush;
4044
_ownerOnboarding = ownerOnboarding;
4145
_createValidator = createValidator;
4246
}
@@ -176,6 +180,16 @@ public async Task<Guid> AcceptAsync(Guid userId, string token, CancellationToken
176180
$"Nuova persona nel cerchio {circleName}",
177181
EmailTemplates.InviteAccepted(circleName, newMemberName),
178182
CancellationToken.None);
183+
_ = _mobilePush.NotifyUserAsync(
184+
ownerId,
185+
NotificationTopic.InviteAccepted,
186+
circleName,
187+
$"{newMemberName} è entrato nel cerchio",
188+
new Dictionary<string, string>
189+
{
190+
["circleId"] = invite.CareCircleId.ToString()
191+
},
192+
CancellationToken.None);
179193
}
180194

181195
return invite.CareCircleId;

0 commit comments

Comments
 (0)