From 1d27935dca3453c98e8ed627f5ae274bd28e46ad Mon Sep 17 00:00:00 2001 From: Clotilde DESQUILBET Date: Fri, 14 Aug 2026 13:54:29 +0200 Subject: [PATCH 1/5] user: add internal endpoint to get consents (#911) --- ami/user/api_urls.py | 3 ++- ami/user/api_views.py | 8 ++++++++ ami/user/serializers.py | 6 ++++++ ami/user/tests/test_consent.py | 22 ++++++++++++++++++++++ 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/ami/user/api_urls.py b/ami/user/api_urls.py index 32e8c3049..91d29575f 100644 --- a/ami/user/api_urls.py +++ b/ami/user/api_urls.py @@ -1,9 +1,10 @@ from django.urls import path -from .api_views import consent, registrations, unregister +from .api_views import consent, consents, registrations, unregister urlpatterns = [ path("users/registrations", registrations), path("users/registrations/", unregister), path("consent/", consent), + path("users/consents", consents), ] diff --git a/ami/user/api_views.py b/ami/user/api_views.py index 2d608ffc3..f3ee5d49b 100644 --- a/ami/user/api_views.py +++ b/ami/user/api_views.py @@ -21,6 +21,7 @@ ConsentPostResponseSerializer, ConsentPostSerializer, ConsentResponseSerializer, + ConsentSerializer, MobileAppSubscriptionSerializer, RegistrationCreateSerializer, RegistrationSerializer, @@ -156,3 +157,10 @@ def consent(request: Request, fc_hash: str) -> Response: {"message": "Consent given" if data["consent"] else "Consent withdrawn"} ) return Response(response_serializer.data) + + +@api_view(["GET"]) +@ami_login_required +def consents(request: Request) -> Response: + consents_qs: QuerySet[Consent] = request.ami_user.consent_set.all() + return Response(ConsentSerializer(consents_qs, many=True).data) diff --git a/ami/user/serializers.py b/ami/user/serializers.py index 1f2b7598b..385e7464b 100644 --- a/ami/user/serializers.py +++ b/ami/user/serializers.py @@ -48,3 +48,9 @@ class ConsentPostSerializer(serializers.Serializer): class ConsentPostResponseSerializer(serializers.Serializer): message = serializers.ChoiceField(choices=["Consent given", "Consent withdrawn"]) + + +class ConsentSerializer(serializers.Serializer): + id = serializers.UUIDField() + partner_id = serializers.CharField() + consent_datetime = serializers.DateTimeField() diff --git a/ami/user/tests/test_consent.py b/ami/user/tests/test_consent.py index 08b2fbe50..b2bd5af5c 100644 --- a/ami/user/tests/test_consent.py +++ b/ami/user/tests/test_consent.py @@ -4,6 +4,7 @@ import pytest from django.utils.timezone import now +from ami.tests.utils import assert_query_fails_without_auth, login from ami.user.models import Consent, User @@ -165,3 +166,24 @@ def test_post_consent_without_auth(app, settings) -> None: b64 = base64.b64encode("dinum-ami:foo".encode("utf8")).decode("utf8") app.post("/api/v1/consent/fake-fc-hash", headers={"authorization": f"Basic {b64}"}, status=401) + + +@pytest.mark.django_db +def test_consents(app, user: User) -> None: + login(app, user) + + consent_datetime = datetime.datetime(2020, 12, 25, 17, 5, 55, tzinfo=datetime.timezone.utc) + consent = Consent.objects.create(user=user, partner_id="psl", consent_datetime=consent_datetime) + + response = app.get("/api/v1/users/consents", status=200) + consents = response.json + assert len(consents) == 1 + assert set(response.json[0].keys()) == {"consent_datetime", "id", "partner_id"} + assert response.json[0]["id"] == str(consent.id) + assert response.json[0]["partner_id"] == consent.partner_id + assert response.json[0]["consent_datetime"] == "2020-12-25T17:05:55Z" + + +@pytest.mark.django_db +def test_consents_without_auth(app) -> None: + assert_query_fails_without_auth(app, "/api/v1/users/consents") From 6bf6adbdbb5ae7ff662e20ec101d308fe78aaa5f Mon Sep 17 00:00:00 2001 From: Clotilde DESQUILBET Date: Wed, 26 Aug 2026 14:46:47 +0200 Subject: [PATCH 2/5] user: add internal endpoint to update consent (#911) --- ami/user/api_views.py | 33 ++++++++++++++-- ami/user/serializers.py | 5 +++ ami/user/tests/test_consent.py | 72 +++++++++++++++++++++++++++++++++- 3 files changed, 105 insertions(+), 5 deletions(-) diff --git a/ami/user/api_views.py b/ami/user/api_views.py index f3ee5d49b..7b992d04c 100644 --- a/ami/user/api_views.py +++ b/ami/user/api_views.py @@ -22,6 +22,7 @@ ConsentPostSerializer, ConsentResponseSerializer, ConsentSerializer, + ConsentUpdateSerializer, MobileAppSubscriptionSerializer, RegistrationCreateSerializer, RegistrationSerializer, @@ -159,8 +160,34 @@ def consent(request: Request, fc_hash: str) -> Response: return Response(response_serializer.data) -@api_view(["GET"]) +@extend_schema( + methods=["POST"], + request=ConsentUpdateSerializer, +) +@api_view(["GET", "POST"]) @ami_login_required def consents(request: Request) -> Response: - consents_qs: QuerySet[Consent] = request.ami_user.consent_set.all() - return Response(ConsentSerializer(consents_qs, many=True).data) + if request.method == "GET": + consents_qs: QuerySet[Consent] = request.ami_user.consent_set.all() + return Response(ConsentSerializer(consents_qs, many=True).data) + + serializer = ConsentUpdateSerializer(data=request.data) + try: + serializer.is_valid(raise_exception=True) + except serializers.ValidationError: + logger.exception("Internal post consent serialization error") + raise + data: dict = cast(dict, serializer.validated_data) + + consent_datetime = now() if data["consent"] else None + Consent.objects.update_or_create( + user=request.ami_user, + partner_id=data["partner_id"], + defaults={"consent_datetime": consent_datetime}, + create_defaults={"consent_datetime": consent_datetime}, + ) + + response_serializer = ConsentPostResponseSerializer( + {"message": "Consent given" if data["consent"] else "Consent withdrawn"} + ) + return Response(response_serializer.data) diff --git a/ami/user/serializers.py b/ami/user/serializers.py index 385e7464b..7f7bdfe5d 100644 --- a/ami/user/serializers.py +++ b/ami/user/serializers.py @@ -54,3 +54,8 @@ class ConsentSerializer(serializers.Serializer): id = serializers.UUIDField() partner_id = serializers.CharField() consent_datetime = serializers.DateTimeField() + + +class ConsentUpdateSerializer(serializers.Serializer): + partner_id = serializers.CharField() + consent = serializers.BooleanField() diff --git a/ami/user/tests/test_consent.py b/ami/user/tests/test_consent.py index b2bd5af5c..cca82d866 100644 --- a/ami/user/tests/test_consent.py +++ b/ami/user/tests/test_consent.py @@ -169,7 +169,7 @@ def test_post_consent_without_auth(app, settings) -> None: @pytest.mark.django_db -def test_consents(app, user: User) -> None: +def test_get_consents(app, user: User) -> None: login(app, user) consent_datetime = datetime.datetime(2020, 12, 25, 17, 5, 55, tzinfo=datetime.timezone.utc) @@ -185,5 +185,73 @@ def test_consents(app, user: User) -> None: @pytest.mark.django_db -def test_consents_without_auth(app) -> None: +def test_get_consents_without_auth(app) -> None: assert_query_fails_without_auth(app, "/api/v1/users/consents") + + +@pytest.mark.django_db +def test_post_consents( + app, + two_users: list[User], +) -> None: + login(app, two_users[0]) + + Consent.objects.create(user=two_users[0], partner_id="psl", consent_datetime=now()) + Consent.objects.create(user=two_users[1], partner_id="dinum-ami", consent_datetime=now()) + + data = {"partner_id": "dinum-ami", "consent": True} + response = app.post_json("/api/v1/users/consents", data) + assert response.json == {"message": "Consent given"} + assert Consent.objects.count() == 3 + consent = Consent.objects.latest("created_at") + assert consent.user == two_users[0] + assert consent.partner_id == "dinum-ami" + assert consent.consent_datetime is not None + + data = {"partner_id": "dinum-ami", "consent": False} + response = app.post_json("/api/v1/users/consents", data) + assert response.json == {"message": "Consent withdrawn"} + assert Consent.objects.count() == 3 + consent.refresh_from_db() + assert consent.user == two_users[0] + assert consent.partner_id == "dinum-ami" + assert consent.consent_datetime is None + + +@pytest.mark.django_db +def test_post_consents_user_consent_invalid( + app, + user: User, +) -> None: + login(app, user) + + data = {"partner_id": "dinum-ami"} + response = app.post_json("/api/v1/users/consents", data, status=400) + assert response.json == {"consent": ["Ce champ est obligatoire."]} + assert Consent.objects.count() == 0 + assert User.objects.count() == 1 + + data = {"partner_id": "dinum-ami", "consent": "invalid"} + response = app.post_json("/api/v1/users/consents", data, status=400) + assert response.json == {"consent": ["Must be a valid boolean."]} + assert Consent.objects.count() == 0 + assert User.objects.count() == 1 + + +@pytest.mark.django_db +def test_post_consents_without_auth(app, settings) -> None: + app.post("/api/v1/users/consents", status=401) + + app.post("/api/v1/users/consents", headers={"authorization": "foo"}, status=401) + + app.post("/api/v1/users/consents", headers={"authorization": "Foo bar"}, status=401) + + app.post("/api/v1/users/consents", headers={"authorization": "Basic bar"}, status=401) + + b64 = base64.b64encode(f"foo:{settings.PARTNERS_DINUM_AMI_SECRET}".encode("utf8")).decode( + "utf8" + ) + app.post("/api/v1/users/consents", headers={"authorization": f"Basic {b64}"}, status=401) + + b64 = base64.b64encode("dinum-ami:foo".encode("utf8")).decode("utf8") + app.post("/api/v1/users/consents", headers={"authorization": f"Basic {b64}"}, status=401) From ea94fda4b518b6d4644249888c263659ab40557d Mon Sep 17 00:00:00 2001 From: Clotilde DESQUILBET Date: Fri, 28 Aug 2026 15:47:39 +0200 Subject: [PATCH 3/5] front: consent (#911) --- .../src/lib/ConnectedHomepage.svelte | 65 ++++--- .../src/lib/ConnectedHomepage.svelte.test.ts | 49 ++++- .../mobile-app/src/lib/api-consents.test.ts | 117 +++++++++++ public/mobile-app/src/lib/api-consents.ts | 47 +++++ .../src/lib/components/Followup.svelte | 182 +++++++++++++----- .../lib/components/Followup.svelte.test.ts | 81 +++++++- .../lib/components/FollowupInformation.svelte | 56 ++++++ .../lib/components/FollowupNoConsent.svelte | 38 ++++ .../src/lib/components/Toggle.svelte | 7 +- public/mobile-app/src/lib/consents.test.ts | 151 +++++++++++++++ public/mobile-app/src/lib/consents.ts | 76 ++++++++ .../followup/archived/page.svelte.test.ts | 2 + .../src/routes/followup/page.svelte.test.ts | 2 + .../src/routes/preferences/+page.svelte | 14 +- .../routes/preferences/consents/+page.svelte | 73 +++++++ .../preferences/consents/page.svelte.test.ts | 84 ++++++++ 16 files changed, 962 insertions(+), 82 deletions(-) create mode 100644 public/mobile-app/src/lib/api-consents.test.ts create mode 100644 public/mobile-app/src/lib/api-consents.ts create mode 100644 public/mobile-app/src/lib/components/FollowupInformation.svelte create mode 100644 public/mobile-app/src/lib/components/FollowupNoConsent.svelte create mode 100644 public/mobile-app/src/lib/consents.test.ts create mode 100644 public/mobile-app/src/lib/consents.ts create mode 100644 public/mobile-app/src/routes/preferences/consents/+page.svelte create mode 100644 public/mobile-app/src/routes/preferences/consents/page.svelte.test.ts diff --git a/public/mobile-app/src/lib/ConnectedHomepage.svelte b/public/mobile-app/src/lib/ConnectedHomepage.svelte index 2587a563f..3ea03e975 100644 --- a/public/mobile-app/src/lib/ConnectedHomepage.svelte +++ b/public/mobile-app/src/lib/ConnectedHomepage.svelte @@ -7,9 +7,10 @@ import AutoPromoCarousel from '$lib/components/AutoPromo.svelte'; import AutoPromoItem from '$lib/components/AutoPromoItem.svelte'; import FollowupItem from '$lib/components/FollowupItem.svelte'; + import FollowupNoConsent from '$lib/components/FollowupNoConsent.svelte'; import AgendaItemModal from '$lib/components/modal/AgendaItemModal.svelte'; - import CenteredModal from '$lib/components/modal/CenteredModal.svelte'; import FollowupItemModal from '$lib/components/modal/FollowupItemModal.svelte'; + import { buildConsents, hasAnyConsents as hasAnyConsentsFunc } from '$lib/consents'; import type { Followup, FollowupItem as FollowupItemType } from '$lib/followup'; import { buildFollowup } from '$lib/followup'; import { @@ -29,6 +30,7 @@ let selectedAgendaItem: AgendaItemType | null = $state(null); let selectedFollowupItem: FollowupItemType | null = $state(null); let autoPromo: AutoPromo | null = $state(null); + let hasAnyConsents: boolean = $state(false); onMount(async () => { console.log('User is connected:', userStore.connected); @@ -65,6 +67,8 @@ followup = await buildFollowup(); console.log($state.snapshot(followup)); isFollowupEmpty = !followup.items.length; + await buildConsents(); + hasAnyConsents = await hasAnyConsentsFunc(); } catch (error) { console.error(error); } @@ -156,20 +160,43 @@ {/if} -
- {#if isFollowupEmpty} -
-

Mes démarches

-
-
-
-
- + {#if hasAnyConsents} +
+ {#if isFollowupEmpty} +
+

Mes démarches

+
+
+
+
+ +
+
Suivez vos démarches ici.
-
Retrouvez et suivez vos démarches ici.
-
- {:else} + {:else} +
+

Mes démarches

+ +
+
+ {#if followup && followup.items.length} + {@const firstItem = followup.items[0]} + openFollowupItemModal(firstItem)} + /> + {/if} +
+ {/if} +
+ {:else} +

Mes démarches

- {#if followup && followup.items.length} - {@const firstItem = followup.items[0]} - openFollowupItemModal(firstItem)} - /> - {/if} +
- {/if} -
+
+ {/if}
{#if selectedAgendaItem} diff --git a/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts b/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts index 8b6900c0f..a953a6c9f 100644 --- a/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts +++ b/public/mobile-app/src/lib/ConnectedHomepage.svelte.test.ts @@ -5,8 +5,11 @@ import type { WS as WSType } from 'vitest-websocket-mock'; import WS from 'vitest-websocket-mock'; import * as agendaMethods from '$lib/agenda'; import { Agenda, Item } from '$lib/agenda'; +import type { APIConsents, APIConsentsItem } from '$lib/api-consents'; import * as autoPromoMethods from '$lib/auto-promo'; import { AutoPromo, AutoPromoItem } from '$lib/auto-promo'; +import * as consentsMethods from '$lib/consents'; +import { Consents } from '$lib/consents'; import * as followupMethods from '$lib/followup'; import { Followup, FollowupItem } from '$lib/followup'; import * as notificationsMethods from '$lib/notifications'; @@ -46,6 +49,7 @@ describe('/ConnectedHomepage.svelte', () => { new AutoPromo(new Agenda()) ); vi.spyOn(followupMethods, 'buildFollowup').mockResolvedValue(new Followup()); + vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(new Consents()); window.localStorage.setItem('notifications_enabled', 'false'); window.localStorage.setItem('user_data', 'fake-user-data'); @@ -345,7 +349,20 @@ describe('/ConnectedHomepage.svelte', () => { }); }); - describe('Followup block', () => { + describe('Followup block - when user has consented', () => { + beforeEach(async () => { + const apiConsentsItem: APIConsentsItem = { + partner_id: 'fake-partner-id', + consent_datetime: new Date('2026-02-22T15:55:00.000Z'), + }; + const apiConsents: APIConsents = { + consents: [apiConsentsItem], + }; + const consents: Consents = new Consents(apiConsents); + vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(consents); + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('Should display first followup found from API', async () => { // Given const followup = new Followup(); @@ -464,9 +481,7 @@ describe('/ConnectedHomepage.svelte', () => { await waitFor(() => { const followupBlock = container.querySelector('.followup-container'); expect(spy).toHaveBeenCalledTimes(1); - expect(followupBlock).toHaveTextContent( - 'Retrouvez et suivez vos démarches ici.' - ); + expect(followupBlock).toHaveTextContent('Suivez vos démarches ici.'); }); }); @@ -724,4 +739,30 @@ describe('/ConnectedHomepage.svelte', () => { }); }); }); + + describe('Followup block - when user has not consented', () => { + beforeEach(async () => { + const apiConsents: APIConsents = { + consents: [], + }; + const consents: Consents = new Consents(apiConsents); + vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(consents); + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(false); + }); + + test('should display followup no consent block', async () => { + // When + const { container } = render(ConnectedHomepage); + + // Then + await waitFor(() => { + const followupNoConsentBlock = container.querySelector( + '.followup-no-consent-container' + ); + expect(followupNoConsentBlock).toHaveTextContent( + 'Suivez vos démarches administratives au même endroit !' + ); + }); + }); + }); }); diff --git a/public/mobile-app/src/lib/api-consents.test.ts b/public/mobile-app/src/lib/api-consents.test.ts new file mode 100644 index 000000000..c4a81e0c8 --- /dev/null +++ b/public/mobile-app/src/lib/api-consents.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { retrieveConsents, updateApiConsent } from '$lib/api-consents'; + +const apiConsents = { + consents: [ + { + partner_id: 'dinum-ami', + consent_datetime: '2026-01-23T15:50:00Z', + }, + { + partner_id: 'dinum-dn', + consent_datetime: '2026-01-22T14:55:00Z', + }, + ], +}; + +describe('/api-consents', () => { + afterEach(() => { + window.localStorage.clear(); + vi.clearAllMocks(); + }); + + describe('retrieveConsents', () => { + test('should get consents from API', async () => { + // Given + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue( + new Response(JSON.stringify(apiConsents.consents), { status: 200 }) + ); + + // When + const result = await retrieveConsents(); + + // Then + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents'); + expect(result.consents.length).toEqual(2); + expect(result.consents[0].partner_id).toEqual(apiConsents.consents[0].partner_id); + expect(result.consents[0].consent_datetime).toEqual( + apiConsents.consents[0].consent_datetime + ); + expect(result.consents[1].partner_id).toEqual(apiConsents.consents[1].partner_id); + expect(result.consents[1].consent_datetime).toEqual( + apiConsents.consents[1].consent_datetime + ); + }); + + test('should get consents items from API - with error', async () => { + // Given + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('error', { status: 400 })); + + // When + const result = await retrieveConsents(); + + // Then + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents'); + expect(result).toEqual({ consents: [] }); + }); + }); + + describe('updateApiConsent', () => { + test('should return true', async () => { + // Given + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + // When + const result = await updateApiConsent('dinum-ami', true); + + // Then + expect(result).toEqual(true); + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents', { + body: '{"partner_id":"dinum-ami","consent":true}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + }); + test('should return false: 400 error', async () => { + // Given + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 400 })); + + // When + const result = await updateApiConsent('dinum-ami', true); + + // Then + expect(result).toEqual(false); + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents', { + body: '{"partner_id":"dinum-ami","consent":true}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + }); + test('should return false: 500 error', async () => { + // Given + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 500 })); + + // When + const result = await updateApiConsent('dinum-ami', true); + + // Then + expect(result).toEqual(false); + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents', { + body: '{"partner_id":"dinum-ami","consent":true}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }); + }); + }); +}); diff --git a/public/mobile-app/src/lib/api-consents.ts b/public/mobile-app/src/lib/api-consents.ts new file mode 100644 index 000000000..4f6a6f26b --- /dev/null +++ b/public/mobile-app/src/lib/api-consents.ts @@ -0,0 +1,47 @@ +import { apiFetch } from '$lib/auth'; + +export type APIConsentsItem = { + partner_id: string; + consent_datetime: Date | null; +}; + +export type APIConsents = { + consents: APIConsentsItem[]; +}; + +export const retrieveConsents = async (): Promise => { + const apiConsents = { + consents: [] as APIConsentsItem[], + } as APIConsents; + + try { + const response = await apiFetch('/api/v1/users/consents'); + if (response.status === 200) { + apiConsents.consents = await response.json(); + } + } catch (error) { + console.error(error); + } + + return apiConsents; +}; + +export const updateApiConsent = async (partnerId: string, checked: boolean) => { + const payload = { + partner_id: partnerId, + consent: checked, + }; + try { + const response = await apiFetch(`/api/v1/users/consents`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (response.status === 200) { + return true; + } + } catch (error) { + console.error(error); + } + return false; +}; diff --git a/public/mobile-app/src/lib/components/Followup.svelte b/public/mobile-app/src/lib/components/Followup.svelte index f5a7c1a83..f0b669b16 100644 --- a/public/mobile-app/src/lib/components/Followup.svelte +++ b/public/mobile-app/src/lib/components/Followup.svelte @@ -2,8 +2,10 @@ import { onMount } from 'svelte'; import { goto } from '$app/navigation'; import FollowupItem from '$lib/components/FollowupItem.svelte'; + import FollowupNoConsent from '$lib/components/FollowupNoConsent.svelte'; import FollowupItemModal from '$lib/components/modal/FollowupItemModal.svelte'; import NavWithBackButton from '$lib/components/NavWithBackButton.svelte'; + import { hasAnyConsents as hasAnyConsentsFunc } from '$lib/consents'; import type { Followup, FollowupItem as FollowupItemType } from '$lib/followup'; import { buildFollowup } from '$lib/followup'; @@ -17,12 +19,23 @@ let followup: Followup | null = $state(null); let selectedFollowupItem: FollowupItemType | null = $state(null); let menuOpened: boolean = $state(false); + let hasAnyConsents: boolean = $state(false); + let isExpanded: boolean = $state(true); onMount(async () => { followup = await buildFollowup(); console.log($state.snapshot(followup)); + hasAnyConsents = await hasAnyConsentsFunc(); + isExpanded = expandAccordion(); }); + const expandAccordion = (): boolean => { + if (followup) { + return !archived && followup.items?.length === 0; + } + return false; + }; + const openFollowupItemModal = (item: FollowupItemType) => { selectedFollowupItem = item; }; @@ -34,6 +47,10 @@ const gotoArchivedFollowup = () => { goto('/#/followup/archived'); }; + + const gotoConsents = () => { + goto('/#/preferences/consents'); + }; {#if archived} @@ -44,19 +61,21 @@ {#if !archived}

Mes démarches

-
- -
+ Sous-menu + +
+ {/if} {#if menuOpened}
  • @@ -75,24 +94,90 @@ {/if}
    - {#if archived && followup && followup.archived_items.length} - {#each followup.archived_items as item} - openFollowupItemModal(item)} /> - {/each} - {:else if !archived && followup && followup.items.length} - {#each followup.items as item} - openFollowupItemModal(item)} /> - {/each} - {:else} + {#if hasAnyConsents} + {#if archived && followup && followup.archived_items.length} + {#each followup.archived_items as item} + openFollowupItemModal(item)} /> + {/each} + {:else if !archived && followup && followup.items.length} + {#each followup.items as item} + openFollowupItemModal(item)} /> + {/each} + {/if}
    -
    - -
    -
    - Après avoir effectué vos démarches, vous pouvez les suivre en temps réel - depuis l’application. -
    +
    +

    + +

    +
    +
    +

    Consultez votre compte

    +
      + + + + +
    +

    Vérifiez que vous suivez bien toutes vos démarches

    + +
    +
    +
    + {:else} + {/if}
@@ -152,26 +237,29 @@ .followup--container { display: flex; flex-direction: column; - &:has(div.no-followup) { - align-items: center; - justify-content: center; - height: calc(100vh - 15rem); - min-height: 10rem; - } .no-followup { - flex-direction: column; - text-align: center; - padding: 1rem; - display: flex; - font-size: 16px; - line-height: 24px; - color: var(--grey-0-1000); - img { - height: 5rem; - width: 5rem; - } - .no-followup--title { - text-align: left; + .fr-accordion { + .fr-accordion__btn { + font-size: 14px; + font-weight: 700; + } + .account { + list-style: none; + } + &:before { + border: solid 1px var(--border-default-blue-france); + box-shadow: none; + } + .consent-action-button { + display: flex; + flex-direction: column; + gap: 1rem; + button { + display: flex; + justify-content: center; + width: 100%; + } + } } } } diff --git a/public/mobile-app/src/lib/components/Followup.svelte.test.ts b/public/mobile-app/src/lib/components/Followup.svelte.test.ts index 1ebeb355e..c3858516e 100644 --- a/public/mobile-app/src/lib/components/Followup.svelte.test.ts +++ b/public/mobile-app/src/lib/components/Followup.svelte.test.ts @@ -1,13 +1,18 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; -import { describe, expect, test, vi } from 'vitest'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; import * as navigationMethods from '$app/navigation'; import FollowupComponent from '$lib/components/Followup.svelte'; +import * as consentsMethods from '$lib/consents'; import * as followupMethods from '$lib/followup'; import { Followup, FollowupItem } from '$lib/followup'; import { toastStore } from '$lib/state/toast.svelte'; describe('/Followup.svelte', () => { describe('Current items', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('Should display followup from API', async () => { // Given const followup = new Followup(); @@ -112,6 +117,9 @@ describe('/Followup.svelte', () => { expect(screen.getByTestId('followup')).not.toHaveTextContent( 'Après avoir effectué vos démarches, vous pouvez les suivre en temps réel depuis l’application.' ); + const accordionButton: HTMLButtonElement = + screen.getByTestId('accordion-button'); + expect(accordionButton).toHaveAttribute('aria-expanded', 'false'); }); }); test('Should display empty followup', async () => { @@ -149,12 +157,19 @@ describe('/Followup.svelte', () => { await waitFor(() => { expect(spy).toHaveBeenCalledTimes(1); expect(screen.getByTestId('followup')).toHaveTextContent( - 'Après avoir effectué vos démarches, vous pouvez les suivre en temps réel depuis l’application.' + 'Votre démarche n’apparaît pas ? Consultez votre compte Service Public CNMSS Démarche numérique Dossier facile Vérifiez que vous suivez bien toutes vos démarches' ); + const accordionButton: HTMLButtonElement = + screen.getByTestId('accordion-button'); + expect(accordionButton).toHaveAttribute('aria-expanded', 'true'); }); }); }); describe('Archived items', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('Should display followup from API', async () => { // Given const followup = new Followup(); @@ -259,6 +274,9 @@ describe('/Followup.svelte', () => { expect(screen.getByTestId('followup')).not.toHaveTextContent( 'Après avoir effectué vos démarches, vous pouvez les suivre en temps réel depuis l’application.' ); + const accordionButton: HTMLButtonElement = + screen.getByTestId('accordion-button'); + expect(accordionButton).toHaveAttribute('aria-expanded', 'false'); }); }); test('Should display empty followup', async () => { @@ -296,12 +314,19 @@ describe('/Followup.svelte', () => { await waitFor(() => { expect(spy).toHaveBeenCalledTimes(1); expect(screen.getByTestId('followup')).toHaveTextContent( - 'Après avoir effectué vos démarches, vous pouvez les suivre en temps réel depuis l’application.' + 'Votre démarche n’apparaît pas ? Consultez votre compte Service Public CNMSS Démarche numérique Dossier facile Vérifiez que vous suivez bien toutes vos démarches' ); + const accordionButton: HTMLButtonElement = + screen.getByTestId('accordion-button'); + expect(accordionButton).toHaveAttribute('aria-expanded', 'false'); }); }); }); describe('More menu', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('No "more" button for archived followup items', async () => { // Given const followup = new Followup(); @@ -358,8 +383,30 @@ describe('/Followup.svelte', () => { expect(spy).toHaveBeenCalledWith('/#/followup/archived'); }); }); + + test('No "more" button when user has not consented', async () => { + // Given + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(false); + + const followup = new Followup(); + vi.spyOn(followup, 'items', 'get').mockReturnValue([]); + vi.spyOn(followupMethods, 'buildFollowup').mockResolvedValue(followup); + + // When + render(FollowupComponent, { archived: true }); + + // Then + await waitFor(async () => { + const button = screen.queryByTestId('more-button'); + expect(button).toBeNull(); + }); + }); }); describe('Followup item modal', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + test('No more icon for archived followup item', async () => { const followup = new Followup(); vi.spyOn(followup, 'archived_items', 'get').mockReturnValue([ @@ -663,4 +710,32 @@ describe('/Followup.svelte', () => { }); }); }); + + describe('No consent block', () => { + beforeEach(async () => { + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(true); + }); + + test('No more icon for archived followup item', async () => { + // Given + vi.spyOn(consentsMethods, 'hasAnyConsents').mockResolvedValue(false); + + const followup = new Followup(); + vi.spyOn(followup, 'items', 'get').mockReturnValue([]); + vi.spyOn(followupMethods, 'buildFollowup').mockResolvedValue(followup); + + // When + const { container } = render(FollowupComponent); + + // Then + await waitFor(() => { + const followupNoConsentBlock = container.querySelector( + '.followup-no-consent-container' + ); + expect(followupNoConsentBlock).toHaveTextContent( + 'Suivez vos démarches administratives au même endroit !' + ); + }); + }); + }); }); diff --git a/public/mobile-app/src/lib/components/FollowupInformation.svelte b/public/mobile-app/src/lib/components/FollowupInformation.svelte new file mode 100644 index 000000000..07c836be4 --- /dev/null +++ b/public/mobile-app/src/lib/components/FollowupInformation.svelte @@ -0,0 +1,56 @@ +
+

+ +

+
+
    +
  • S’inscrire à l’opération tranquillité vacances
  • +
  • Déclaration de changement d’adresse
  • +
  • Demande de recensement citoyen obligatoire
  • +
  • + Demande d’acte de naissance, de mariage, de décès : copie intégrale ou + extrait (survenu à l'étranger) +
    + Service gratuit +
  • +
  • Déclaration de changement de nom d’usage (nom de l’époux ou de l’épouse)
  • +
  • Déposer une pré-demande de Pacs
  • +
  • Toutes les démarches Démarche numérique
  • +
  • + Et bientôt : Demande d’acte de naissance, de mariage, de décès : copie + intégrale ou extrait (survenu en France) Service gratuit, Demander en ligne un + certificat de non-Pacs pour le partenaire étranger né à l’étranger, + Renouvellement de l’inscription consulaire, Inscription consulaire, + Actualisation, Radiation, Demander ses certificats (inscription ou radiation) et + sa carte consulaire (Registre des français à l’étranger), Demande de correction + d’état civil auprès de l’Insee, Demande de publication au Journal officiel + d’annonce préalable de changement de nom pour motif légitime, Renouvellement à + distance de passeport en Australie, au Canada, en Espagne et au Portugal, + Rendez-vous commissariat +
  • +
+
+
+ + diff --git a/public/mobile-app/src/lib/components/FollowupNoConsent.svelte b/public/mobile-app/src/lib/components/FollowupNoConsent.svelte new file mode 100644 index 000000000..e528ab8c0 --- /dev/null +++ b/public/mobile-app/src/lib/components/FollowupNoConsent.svelte @@ -0,0 +1,38 @@ + + + + + diff --git a/public/mobile-app/src/lib/components/Toggle.svelte b/public/mobile-app/src/lib/components/Toggle.svelte index 9448d492e..4a0debdf2 100644 --- a/public/mobile-app/src/lib/components/Toggle.svelte +++ b/public/mobile-app/src/lib/components/Toggle.svelte @@ -1,4 +1,5 @@ + + + +
+ + + + + +
diff --git a/public/mobile-app/src/routes/preferences/consents/page.svelte.test.ts b/public/mobile-app/src/routes/preferences/consents/page.svelte.test.ts new file mode 100644 index 000000000..8bfb0539c --- /dev/null +++ b/public/mobile-app/src/routes/preferences/consents/page.svelte.test.ts @@ -0,0 +1,84 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'; +import { describe, expect, test, vi } from 'vitest'; +import * as navigationMethods from '$app/navigation'; +import type { APIConsents } from '$lib/api-consents'; +import * as consentsMethods from '$lib/consents'; +import { Consents } from '$lib/consents'; +import { userStore } from '$lib/state/User.svelte'; +import { expectBackButtonPresent, mockUserInfo } from '$tests/utils'; +import Page from './+page.svelte'; + +describe('/+page.svelte', () => { + test('user has to be connected', async () => { + // Given + const spy = vi.spyOn(navigationMethods, 'goto').mockResolvedValue(); + + // When + render(Page); + + // Then + await waitFor(() => { + expect(spy).toHaveBeenCalledTimes(1); + expect(spy).toHaveBeenCalledWith('/#/login'); + }); + }); + + test('should enable consent when user toggles on', async () => { + // Given + await userStore.login(mockUserInfo); + + const apiConsents: APIConsents = { consents: [] }; + const consents: Consents = new Consents(apiConsents); + vi.spyOn(consentsMethods, 'buildConsents').mockResolvedValue(consents); + + const spy = vi.spyOn(consentsMethods, 'updateConsent'); + render(Page); + + // When + const toggleInput: HTMLInputElement = screen.getByTestId('psl'); + expect(toggleInput.checked).toBeFalsy(); + await fireEvent.click(toggleInput); + + // Then + await waitFor(async () => { + expect(spy).toHaveBeenCalledWith('psl', true); + }); + }); + + test('should disable consent when user toggles off', async () => { + // Given + await userStore.login(mockUserInfo); + + const spy = vi.spyOn(consentsMethods, 'updateConsent'); + render(Page); + const toggleInput: HTMLInputElement = screen.getByTestId('psl'); + await fireEvent.click(toggleInput); // set toggle to checked + + // When + expect(toggleInput.checked).toBeTruthy(); + await fireEvent.click(toggleInput); + + // Then + await waitFor(async () => { + expect(spy).toHaveBeenCalledWith('psl', false); + }); + }); + + test('should import NavWithBackButton component', async () => { + // When + render(Page); + const backButton = screen.getByTestId('back-button'); + + // Then + expect(backButton).toBeInTheDocument(); + expect(screen.getByText('Suivi des démarches')).toBeInTheDocument(); + }); + + test('should render a Back button', async () => { + // When + render(Page); + + // Then + expectBackButtonPresent(screen); + }); +}); From 79c5107df9849fd3add0a8bcb9977fdea999d77e Mon Sep 17 00:00:00 2001 From: Clotilde DESQUILBET Date: Fri, 28 Aug 2026 14:46:13 +0200 Subject: [PATCH 4/5] front: store consents in localStorage (#911) --- .../mobile-app/src/lib/api-consents.test.ts | 90 ++++++++++++++++++- public/mobile-app/src/lib/api-consents.ts | 4 +- .../src/lib/initializeDataFromAPI.test.ts | 5 ++ .../src/lib/initializeDataFromAPI.ts | 3 +- 4 files changed, 98 insertions(+), 4 deletions(-) diff --git a/public/mobile-app/src/lib/api-consents.test.ts b/public/mobile-app/src/lib/api-consents.test.ts index c4a81e0c8..7a2d8dc6f 100644 --- a/public/mobile-app/src/lib/api-consents.test.ts +++ b/public/mobile-app/src/lib/api-consents.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, test, vi } from 'vitest'; import '@testing-library/jest-dom/vitest'; +import { waitFor } from '@testing-library/svelte'; import { retrieveConsents, updateApiConsent } from '$lib/api-consents'; const apiConsents = { @@ -45,9 +46,35 @@ describe('/api-consents', () => { apiConsents.consents[1].consent_datetime ); }); + test('should store consents to localStorage', async () => { + // Given + localStorage.clear(); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response(JSON.stringify(apiConsents.consents), { status: 200 }) + ); + + // When + await retrieveConsents(); + + // Then + const result = JSON.parse(localStorage.getItem('consents') || '[]'); + expect(result.consents.length).toEqual(2); + expect(result.consents[0].partner_id).toEqual(apiConsents.consents[0].partner_id); + expect(result.consents[0].consent_datetime).toEqual( + apiConsents.consents[0].consent_datetime + ); + expect(result.consents[1].partner_id).toEqual(apiConsents.consents[1].partner_id); + expect(result.consents[1].consent_datetime).toEqual( + apiConsents.consents[1].consent_datetime + ); + }); - test('should get consents items from API - with error', async () => { + test('should get consents items from localStorage - when status code is not 200', async () => { // Given + localStorage.clear(); + localStorage.setItem('consents', JSON.stringify(apiConsents)); + const spy = vi .spyOn(globalThis, 'fetch') .mockResolvedValue(new Response('error', { status: 400 })); @@ -57,7 +84,66 @@ describe('/api-consents', () => { // Then expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents'); - expect(result).toEqual({ consents: [] }); + expect(result.consents.length).toEqual(2); + expect(result.consents[0].partner_id).toEqual(apiConsents.consents[0].partner_id); + expect(result.consents[0].consent_datetime).toEqual( + apiConsents.consents[0].consent_datetime + ); + expect(result.consents[1].partner_id).toEqual(apiConsents.consents[1].partner_id); + expect(result.consents[1].consent_datetime).toEqual( + apiConsents.consents[1].consent_datetime + ); + }); + test('should get consents with no item when localStorage has no key - when status code is not 200', async () => { + // Given + localStorage.clear(); + + const spy = vi + .spyOn(globalThis, 'fetch') + .mockResolvedValue(new Response('error', { status: 400 })); + + // When + const result = await retrieveConsents(); + + // Then + expect(spy).toHaveBeenCalledExactlyOnceWith('/api/v1/users/consents'); + expect(result.consents.length).toEqual(0); + }); + test('should get consents items from localStorage - when fetch fails', async () => { + // Given + localStorage.clear(); + localStorage.setItem('consents', JSON.stringify(apiConsents)); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Fetch failed')); + + // When + const result = await retrieveConsents(); + + // Then + console.log(result); + expect(result.consents.length).toEqual(2); + expect(result.consents[0].partner_id).toEqual(apiConsents.consents[0].partner_id); + expect(result.consents[0].consent_datetime).toEqual( + apiConsents.consents[0].consent_datetime + ); + expect(result.consents[1].partner_id).toEqual(apiConsents.consents[1].partner_id); + expect(result.consents[1].consent_datetime).toEqual( + apiConsents.consents[1].consent_datetime + ); + }); + test('should get consents with no item when localStorage has no key - when fetch fails', async () => { + // Given + localStorage.clear(); + + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Fetch failed')); + + // When + const result = await retrieveConsents(); + + // Then + await waitFor(() => { + expect(result.consents.length).toEqual(0); + }); }); }); diff --git a/public/mobile-app/src/lib/api-consents.ts b/public/mobile-app/src/lib/api-consents.ts index 4f6a6f26b..3c7e05569 100644 --- a/public/mobile-app/src/lib/api-consents.ts +++ b/public/mobile-app/src/lib/api-consents.ts @@ -10,7 +10,7 @@ export type APIConsents = { }; export const retrieveConsents = async (): Promise => { - const apiConsents = { + let apiConsents = { consents: [] as APIConsentsItem[], } as APIConsents; @@ -18,10 +18,12 @@ export const retrieveConsents = async (): Promise => { const response = await apiFetch('/api/v1/users/consents'); if (response.status === 200) { apiConsents.consents = await response.json(); + localStorage.setItem('consents', JSON.stringify(apiConsents)); } } catch (error) { console.error(error); } + apiConsents = JSON.parse(localStorage.getItem('consents') || '{"consents":[]}'); return apiConsents; }; diff --git a/public/mobile-app/src/lib/initializeDataFromAPI.test.ts b/public/mobile-app/src/lib/initializeDataFromAPI.test.ts index ce070b466..828cf35a6 100644 --- a/public/mobile-app/src/lib/initializeDataFromAPI.test.ts +++ b/public/mobile-app/src/lib/initializeDataFromAPI.test.ts @@ -3,6 +3,7 @@ import '@testing-library/jest-dom/vitest'; import { waitFor } from '@testing-library/svelte'; import type { APIAgenda } from '$lib/api-agenda'; import * as apiAgendaMethods from '$lib/api-agenda'; +import * as apiConsentsMethods from '$lib/api-consents'; import { initializeData, initializeLocalStorage } from '$lib/initializeDataFromAPI'; import type { AppNotification } from '$lib/notifications'; import * as notificationsMethods from '$lib/notifications'; @@ -105,6 +106,9 @@ describe('/initializeDataFromAPI.ts', () => { .spyOn(apiAgendaMethods, 'retrieveAgenda') .mockResolvedValue(apiAgendaData); const notifications: AppNotification[] = buildNotifications(); + const retrieveConsentsSpy = vi + .spyOn(apiConsentsMethods, 'retrieveConsents') + .mockResolvedValue({ consents: [] }); const retrieveNotificationsSpy = vi .spyOn(notificationsMethods, 'retrieveNotifications') .mockResolvedValue(notifications); @@ -114,6 +118,7 @@ describe('/initializeDataFromAPI.ts', () => { // Then expect(retrieveAgendaSpy).toHaveBeenCalledTimes(1); + expect(retrieveConsentsSpy).toHaveBeenCalledTimes(1); expect(retrieveNotificationsSpy).toHaveBeenCalledTimes(1); }); }); diff --git a/public/mobile-app/src/lib/initializeDataFromAPI.ts b/public/mobile-app/src/lib/initializeDataFromAPI.ts index 651e4f010..c0bd6c499 100644 --- a/public/mobile-app/src/lib/initializeDataFromAPI.ts +++ b/public/mobile-app/src/lib/initializeDataFromAPI.ts @@ -1,4 +1,5 @@ import { retrieveAgenda } from '$lib/api-agenda'; +import { retrieveConsents } from '$lib/api-consents'; import { retrieveNotifications } from '$lib/notifications'; export const initializeLocalStorage = (searchParams: URLSearchParams) => { @@ -29,5 +30,5 @@ export const initializeLocalStorage = (searchParams: URLSearchParams) => { }; export const initializeData = async () => { - await Promise.all([retrieveAgenda(), retrieveNotifications()]); + await Promise.all([retrieveAgenda(), retrieveConsents(), retrieveNotifications()]); }; From b5d36dab4a561f119c12f3a02a651c9c071e198c Mon Sep 17 00:00:00 2001 From: Clotilde DESQUILBET Date: Tue, 1 Sep 2026 11:04:20 +0200 Subject: [PATCH 5/5] front: move followup components to dedicated folder (#911) --- public/mobile-app/src/lib/ConnectedHomepage.svelte | 4 ++-- .../src/lib/components/{ => followup}/Followup.svelte | 4 ++-- .../lib/components/{ => followup}/Followup.svelte.test.ts | 4 ++-- .../components/{ => followup}/FollowupInformation.svelte | 0 .../src/lib/components/{ => followup}/FollowupItem.svelte | 4 ++-- .../components/{ => followup}/FollowupItem.svelte.test.ts | 0 .../components/{ => followup}/FollowupItemDetail.svelte | 2 +- .../{ => followup}/FollowupItemDetail.svelte.test.ts | 6 +++--- .../{ => followup}/FollowupItemDetailHeader.svelte | 0 .../FollowupItemDetailHeader.svelte.test.ts | 2 +- .../components/{ => followup}/FollowupNoConsent.svelte | 2 +- .../{ => followup}/FollowupParentItemDetail.svelte | 6 +++--- .../FollowupParentItemDetail.svelte.test.ts | 6 +++--- public/mobile-app/src/routes/followup/+page.svelte | 2 +- .../mobile-app/src/routes/followup/archived/+page.svelte | 2 +- .../[item_type]/[item_external_id]/+page.svelte | 4 ++-- .../[item_type]/[item_external_id]/page.svelte.test.ts | 8 ++++---- .../[subitem_type]/[subitem_external_id]/+page.svelte | 4 ++-- .../[subitem_external_id]/page.svelte.test.ts | 8 ++++---- .../src/routes/preferences/consents/+page.svelte | 2 +- 20 files changed, 35 insertions(+), 35 deletions(-) rename public/mobile-app/src/lib/components/{ => followup}/Followup.svelte (98%) rename public/mobile-app/src/lib/components/{ => followup}/Followup.svelte.test.ts (99%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupInformation.svelte (100%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupItem.svelte (94%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupItem.svelte.test.ts (100%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupItemDetail.svelte (92%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupItemDetail.svelte.test.ts (92%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupItemDetailHeader.svelte (100%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupItemDetailHeader.svelte.test.ts (97%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupNoConsent.svelte (90%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupParentItemDetail.svelte (90%) rename public/mobile-app/src/lib/components/{ => followup}/FollowupParentItemDetail.svelte.test.ts (95%) diff --git a/public/mobile-app/src/lib/ConnectedHomepage.svelte b/public/mobile-app/src/lib/ConnectedHomepage.svelte index 3ea03e975..c9caa68c2 100644 --- a/public/mobile-app/src/lib/ConnectedHomepage.svelte +++ b/public/mobile-app/src/lib/ConnectedHomepage.svelte @@ -6,8 +6,8 @@ import AgendaItem from '$lib/components/AgendaItem.svelte'; import AutoPromoCarousel from '$lib/components/AutoPromo.svelte'; import AutoPromoItem from '$lib/components/AutoPromoItem.svelte'; - import FollowupItem from '$lib/components/FollowupItem.svelte'; - import FollowupNoConsent from '$lib/components/FollowupNoConsent.svelte'; + import FollowupItem from '$lib/components/followup/FollowupItem.svelte'; + import FollowupNoConsent from '$lib/components/followup/FollowupNoConsent.svelte'; import AgendaItemModal from '$lib/components/modal/AgendaItemModal.svelte'; import FollowupItemModal from '$lib/components/modal/FollowupItemModal.svelte'; import { buildConsents, hasAnyConsents as hasAnyConsentsFunc } from '$lib/consents'; diff --git a/public/mobile-app/src/lib/components/Followup.svelte b/public/mobile-app/src/lib/components/followup/Followup.svelte similarity index 98% rename from public/mobile-app/src/lib/components/Followup.svelte rename to public/mobile-app/src/lib/components/followup/Followup.svelte index f0b669b16..e9e934686 100644 --- a/public/mobile-app/src/lib/components/Followup.svelte +++ b/public/mobile-app/src/lib/components/followup/Followup.svelte @@ -1,8 +1,8 @@