From 5c0f04ff8f5f2abf1ed890d4ca5b1257b0eacc47 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Sat, 25 May 2024 21:52:02 +0200 Subject: [PATCH 01/22] chore: translation --- designer_v2/lib/localization/app_en.arb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/designer_v2/lib/localization/app_en.arb b/designer_v2/lib/localization/app_en.arb index 87421f050..fa8539822 100644 --- a/designer_v2/lib/localization/app_en.arb +++ b/designer_v2/lib/localization/app_en.arb @@ -68,7 +68,7 @@ "study_status_running": "Live", "study_status_running_description": "This study is currently in progress.", "study_status_closed": "Closed", - "study_status_closed_description": "This study has been completed.\nNo new participants will be able to enroll.", + "study_status_closed_description": "This study has been completed.\nNew participants can no longer enroll.", "participation_open_who": "Everyone", "participation_open_who_description": "All StudyU users may enroll to the study in the StudyU app.", "participation_invite_who": "Invite-only", @@ -121,7 +121,7 @@ "notification_study_deleted": "Study was deleted", "notification_study_closed": "Study was closed", "dialog_study_close_title": "Close participation?", - "dialog_study_close_description": "Are you sure that you want to close participation for this study? No new participants will be able to enroll.", + "dialog_study_close_description": "Are you sure that you want to close participation for this study? New participants can no longer enroll.", "dialog_study_delete_title": "Permanently delete?", "dialog_study_delete_description": "Are you sure you want to delete this study? You will permanently lose the study and all data that has been collected.", "@__________________STUDYPAGE_DESIGN_SHARED__________________": {}, @@ -245,7 +245,7 @@ "banner_study_readonly_title": "This study cannot be edited.", "banner_study_readonly_description": "You can only make changes to studies where you are an owner or collaborator. Studies that have been launched cannot be changed by anyone.", "banner_study_closed_title": "This study is closed.", - "banner_study_closed_description": "No new participants will be able to enroll in this study.", + "banner_study_closed_description": "New participants can no longer enroll in this study.", "form_section_scheduling": "Scheduling and Compliance", "form_section_scheduling_description": "To improve compliance, you can set a limited window of time for participants to complete the task & send a reminder notification at the specified time.", "form_field_has_reminder": "App reminder", From 3cf8804463bb10d8e5d8a25aa5cd99664e0a7cee Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Sat, 25 May 2024 21:54:20 +0200 Subject: [PATCH 02/22] feat: deprecate published for study_status --- .../study/onboarding/study_selection.dart | 4 - core/lib/src/models/tables/study.dart | 19 +-- .../20240526_migrate_close_study.sql | 146 ++++++++++++++++++ database/migration/migrate-close_study.sql | 12 -- database/studyu-schema.sql | 16 +- designer_v2/lib/domain/study.dart | 5 +- .../features/recruit/study_recruit_page.dart | 3 +- .../study/study_controller_state.dart | 2 +- .../lib/repositories/model_repository.dart | 2 +- .../lib/repositories/study_repository.dart | 2 +- 10 files changed, 158 insertions(+), 53 deletions(-) create mode 100644 database/migration/20240526_migrate_close_study.sql delete mode 100644 database/migration/migrate-close_study.sql diff --git a/app/lib/screens/study/onboarding/study_selection.dart b/app/lib/screens/study/onboarding/study_selection.dart index 3a1d36769..ce40c7461 100644 --- a/app/lib/screens/study/onboarding/study_selection.dart +++ b/app/lib/screens/study/onboarding/study_selection.dart @@ -159,10 +159,6 @@ class _StudySelectionScreenState extends State { child: StudyTile.fromStudy( study: study, onTap: () async { - if (study.isClosed) { - await showStudyClosedDialog(context); - return; - } await navigateToStudyOverview(context, study); }, ), diff --git a/core/lib/src/models/tables/study.dart b/core/lib/src/models/tables/study.dart index 04f28354d..b2d37d748 100644 --- a/core/lib/src/models/tables/study.dart +++ b/core/lib/src/models/tables/study.dart @@ -54,10 +54,10 @@ class Study extends SupabaseObjectFunctions implements Comparable late Contact contact = Contact(); @JsonKey(name: 'icon_name', defaultValue: 'accountHeart') late String iconName = 'accountHeart'; + @Deprecated('Use status instead') @JsonKey(defaultValue: false) late bool published = false; - @JsonKey(name: 'is_closed', defaultValue: false) - bool isClosed = false; + late StudyStatus status = StudyStatus.draft; @JsonKey(fromJson: _questionnaireFromJson) late StudyUQuestionnaire questionnaire = StudyUQuestionnaire(); @JsonKey(name: 'eligibility_criteria', fromJson: _eligibilityCriteriaFromJson) @@ -231,7 +231,7 @@ class Study extends SupabaseObjectFunctions implements Comparable static Future> publishedPublicStudies() async { ExtractionResult result; try { - final response = await env.client.from(tableName).select().eq('participation', 'open').eq("is_closed", false); + final response = await env.client.from(tableName).select().eq('participation', 'open').eq("closed", false); final extracted = SupabaseQuery.extractSupabaseList(List>.from(response)); result = ExtractionSuccess(extracted); } on ExtractionFailedException catch (error) { @@ -292,18 +292,9 @@ class Study extends SupabaseObjectFunctions implements Comparable // - Status - StudyStatus get status { - if (isClosed) { - return StudyStatus.closed; - } - if (published) { - return StudyStatus.running; - } - return StudyStatus.draft; - } - bool get isDraft => status == StudyStatus.draft; bool get isRunning => status == StudyStatus.running; + bool get isClosed => status == StudyStatus.closed; bool isReadonly(User user) { return status != StudyStatus.draft || !canEdit(user); @@ -311,7 +302,7 @@ class Study extends SupabaseObjectFunctions implements Comparable @override String toString() { - return 'Study{id: $id, title: $title, description: $description, userId: $userId, participation: $participation, resultSharing: $resultSharing, contact: $contact, iconName: $iconName, published: $published, questionnaire: $questionnaire, eligibilityCriteria: $eligibilityCriteria, consent: $consent, interventions: $interventions, observations: $observations, schedule: $schedule, reportSpecification: $reportSpecification, results: $results, collaboratorEmails: $collaboratorEmails, registryPublished: $registryPublished, participantCount: $participantCount, endedCount: $endedCount, activeSubjectCount: $activeSubjectCount, missedDays: $missedDays, repo: $repo, invites: $invites, participants: $participants, participantsProgress: $participantsProgress, createdAt: $createdAt}'; + return 'Study{id: $id, title: $title, description: $description, userId: $userId, participation: $participation, resultSharing: $resultSharing, contact: $contact, iconName: $iconName, published: , status: $status, questionnaire: $questionnaire, eligibilityCriteria: $eligibilityCriteria, consent: $consent, interventions: $interventions, observations: $observations, schedule: $schedule, reportSpecification: $reportSpecification, results: $results, collaboratorEmails: $collaboratorEmails, registryPublished: $registryPublished, participantCount: $participantCount, endedCount: $endedCount, activeSubjectCount: $activeSubjectCount, missedDays: $missedDays, repo: $repo, invites: $invites, participants: $participants, participantsProgress: $participantsProgress, createdAt: $createdAt}'; } @override diff --git a/database/migration/20240526_migrate_close_study.sql b/database/migration/20240526_migrate_close_study.sql new file mode 100644 index 000000000..99953068d --- /dev/null +++ b/database/migration/20240526_migrate_close_study.sql @@ -0,0 +1,146 @@ +-- todo move to studyu-schema.sql + +CREATE TYPE public.study_status AS ENUM ( + 'draft', + 'running', + 'closed' +); + +ALTER TABLE public.study +ADD COLUMN status public.study_status DEFAULT 'draft'::public.study_status NOT NULL; + +-- Migrate existing studies from published to study_status +UPDATE public.study SET status = CASE + WHEN status != 'draft'::public.study_status THEN status + WHEN published THEN 'running'::public.study_status + ELSE status +END; + +-- Migrate policy +DROP POLICY "Editors can do everything with their studies" ON public.study; + +CREATE POLICY "Editors can view their studies" ON public.study FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Editor can control their draft studies" ON public.study + USING (public.can_edit(auth.uid(), study.*) AND status = 'draft'::public.study_status); + +-- Editors can only update registry_published and resultSharing +--grant update (registry_published, result_sharing) on public.study USING (public.can_edit(auth.uid(), study.*); +--CREATE POLICY "Editors can only update registry_published and resultSharing" ON public.study +-- FOR UPDATE +-- USING (public.can_edit(auth.uid(), study.*)) +-- WITH CHECK ((new.*) IS NOT DISTINCT FROM (old.* EXCEPT registry_published, result_sharing)); +-- todo solve with trigger or function +-- or create view with only updatable columns and provide permission on view see https://dba.stackexchange.com/questions/298931/allow-users-to-modify-only-some-but-not-all-fields-in-a-postgresql-table-with + +-- https://stackoverflow.com/questions/72756376/supabase-solutions-for-column-level-security + +-- https://github.com/orgs/supabase/discussions/656#discussioncomment-5594653 +CREATE OR REPLACE FUNCTION public.allow_updating_only() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ +DECLARE + whitelist TEXT[] := TG_ARGV::TEXT[]; + schema_table TEXT; + column_name TEXT; + rec RECORD; + new_value TEXT; + old_value TEXT; +BEGIN + schema_table := concat(TG_TABLE_SCHEMA, '.', TG_TABLE_NAME); + + -- If RLS is not active on current table for function invoker, early return + IF NOT row_security_active(schema_table) THEN + RETURN NEW; + END IF; + + -- Otherwise, loop on all columns of the table schema + FOR rec IN ( + SELECT col.column_name + FROM information_schema.columns as col + WHERE table_schema = TG_TABLE_SCHEMA + AND table_name = TG_TABLE_NAME + ) LOOP + -- If the current column is whitelisted, early continue + column_name := rec.column_name; + IF column_name = ANY(whitelist) THEN + CONTINUE; + END IF; + + -- If not whitelisted, execute dynamic SQL to get column value from OLD and NEW records + EXECUTE format('SELECT ($1).%I, ($2).%I', column_name, column_name) + INTO new_value, old_value + USING NEW, OLD; + + -- Raise exception if column value changed + IF new_value IS DISTINCT FROM old_value THEN + RAISE EXCEPTION 'Unauthorized change to "%"', column_name; + END IF; + END LOOP; + + -- RLS active, but no exception encountered, clear to proceed. + RETURN NEW; +END; +$function$; + +CREATE OR REPLACE FUNCTION public.check_study_update_permissions() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ +BEGIN + -- todo check if this is needed, because the policy should already prevent modification of foreign studies + --IF study_param.user_id != auth.uid() THEN + -- RAISE EXCEPTION 'Only the owner can update the status'; + --END IF; + + -- check if old.status is not draft and if yes check if allow_updating_only includes registry_published and result_sharing otherwise raise exception + IF OLD.status != 'draft'::public.study_status THEN + PERFORM public.allow_updating_only(ARRAY['registry_published', 'result_sharing']); + -- dont allow to update status directly + IF NEW.status != OLD.status THEN + RAISE EXCEPTION 'Study.status can only be updated using the `public.update_study_status` function'; + END IF; + END IF; + + RETURN NEW; +END; +$function$; + +CREATE OR REPLACE TRIGGER study_status_update_permissions + BEFORE UPDATE + ON public.study + FOR EACH ROW + EXECUTE FUNCTION public.check_study_update_permissions(); + +-- todo use this function to update status in designer +-- Owners can update status +CREATE FUNCTION public.update_study_status(study_param public.study) RETURNS VOID + LANGUAGE plpgsql -- SECURITY DEFINER + AS $$ +BEGIN + --IF study_param.user_id != auth.uid() THEN + -- RAISE EXCEPTION 'Only the owner can update the status'; + --END IF; + -- Increment the study.status + UPDATE public.study + SET status = CASE + WHEN study_param.status = 'draft'::public.study_status THEN 'running'::public.study_status + WHEN study_param.status = 'running'::public.study_status THEN 'closed'::public.study_status + ELSE study_param.status + END + WHERE id = study_param.id; +END; +$$; + +ALTER FUNCTION public.update_study_status(public.study) OWNER TO postgres; + +CREATE POLICY "Joining a closed study should not be possible" ON public.study_subject + AS RESTRICTIVE + FOR INSERT + WITH CHECK (NOT EXISTS ( + SELECT 1 + FROM public.study + WHERE study.id = study_subject.study_id + AND study.status = 'closed'::public.study_status +)); diff --git a/database/migration/migrate-close_study.sql b/database/migration/migrate-close_study.sql deleted file mode 100644 index f887f3652..000000000 --- a/database/migration/migrate-close_study.sql +++ /dev/null @@ -1,12 +0,0 @@ -ALTER TABLE public.study - ADD COLUMN is_closed boolean DEFAULT false NOT NULL; - -CREATE POLICY "Joining a closed study should not be possible" ON public.study_subject - AS RESTRICTIVE - FOR INSERT - WITH CHECK (NOT EXISTS ( - SELECT 1 - FROM public.study - WHERE study.id = study_subject.study_id - AND study.is_closed -)); diff --git a/database/studyu-schema.sql b/database/studyu-schema.sql index dd7a7f95e..b202d5ef4 100644 --- a/database/studyu-schema.sql +++ b/database/studyu-schema.sql @@ -80,8 +80,8 @@ CREATE TABLE public.study ( title text NOT NULL, description text NOT NULL, icon_name text NOT NULL, + -- published is deprecated, use status instead published boolean DEFAULT false NOT NULL, - is_closed boolean DEFAULT false NOT NULL, registry_published boolean DEFAULT false NOT NULL, questionnaire jsonb NOT NULL, eligibility_criteria jsonb NOT NULL, @@ -836,20 +836,6 @@ CREATE POLICY "Editors can see subjects from their studies" ON public.study_subj CREATE POLICY "Invite code needs to be valid (not possible in the app)" ON public.study_subject AS RESTRICTIVE FOR INSERT WITH CHECK (((invite_code IS NULL) OR (study_id IN ( SELECT code_fun.study_id FROM public.get_study_from_invite(study_subject.invite_code) code_fun(study_id, preselected_intervention_ids))))); --- --- Name: study_subject Joining a closed study should not be possible; Type: POLICY; Schema: public; Owner: postgres --- - -CREATE POLICY "Joining a closed study should not be possible" ON public.study_subject -AS RESTRICTIVE -FOR INSERT -WITH CHECK (NOT EXISTS ( - SELECT 1 - FROM public.study - WHERE study.id = study_subject.study_id - AND study.is_closed -)); - -- -- Name: subject_progress Editors can see their study subjects progress; Type: POLICY; Schema: public; Owner: postgres -- diff --git a/designer_v2/lib/domain/study.dart b/designer_v2/lib/domain/study.dart index 26ca0c72f..4c3cedfd0 100644 --- a/designer_v2/lib/domain/study.dart +++ b/designer_v2/lib/domain/study.dart @@ -113,8 +113,7 @@ extension StudyDuplicateX on Study { copy.resetJsonIgnoredAttributes(); copy.title = (copy.title ?? '').withDuplicateLabel(); copy.userId = userId; - copy.published = false; - copy.isClosed = false; + copy.status = StudyStatus.draft; copy.resultSharing = ResultSharing.private; copy.registryPublished = false; copy.results = []; @@ -141,7 +140,7 @@ extension StudyDuplicateX on Study { final copy = Study.fromJson(toJson()); copy.copyJsonIgnoredAttributes(from: this, createdAt: true); copy.resetParticipantData(); - copy.published = true; + copy.status = StudyStatus.running; return copy; } diff --git a/designer_v2/lib/features/recruit/study_recruit_page.dart b/designer_v2/lib/features/recruit/study_recruit_page.dart index 42774a979..c9f886496 100644 --- a/designer_v2/lib/features/recruit/study_recruit_page.dart +++ b/designer_v2/lib/features/recruit/study_recruit_page.dart @@ -55,7 +55,7 @@ class StudyRecruitScreen extends StudyPageWidget { @override Widget? banner(BuildContext context, WidgetRef ref) { final state = ref.watch(studyRecruitControllerProvider(studyId)); - final isStudyClosed = state.studyWithMetadata?.model.isClosed == true; + final isStudyClosed = state.studyWithMetadata!.model.isClosed; if (isStudyClosed) { return BannerBox( @@ -74,7 +74,6 @@ class StudyRecruitScreen extends StudyPageWidget { ]), style: BannerStyle.info); } - return null; } diff --git a/designer_v2/lib/features/study/study_controller_state.dart b/designer_v2/lib/features/study/study_controller_state.dart index 3c41c6566..483b9b61c 100644 --- a/designer_v2/lib/features/study/study_controller_state.dart +++ b/designer_v2/lib/features/study/study_controller_state.dart @@ -15,7 +15,7 @@ class StudyControllerState extends StudyControllerBaseState implements IStudyApp this.lastSynced, }); - bool get isPublished => study.value != null && study.value!.published; + bool get isPublished => study.value != null && study.value!.status == StudyStatus.running; // - ISyncIndicatorViewModel diff --git a/designer_v2/lib/repositories/model_repository.dart b/designer_v2/lib/repositories/model_repository.dart index c2415a57f..d910bd2f3 100644 --- a/designer_v2/lib/repositories/model_repository.dart +++ b/designer_v2/lib/repositories/model_repository.dart @@ -304,7 +304,7 @@ abstract class ModelRepository extends IModelRepository { if (fetchOnSubscribe) { if (!(wrappedModel != null && wrappedModel.isLocalOnly)) { fetch(modelId).catchError((e) { - if (!modelController.isClosed) { + if (!modelController.closed) { modelController.addError(e); } return e; diff --git a/designer_v2/lib/repositories/study_repository.dart b/designer_v2/lib/repositories/study_repository.dart index e0625f53f..8c523ca9b 100644 --- a/designer_v2/lib/repositories/study_repository.dart +++ b/designer_v2/lib/repositories/study_repository.dart @@ -106,7 +106,7 @@ class StudyRepository extends ModelRepository implements IStudyRepository } Future onCloseCallback() { - model.isClosed = true; + model.status = StudyStatus.closed; return save(model).then((value) => ref.read(routerProvider).dispatch(RoutingIntents.studies)).then((value) => Future.delayed(const Duration(milliseconds: 200), () => ref.read(notificationServiceProvider).show(Notifications.studyClosed))); From 29310b78019518a2063836125fced614bd2c6823 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Sun, 26 May 2024 08:01:09 +0200 Subject: [PATCH 03/22] chore: create migration temporarily --- supabase/migrations/20240526_migrate_close_study.sql | 1 + 1 file changed, 1 insertion(+) create mode 120000 supabase/migrations/20240526_migrate_close_study.sql diff --git a/supabase/migrations/20240526_migrate_close_study.sql b/supabase/migrations/20240526_migrate_close_study.sql new file mode 120000 index 000000000..5cfba03e0 --- /dev/null +++ b/supabase/migrations/20240526_migrate_close_study.sql @@ -0,0 +1 @@ +../../database/migration/20240526_migrate_close_study.sql \ No newline at end of file From 9aba30c686abb5b89ffa8df3fb68d741ccf509c1 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Sun, 26 May 2024 16:54:51 +0200 Subject: [PATCH 04/22] fix: improve status transition rules --- .../20240526_migrate_close_study.sql | 83 ++++++++++--------- 1 file changed, 42 insertions(+), 41 deletions(-) diff --git a/database/migration/20240526_migrate_close_study.sql b/database/migration/20240526_migrate_close_study.sql index 99953068d..c6cf982cd 100644 --- a/database/migration/20240526_migrate_close_study.sql +++ b/database/migration/20240526_migrate_close_study.sql @@ -22,7 +22,8 @@ DROP POLICY "Editors can do everything with their studies" ON public.study; CREATE POLICY "Editors can view their studies" ON public.study FOR SELECT USING (auth.uid() = user_id); CREATE POLICY "Editor can control their draft studies" ON public.study - USING (public.can_edit(auth.uid(), study.*) AND status = 'draft'::public.study_status); + --USING (public.can_edit(auth.uid(), study.*) AND status = 'draft'::public.study_status); + USING (public.can_edit(auth.uid(), study.*)); -- Editors can only update registry_published and resultSharing --grant update (registry_published, result_sharing) on public.study USING (public.can_edit(auth.uid(), study.*); @@ -36,6 +37,8 @@ CREATE POLICY "Editor can control their draft studies" ON public.study -- https://stackoverflow.com/questions/72756376/supabase-solutions-for-column-level-security -- https://github.com/orgs/supabase/discussions/656#discussioncomment-5594653 + +-- todo document this function CREATE OR REPLACE FUNCTION public.allow_updating_only() RETURNS trigger LANGUAGE plpgsql @@ -48,6 +51,27 @@ DECLARE new_value TEXT; old_value TEXT; BEGIN + + -- If the current user is 'postgres', return NEW without making any changes + IF CURRENT_USER = 'postgres' THEN + RETURN NEW; + END IF; + + -- If the status is draft, return NEW without making any changes + IF OLD.status = 'draft'::public.study_status THEN + RETURN NEW; + END IF; + + -- Don't allow invalid status transitions + IF OLD.status != NEW.status THEN + IF NOT ( + (OLD.status = 'draft'::public.study_status AND NEW.status = 'running'::public.study_status) + OR (OLD.status = 'running'::public.study_status AND NEW.status = 'closed'::public.study_status) + ) THEN + RAISE EXCEPTION 'Invalid status transition'; + END IF; + END IF; + schema_table := concat(TG_TABLE_SCHEMA, '.', TG_TABLE_NAME); -- If RLS is not active on current table for function invoker, early return @@ -84,56 +108,33 @@ BEGIN END; $function$; -CREATE OR REPLACE FUNCTION public.check_study_update_permissions() - RETURNS trigger - LANGUAGE plpgsql -AS $function$ -BEGIN - -- todo check if this is needed, because the policy should already prevent modification of foreign studies - --IF study_param.user_id != auth.uid() THEN - -- RAISE EXCEPTION 'Only the owner can update the status'; - --END IF; - - -- check if old.status is not draft and if yes check if allow_updating_only includes registry_published and result_sharing otherwise raise exception - IF OLD.status != 'draft'::public.study_status THEN - PERFORM public.allow_updating_only(ARRAY['registry_published', 'result_sharing']); - -- dont allow to update status directly - IF NEW.status != OLD.status THEN - RAISE EXCEPTION 'Study.status can only be updated using the `public.update_study_status` function'; - END IF; - END IF; - - RETURN NEW; -END; -$function$; - CREATE OR REPLACE TRIGGER study_status_update_permissions BEFORE UPDATE ON public.study FOR EACH ROW - EXECUTE FUNCTION public.check_study_update_permissions(); + EXECUTE FUNCTION public.allow_updating_only('updated_at', 'status', 'registry_published', 'result_sharing'); + -- todo also add participation? --- todo use this function to update status in designer -- Owners can update status -CREATE FUNCTION public.update_study_status(study_param public.study) RETURNS VOID - LANGUAGE plpgsql -- SECURITY DEFINER - AS $$ -BEGIN +--CREATE FUNCTION public.update_study_status(study_param public.study) RETURNS VOID +-- LANGUAGE plpgsql -- SECURITY DEFINER +-- AS $$ +--BEGIN --IF study_param.user_id != auth.uid() THEN -- RAISE EXCEPTION 'Only the owner can update the status'; --END IF; -- Increment the study.status - UPDATE public.study - SET status = CASE - WHEN study_param.status = 'draft'::public.study_status THEN 'running'::public.study_status - WHEN study_param.status = 'running'::public.study_status THEN 'closed'::public.study_status - ELSE study_param.status - END - WHERE id = study_param.id; -END; -$$; - -ALTER FUNCTION public.update_study_status(public.study) OWNER TO postgres; +-- UPDATE public.study +-- SET status = CASE +-- WHEN study_param.status = 'draft'::public.study_status THEN 'running'::public.study_status +-- WHEN study_param.status = 'running'::public.study_status THEN 'closed'::public.study_status +-- ELSE study_param.status +-- END +-- WHERE id = study_param.id; +--END; +--$$; + +--ALTER FUNCTION public.update_study_status(public.study) OWNER TO postgres; CREATE POLICY "Joining a closed study should not be possible" ON public.study_subject AS RESTRICTIVE From 15a72e5e8ffb9c8a7251fcd4eeefe8b5713d5d49 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 7 Jun 2024 16:33:11 +0200 Subject: [PATCH 05/22] feat: study close button --- .../20240526_migrate_close_study.sql | 2 +- designer_v2/lib/domain/study.dart | 3 - .../close/study_close_dialog_confirm.dart | 64 +++++++++++++++++++ .../close/study_close_dialog_success.dart | 42 ++++++++++++ .../publish/study_publish_dialog_confirm.dart | 0 .../publish/study_publish_dialog_success.dart | 0 .../lib/features/dialogs/study_dialogs.dart | 39 +++++++++++ .../publish/study_publish_dialog.dart | 31 --------- .../features/recruit/study_recruit_page.dart | 4 +- .../lib/features/study/study_actions.dart | 1 - .../lib/features/study/study_controller.dart | 6 +- .../study/study_controller_state.dart | 2 + .../lib/features/study/study_scaffold.dart | 23 ++++++- designer_v2/lib/localization/app_de.arb | 2 +- designer_v2/lib/localization/app_en.arb | 2 +- .../lib/repositories/study_repository.dart | 39 ++++++----- designer_v2/lib/services/notifications.dart | 3 - 17 files changed, 200 insertions(+), 63 deletions(-) create mode 100644 designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart create mode 100644 designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart rename designer_v2/lib/features/{ => dialogs}/publish/study_publish_dialog_confirm.dart (100%) rename designer_v2/lib/features/{ => dialogs}/publish/study_publish_dialog_success.dart (100%) create mode 100644 designer_v2/lib/features/dialogs/study_dialogs.dart delete mode 100644 designer_v2/lib/features/publish/study_publish_dialog.dart diff --git a/database/migration/20240526_migrate_close_study.sql b/database/migration/20240526_migrate_close_study.sql index c6cf982cd..b5d44e7e8 100644 --- a/database/migration/20240526_migrate_close_study.sql +++ b/database/migration/20240526_migrate_close_study.sql @@ -31,7 +31,7 @@ CREATE POLICY "Editor can control their draft studies" ON public.study -- FOR UPDATE -- USING (public.can_edit(auth.uid(), study.*)) -- WITH CHECK ((new.*) IS NOT DISTINCT FROM (old.* EXCEPT registry_published, result_sharing)); --- todo solve with trigger or function +-- t odo solve with trigger or function -- or create view with only updatable columns and provide permission on view see https://dba.stackexchange.com/questions/298931/allow-users-to-modify-only-some-but-not-all-fields-in-a-postgresql-table-with -- https://stackoverflow.com/questions/72756376/supabase-solutions-for-column-level-security diff --git a/designer_v2/lib/domain/study.dart b/designer_v2/lib/domain/study.dart index 4c3cedfd0..27c25ba3a 100644 --- a/designer_v2/lib/domain/study.dart +++ b/designer_v2/lib/domain/study.dart @@ -14,7 +14,6 @@ enum StudyActionType { duplicateDraft, addCollaborator, export, - close, delete, } @@ -28,8 +27,6 @@ extension StudyActionTypeFormatted on StudyActionType { return tr.action_unpin; case StudyActionType.edit: return tr.action_edit; - case StudyActionType.close: - return tr.action_close; case StudyActionType.delete: return tr.action_delete; case StudyActionType.duplicate: diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart new file mode 100644 index 000000000..ab3aa2d6b --- /dev/null +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:reactive_forms/reactive_forms.dart'; +import 'package:studyu_designer_v2/common_views/dialog.dart'; +import 'package:studyu_designer_v2/common_views/form_buttons.dart'; +import 'package:studyu_designer_v2/common_views/primary_button.dart'; +import 'package:studyu_designer_v2/features/study/settings/study_settings_form_controller.dart'; +import 'package:studyu_designer_v2/features/study/study_controller.dart'; +import 'package:studyu_designer_v2/features/study/study_page_view.dart'; +import 'package:studyu_designer_v2/localization/app_translation.dart'; + +class CloseConfirmationDialog extends StudyPageWidget { + const CloseConfirmationDialog(super.studyId, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = ref.watch(studyControllerProvider(studyId).notifier); + final formViewModel = + ref.watch(studySettingsFormViewModelProvider(studyId)); + + return ReactiveForm( + formGroup: formViewModel.form, + child: StandardDialog( + titleText: tr.dialog_study_close_title, + body: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + text: tr.dialog_study_close_description, + ), + ), + ], + )), + ], + ), + ], + ), + actionButtons: [ + const DismissButton(), + ReactiveFormConsumer(builder: (context, form, child) { + return PrimaryButton( + text: tr.dialog_close, + icon: null, + onPressedFuture: () => controller.closeStudy(), + ); + }), + ], + maxWidth: 650, + minWidth: 610, + minHeight: 200, + ), + ); + } +} diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart new file mode 100644 index 000000000..c08726a0e --- /dev/null +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:studyu_designer_v2/common_views/dialog.dart'; +import 'package:studyu_designer_v2/common_views/empty_body.dart'; +import 'package:studyu_designer_v2/common_views/primary_button.dart'; +import 'package:studyu_designer_v2/features/study/study_page_view.dart'; +import 'package:studyu_designer_v2/localization/app_translation.dart'; +import 'package:studyu_designer_v2/localization/string_hardcoded.dart'; + +class CloseSuccessDialog extends StudyPageWidget { + const CloseSuccessDialog(super.studyId, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + + return StandardDialog( + body: Column( + children: [ + const SizedBox(height: 24.0), + EmptyBody( + leading: Text("\u{1f512}".hardcoded, + style: theme.textTheme.displayLarge?.copyWith( + fontSize: (theme.textTheme.displayLarge?.fontSize ?? 48.0) * 1.5, + )), + title: tr.notification_study_closed, + description: '', + ), + const SizedBox(height: 8.0), + ], + ), + actionButtons: [ + PrimaryButton( + text: tr.action_button_study_close, + icon: null, + onPressedFuture: () => Navigator.maybePop(context), + ), + ], + maxWidth: 450, + ); + } +} diff --git a/designer_v2/lib/features/publish/study_publish_dialog_confirm.dart b/designer_v2/lib/features/dialogs/publish/study_publish_dialog_confirm.dart similarity index 100% rename from designer_v2/lib/features/publish/study_publish_dialog_confirm.dart rename to designer_v2/lib/features/dialogs/publish/study_publish_dialog_confirm.dart diff --git a/designer_v2/lib/features/publish/study_publish_dialog_success.dart b/designer_v2/lib/features/dialogs/publish/study_publish_dialog_success.dart similarity index 100% rename from designer_v2/lib/features/publish/study_publish_dialog_success.dart rename to designer_v2/lib/features/dialogs/publish/study_publish_dialog_success.dart diff --git a/designer_v2/lib/features/dialogs/study_dialogs.dart b/designer_v2/lib/features/dialogs/study_dialogs.dart new file mode 100644 index 000000000..8ecee70f9 --- /dev/null +++ b/designer_v2/lib/features/dialogs/study_dialogs.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:studyu_designer_v2/domain/study.dart'; +import 'package:studyu_designer_v2/features/dialogs/close/study_close_dialog_confirm.dart'; +import 'package:studyu_designer_v2/features/dialogs/close/study_close_dialog_success.dart'; +import 'package:studyu_designer_v2/features/dialogs/publish/study_publish_dialog_confirm.dart'; +import 'package:studyu_designer_v2/features/dialogs/publish/study_publish_dialog_success.dart'; +import 'package:studyu_designer_v2/features/study/study_controller.dart'; +import 'package:studyu_designer_v2/features/study/study_page_view.dart'; +import 'package:studyu_designer_v2/theme.dart'; + +enum StudyDialogType { publish, close } + +class StudyDialog extends StudyPageWidget { + final StudyDialogType dialogType; + + const StudyDialog(this.dialogType, super.studyId, {super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(studyControllerProvider(studyId)); + + switch(dialogType) { + case StudyDialogType.publish: + return state.isPublished ? PublishSuccessDialog(studyId) : PublishConfirmationDialog(studyId); + case StudyDialogType.close: + return state.isClosed ? CloseSuccessDialog(studyId) : CloseConfirmationDialog(studyId); + } + } +} + +showStudyDialog(BuildContext context, StudyID studyId, StudyDialogType dialogType) { + final theme = Theme.of(context); + return showDialog( + context: context, + barrierColor: ThemeConfig.modalBarrierColor(theme), + builder: (context) => StudyDialog(dialogType, studyId), + ); +} diff --git a/designer_v2/lib/features/publish/study_publish_dialog.dart b/designer_v2/lib/features/publish/study_publish_dialog.dart deleted file mode 100644 index d9efd69c1..000000000 --- a/designer_v2/lib/features/publish/study_publish_dialog.dart +++ /dev/null @@ -1,31 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:studyu_designer_v2/domain/study.dart'; -import 'package:studyu_designer_v2/features/publish/study_publish_dialog_confirm.dart'; -import 'package:studyu_designer_v2/features/publish/study_publish_dialog_success.dart'; -import 'package:studyu_designer_v2/features/study/study_controller.dart'; -import 'package:studyu_designer_v2/features/study/study_page_view.dart'; -import 'package:studyu_designer_v2/theme.dart'; - -class PublishDialog extends StudyPageWidget { - const PublishDialog(super.studyId, {super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final state = ref.watch(studyControllerProvider(studyId)); - - if (state.isPublished) { - return PublishSuccessDialog(studyId); - } - return PublishConfirmationDialog(studyId); - } -} - -showPublishDialog(BuildContext context, StudyID studyId) { - final theme = Theme.of(context); - return showDialog( - context: context, - barrierColor: ThemeConfig.modalBarrierColor(theme), - builder: (context) => PublishDialog(studyId), - ); -} diff --git a/designer_v2/lib/features/recruit/study_recruit_page.dart b/designer_v2/lib/features/recruit/study_recruit_page.dart index c9f886496..5b89866a8 100644 --- a/designer_v2/lib/features/recruit/study_recruit_page.dart +++ b/designer_v2/lib/features/recruit/study_recruit_page.dart @@ -55,9 +55,9 @@ class StudyRecruitScreen extends StudyPageWidget { @override Widget? banner(BuildContext context, WidgetRef ref) { final state = ref.watch(studyRecruitControllerProvider(studyId)); - final isStudyClosed = state.studyWithMetadata!.model.isClosed; + final isStudyClosed = state.studyWithMetadata?.model.isClosed; - if (isStudyClosed) { + if (isStudyClosed ?? false) { return BannerBox( noPrefix: true, body: Column( diff --git a/designer_v2/lib/features/study/study_actions.dart b/designer_v2/lib/features/study/study_actions.dart index 8b38692cd..cbd257fcb 100644 --- a/designer_v2/lib/features/study/study_actions.dart +++ b/designer_v2/lib/features/study/study_actions.dart @@ -11,5 +11,4 @@ Map studyActionIcons = { StudyActionType.addCollaborator: Icons.person_add_rounded, StudyActionType.export: Icons.download_rounded, StudyActionType.delete: Icons.delete_rounded, - StudyActionType.close: MdiIcons.accountLock, }; diff --git a/designer_v2/lib/features/study/study_controller.dart b/designer_v2/lib/features/study/study_controller.dart index 2edbd13dd..3091f8350 100644 --- a/designer_v2/lib/features/study/study_controller.dart +++ b/designer_v2/lib/features/study/study_controller.dart @@ -82,6 +82,11 @@ class StudyController extends StudyBaseController { return studyRepository.launch(study); } + Future closeStudy() { + final study = state.study.value!; + return studyRepository.close(study); + } + void onChangeStudyParticipation() { router.dispatch(RoutingIntents.studyEditEnrollment(studyId)); } @@ -111,7 +116,6 @@ final studyControllerProvider = currentUser: ref.watch(authRepositoryProvider).currentUser, router: ref.watch(routerProvider), notificationService: ref.watch(notificationServiceProvider), - //ref: ref, ); controller.addListener((state) { print("studyController.state updated"); diff --git a/designer_v2/lib/features/study/study_controller_state.dart b/designer_v2/lib/features/study/study_controller_state.dart index 483b9b61c..bd31b0813 100644 --- a/designer_v2/lib/features/study/study_controller_state.dart +++ b/designer_v2/lib/features/study/study_controller_state.dart @@ -17,6 +17,8 @@ class StudyControllerState extends StudyControllerBaseState implements IStudyApp bool get isPublished => study.value != null && study.value!.status == StudyStatus.running; + bool get isClosed => study.value != null && study.value!.status == StudyStatus.closed; + // - ISyncIndicatorViewModel @override diff --git a/designer_v2/lib/features/study/study_scaffold.dart b/designer_v2/lib/features/study/study_scaffold.dart index 0a12c7c03..4536e38bc 100644 --- a/designer_v2/lib/features/study/study_scaffold.dart +++ b/designer_v2/lib/features/study/study_scaffold.dart @@ -6,13 +6,13 @@ import 'package:studyu_designer_v2/common_views/async_value_widget.dart'; import 'package:studyu_designer_v2/common_views/layout_single_column.dart'; import 'package:studyu_designer_v2/common_views/navbar_tabbed.dart'; import 'package:studyu_designer_v2/common_views/primary_button.dart'; +import 'package:studyu_designer_v2/common_views/secondary_button.dart'; import 'package:studyu_designer_v2/common_views/sync_indicator.dart'; import 'package:studyu_designer_v2/common_views/utils.dart'; import 'package:studyu_designer_v2/constants.dart'; import 'package:studyu_designer_v2/features/app_drawer.dart'; import 'package:studyu_designer_v2/features/design/study_form_providers.dart'; import 'package:studyu_designer_v2/features/forms/form_validation.dart'; -import 'package:studyu_designer_v2/features/publish/study_publish_dialog.dart'; import 'package:studyu_designer_v2/features/study/study_controller.dart'; import 'package:studyu_designer_v2/features/study/study_controller_state.dart'; import 'package:studyu_designer_v2/features/study/study_navbar.dart'; @@ -20,6 +20,7 @@ import 'package:studyu_designer_v2/features/study/study_page_view.dart'; import 'package:studyu_designer_v2/features/study/study_status_badge.dart'; import 'package:studyu_designer_v2/localization/app_translation.dart'; import 'package:studyu_designer_v2/theme.dart'; +import 'package:studyu_designer_v2/features/dialogs/study_dialogs.dart'; abstract class IStudyAppBarViewModel implements IStudyStatusBadgeViewModel, IStudyNavViewModel { bool get isSyncIndicatorVisible; @@ -228,7 +229,7 @@ class _StudyScaffoldState extends ConsumerState { tooltipDisabled: "${tr.form_invalid_prompt}\n\n${form.validationErrorSummary}", icon: null, enabled: formViewModel.isValid, - onPressed: () => showPublishDialog(context, widget.studyId), + onPressed: () => showStudyDialog(context, widget.studyId, StudyDialogType.publish), ); }), ); @@ -236,6 +237,24 @@ class _StudyScaffoldState extends ConsumerState { actionButtons.add(const SizedBox(width: 12.0)); // padding } + if (state.isPublished) { + final formViewModel = ref.watch(studyPublishValidatorProvider(widget.studyId)); + final closeButton = ReactiveForm( + formGroup: formViewModel.form, + child: ReactiveFormConsumer( + // enable re-rendering based on form validation status + builder: (context, form, child) { + return SecondaryButton( + text: tr.action_button_study_close, + icon: null, + onPressed: () => showStudyDialog(context, widget.studyId, StudyDialogType.close), + ); + }), + ); + actionButtons.add(closeButton); + actionButtons.add(const SizedBox(width: 12.0)); // padding + } + if (state.isSettingsEnabled) { actionButtons.add(IconButton( onPressed: controller.onSettingsPressed, diff --git a/designer_v2/lib/localization/app_de.arb b/designer_v2/lib/localization/app_de.arb index ded5ffe6a..e19eebf00 100644 --- a/designer_v2/lib/localization/app_de.arb +++ b/designer_v2/lib/localization/app_de.arb @@ -118,6 +118,7 @@ "study_settings_publish_results": "Ergebnisse veröffentlichen", "study_settings_publish_results_tooltip": "Andere Forscher und Kliniker können auf die anonymisierten Ergebnisdaten einer Studie zugreifen, \nsie exportieren und analysieren (die Analysieren-Unterseite deiner Studie ist zugänglich). Die Studie \nselbst wird dadurch automatisch auch für andere Forscher und Kliniker im Studienregister veröffentlicht.", "action_button_study_launch": "Studie starten", + "action_button_study_close": "Studie schließen", "notification_study_deleted": "Die Studie wurde gelöscht", "notification_study_closed": "Die Studie wurde geschlossen", "dialog_study_close_title": "Teilnahme schließen?", @@ -550,7 +551,6 @@ "action_pin": "Anheften", "action_unpin": "Nicht mehr anheften", "action_edit": "Bearbeiten", - "action_close": "Schließen", "action_delete": "Löschen", "action_remove": "Entfernen", "action_duplicate": "Duplizieren", diff --git a/designer_v2/lib/localization/app_en.arb b/designer_v2/lib/localization/app_en.arb index fa8539822..12b88af00 100644 --- a/designer_v2/lib/localization/app_en.arb +++ b/designer_v2/lib/localization/app_en.arb @@ -118,6 +118,7 @@ "study_settings_publish_results": "Publish results", "study_settings_publish_results_tooltip": "Make your anonymized study results & data available in the study registry. \nOther researchers & clinicians will be able to access, export and \nanalyze the results from your study (the Analyze page will be available). \n This will automatically publish your study design to the registry.", "action_button_study_launch": "Launch", + "action_button_study_close": "Close study", "notification_study_deleted": "Study was deleted", "notification_study_closed": "Study was closed", "dialog_study_close_title": "Close participation?", @@ -599,7 +600,6 @@ "action_pin": "Pin", "action_unpin": "Remove pin", "action_delete": "Delete", - "action_close": "Close", "action_remove": "Remove", "action_duplicate": "Duplicate", "action_clipboard": "Copy to clipboard", diff --git a/designer_v2/lib/repositories/study_repository.dart b/designer_v2/lib/repositories/study_repository.dart index 8c523ca9b..10980492b 100644 --- a/designer_v2/lib/repositories/study_repository.dart +++ b/designer_v2/lib/repositories/study_repository.dart @@ -20,6 +20,7 @@ import 'package:studyu_designer_v2/utils/performance.dart'; abstract class IStudyRepository implements ModelRepository { Future launch(Study study); Future deleteParticipants(Study study); + Future close(Study study); // Future deleteProgress(Study study); } @@ -97,6 +98,27 @@ class StudyRepository extends ModelRepository implements IStudyRepository return publishOperation.execute(); } + @override + Future close(Study study) async { + final wrappedModel = get(study.id); + if (wrappedModel == null) { + throw ModelNotFoundException(); + } + study.status = StudyStatus.closed; + + final publishOperation = OptimisticUpdate( + applyOptimistic: () => {}, // nothing to do here + apply: () => save(study, runOptimistically: false), + rollback: () {}, // nothing to do here + onUpdate: () => emitUpdate(), + onError: (e, stackTrace) { + emitError(modelStreamControllers[study.id], e, stackTrace); + }, + ); + + return publishOperation.execute(); + } + @override List availableActions(Study model) { Future onDeleteCallback() { @@ -105,13 +127,6 @@ class StudyRepository extends ModelRepository implements IStudyRepository () => ref.read(notificationServiceProvider).show(Notifications.studyDeleted))); } - Future onCloseCallback() { - model.status = StudyStatus.closed; - return save(model).then((value) => ref.read(routerProvider).dispatch(RoutingIntents.studies)).then((value) => - Future.delayed(const Duration(milliseconds: 200), - () => ref.read(notificationServiceProvider).show(Notifications.studyClosed))); - } - final currentUser = authRepository.currentUser!; // TODO: review Postgres policies to match [ModelAction.isAvailable] @@ -161,16 +176,6 @@ class StudyRepository extends ModelRepository implements IStudyRepository isAvailable: model.canExport(currentUser), ), ModelAction.addSeparator(), - ModelAction( - type: StudyActionType.close, - label: StudyActionType.close.string, - onExecute: () { - return ref.read(notificationServiceProvider).show(Notifications.studyCloseConfirmation, actions: [ - NotificationAction(label: StudyActionType.close.string, onSelect: onCloseCallback, isDestructive: true), - ]); - }, - isAvailable: model.canClose(currentUser) && model.status == StudyStatus.running, - ), ModelAction( type: StudyActionType.delete, label: StudyActionType.delete.string, diff --git a/designer_v2/lib/services/notifications.dart b/designer_v2/lib/services/notifications.dart index 40eeb7abe..adcc3b239 100644 --- a/designer_v2/lib/services/notifications.dart +++ b/designer_v2/lib/services/notifications.dart @@ -19,9 +19,6 @@ class Notifications { static final studyDeleted = SnackbarIntent( message: tr.notification_study_deleted, ); - static final studyClosed = SnackbarIntent( - message: tr.notification_study_closed, - ); static final inviteCodeDeleted = SnackbarIntent( message: tr.notification_code_deleted, ); From 881f856fe5a2a039fb9de2d0f90d744ad717c322 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Fri, 7 Jun 2024 16:38:32 +0200 Subject: [PATCH 06/22] chore: format --- .../dialogs/close/study_close_dialog_confirm.dart | 15 +++++++-------- .../lib/features/dialogs/study_dialogs.dart | 2 +- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart index ab3aa2d6b..d0ddf8ca9 100644 --- a/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart @@ -15,8 +15,7 @@ class CloseConfirmationDialog extends StudyPageWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = ref.watch(studyControllerProvider(studyId).notifier); - final formViewModel = - ref.watch(studySettingsFormViewModelProvider(studyId)); + final formViewModel = ref.watch(studySettingsFormViewModelProvider(studyId)); return ReactiveForm( formGroup: formViewModel.form, @@ -31,12 +30,12 @@ class CloseConfirmationDialog extends StudyPageWidget { children: [ Flexible( child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - text: TextSpan( - text: tr.dialog_study_close_description, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + text: tr.dialog_study_close_description, ), ), ], diff --git a/designer_v2/lib/features/dialogs/study_dialogs.dart b/designer_v2/lib/features/dialogs/study_dialogs.dart index 8ecee70f9..bd22ccf7e 100644 --- a/designer_v2/lib/features/dialogs/study_dialogs.dart +++ b/designer_v2/lib/features/dialogs/study_dialogs.dart @@ -20,7 +20,7 @@ class StudyDialog extends StudyPageWidget { Widget build(BuildContext context, WidgetRef ref) { final state = ref.watch(studyControllerProvider(studyId)); - switch(dialogType) { + switch (dialogType) { case StudyDialogType.publish: return state.isPublished ? PublishSuccessDialog(studyId) : PublishConfirmationDialog(studyId); case StudyDialogType.close: From acdbb1c2c1274dc4f6bbb6e89156b83326cd4afc Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Mon, 10 Jun 2024 09:55:22 +0200 Subject: [PATCH 07/22] chore: copy migration to schema --- core/lib/src/models/tables/study.g.dart | 10 +- .../20240526_migrate_close_study.sql | 24 ++- database/studyu-schema.sql | 173 ++++++++++++++---- .../20240526_migrate_close_study.sql | 1 - 4 files changed, 158 insertions(+), 50 deletions(-) delete mode 120000 supabase/migrations/20240526_migrate_close_study.sql diff --git a/core/lib/src/models/tables/study.g.dart b/core/lib/src/models/tables/study.g.dart index 78f3de9de..85629996b 100644 --- a/core/lib/src/models/tables/study.g.dart +++ b/core/lib/src/models/tables/study.g.dart @@ -19,7 +19,7 @@ Study _$StudyFromJson(Map json) => Study( ..contact = Study._contactFromJson(json['contact']) ..iconName = json['icon_name'] as String? ?? 'accountHeart' ..published = json['published'] as bool? ?? false - ..isClosed = json['is_closed'] as bool? ?? false + ..status = $enumDecode(_$StudyStatusEnumMap, json['status']) ..questionnaire = Study._questionnaireFromJson(json['questionnaire']) ..eligibilityCriteria = Study._eligibilityCriteriaFromJson(json['eligibility_criteria']) @@ -67,7 +67,7 @@ Map _$StudyToJson(Study instance) { val['contact'] = instance.contact.toJson(); val['icon_name'] = instance.iconName; val['published'] = instance.published; - val['is_closed'] = instance.isClosed; + val['status'] = instance.status.toJson(); val['questionnaire'] = instance.questionnaire.toJson(); val['eligibility_criteria'] = instance.eligibilityCriteria.map((e) => e.toJson()).toList(); @@ -92,3 +92,9 @@ const _$ResultSharingEnumMap = { ResultSharing.private: 'private', ResultSharing.organization: 'organization', }; + +const _$StudyStatusEnumMap = { + StudyStatus.draft: 'draft', + StudyStatus.running: 'running', + StudyStatus.closed: 'closed', +}; diff --git a/database/migration/20240526_migrate_close_study.sql b/database/migration/20240526_migrate_close_study.sql index b5d44e7e8..dd5d804c8 100644 --- a/database/migration/20240526_migrate_close_study.sql +++ b/database/migration/20240526_migrate_close_study.sql @@ -6,6 +6,8 @@ CREATE TYPE public.study_status AS ENUM ( 'closed' ); +ALTER TYPE public.study_status OWNER TO postgres; + ALTER TABLE public.study ADD COLUMN status public.study_status DEFAULT 'draft'::public.study_status NOT NULL; @@ -17,13 +19,13 @@ UPDATE public.study SET status = CASE END; -- Migrate policy -DROP POLICY "Editors can do everything with their studies" ON public.study; +--DROP POLICY "Editors can do everything with their studies" ON public.study; CREATE POLICY "Editors can view their studies" ON public.study FOR SELECT USING (auth.uid() = user_id); -CREATE POLICY "Editor can control their draft studies" ON public.study - --USING (public.can_edit(auth.uid(), study.*) AND status = 'draft'::public.study_status); - USING (public.can_edit(auth.uid(), study.*)); +--CREATE POLICY "Editor can control their draft studies" ON public.study + -- old --USING (public.can_edit(auth.uid(), study.*) AND status = 'draft'::public.study_status); +-- USING (public.can_edit(auth.uid(), study.*)); -- Editors can only update registry_published and resultSharing --grant update (registry_published, result_sharing) on public.study USING (public.can_edit(auth.uid(), study.*); @@ -38,8 +40,7 @@ CREATE POLICY "Editor can control their draft studies" ON public.study -- https://github.com/orgs/supabase/discussions/656#discussioncomment-5594653 --- todo document this function -CREATE OR REPLACE FUNCTION public.allow_updating_only() +CREATE OR REPLACE FUNCTION public.allow_updating_only_study() RETURNS trigger LANGUAGE plpgsql AS $function$ @@ -52,17 +53,17 @@ DECLARE old_value TEXT; BEGIN - -- If the current user is 'postgres', return NEW without making any changes + -- The user 'postgres' should be able to update any record, e.g. when using Supabase Studio IF CURRENT_USER = 'postgres' THEN RETURN NEW; END IF; - -- If the status is draft, return NEW without making any changes + -- In draft status allow update of all columns IF OLD.status = 'draft'::public.study_status THEN RETURN NEW; END IF; - -- Don't allow invalid status transitions + -- Only allow status to be updated from draft to running and from running to closed IF OLD.status != NEW.status THEN IF NOT ( (OLD.status = 'draft'::public.study_status AND NEW.status = 'running'::public.study_status) @@ -108,11 +109,14 @@ BEGIN END; $function$; +ALTER FUNCTION public.allow_updating_only_study() OWNER TO postgres; + +-- Only allow updating status, registry_published and result_sharing of the study table when in draft mode CREATE OR REPLACE TRIGGER study_status_update_permissions BEFORE UPDATE ON public.study FOR EACH ROW - EXECUTE FUNCTION public.allow_updating_only('updated_at', 'status', 'registry_published', 'result_sharing'); + EXECUTE FUNCTION public.allow_updating_only_study('updated_at', 'status', 'registry_published', 'result_sharing'); -- todo also add participation? -- Owners can update status diff --git a/database/studyu-schema.sql b/database/studyu-schema.sql index b202d5ef4..083ece6dc 100644 --- a/database/studyu-schema.sql +++ b/database/studyu-schema.sql @@ -66,6 +66,14 @@ CREATE TYPE public.result_sharing AS ENUM ( ALTER TYPE public.result_sharing OWNER TO postgres; +CREATE TYPE public.study_status AS ENUM ( + 'draft', + 'running', + 'closed' +); + +ALTER TYPE public.study_status OWNER TO postgres; + SET default_tablespace = ''; SET default_table_access_method = heap; @@ -82,6 +90,7 @@ CREATE TABLE public.study ( icon_name text NOT NULL, -- published is deprecated, use status instead published boolean DEFAULT false NOT NULL, + status public.study_status DEFAULT 'draft'::public.study_status NOT NULL, registry_published boolean DEFAULT false NOT NULL, questionnaire jsonb NOT NULL, eligibility_criteria jsonb NOT NULL, @@ -486,6 +495,78 @@ $$; ALTER FUNCTION public.user_email(user_id uuid) OWNER TO postgres; + +CREATE OR REPLACE FUNCTION public.allow_updating_only_study() + RETURNS trigger + LANGUAGE plpgsql +AS $function$ +DECLARE + whitelist TEXT[] := TG_ARGV::TEXT[]; + schema_table TEXT; + column_name TEXT; + rec RECORD; + new_value TEXT; + old_value TEXT; +BEGIN + + -- The user 'postgres' should be able to update any record, e.g. when using Supabase Studio + IF CURRENT_USER = 'postgres' THEN + RETURN NEW; + END IF; + + -- In draft status allow update of all columns + IF OLD.status = 'draft'::public.study_status THEN + RETURN NEW; + END IF; + + -- Only allow status to be updated from draft to running and from running to closed + IF OLD.status != NEW.status THEN + IF NOT ( + (OLD.status = 'draft'::public.study_status AND NEW.status = 'running'::public.study_status) + OR (OLD.status = 'running'::public.study_status AND NEW.status = 'closed'::public.study_status) + ) THEN + RAISE EXCEPTION 'Invalid status transition'; + END IF; + END IF; + + schema_table := concat(TG_TABLE_SCHEMA, '.', TG_TABLE_NAME); + + -- If RLS is not active on current table for function invoker, early return + IF NOT row_security_active(schema_table) THEN + RETURN NEW; + END IF; + + -- Otherwise, loop on all columns of the table schema + FOR rec IN ( + SELECT col.column_name + FROM information_schema.columns as col + WHERE table_schema = TG_TABLE_SCHEMA + AND table_name = TG_TABLE_NAME + ) LOOP + -- If the current column is whitelisted, early continue + column_name := rec.column_name; + IF column_name = ANY(whitelist) THEN + CONTINUE; + END IF; + + -- If not whitelisted, execute dynamic SQL to get column value from OLD and NEW records + EXECUTE format('SELECT ($1).%I, ($2).%I', column_name, column_name) + INTO new_value, old_value + USING NEW, OLD; + + -- Raise exception if column value changed + IF new_value IS DISTINCT FROM old_value THEN + RAISE EXCEPTION 'Unauthorized change to "%"', column_name; + END IF; + END LOOP; + + -- RLS active, but no exception encountered, clear to proceed. + RETURN NEW; +END; +$function$; + +ALTER FUNCTION public.allow_updating_only_study() OWNER TO postgres; + -- -- Name: app_config; Type: TABLE; Schema: public; Owner: postgres -- @@ -686,6 +767,12 @@ CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXEC CREATE TRIGGER handle_updated_at BEFORE UPDATE ON public.study FOR EACH ROW EXECUTE FUNCTION extensions.moddatetime('updated_at'); +-- Only allow updating status, registry_published and result_sharing of the study table when in draft mode +CREATE OR REPLACE TRIGGER study_status_update_permissions + BEFORE UPDATE + ON public.study + FOR EACH ROW + EXECUTE FUNCTION public.allow_updating_only_study('updated_at', 'status', 'registry_published', 'result_sharing'); -- -- Name: subject_progress participant_progress_subjectId_fkey; Type: FK CONSTRAINT; Schema: public; Owner: postgres @@ -781,6 +868,8 @@ CREATE POLICY "Study creators can do everything with repos from their studies" O CREATE POLICY "Study subjects can view their joined study" ON public.study FOR SELECT USING (public.is_study_subject_of(auth.uid(), id)); +CREATE POLICY "Editors can view their studies" ON public.study FOR SELECT USING (auth.uid() = user_id); + -- -- Name: study Editors can do everything with their studies; Type: POLICY; Schema: public; Owner: postgres -- @@ -876,92 +965,102 @@ USING (public.has_results_public(id)); CREATE POLICY "Allow users to manage their own user" ON public."user" FOR ALL USING (auth.uid() = id); -- --- Name: app_config; Type: ROW SECURITY; Schema: public; Owner: postgres +-- Name: create blob storage bucket for observations; Type: value; Schema: storage; Owner: postgres -- -ALTER TABLE public.app_config ENABLE ROW LEVEL SECURITY; +INSERT INTO storage.buckets (id, name) VALUES ('observations', 'observations'); -- --- Name: repo; Type: ROW SECURITY; Schema: public; Owner: postgres +-- Name: authenticated Users can view their uploaded data; Type: POLICY, Schema: storage -- -ALTER TABLE public.repo ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Allow authenticated Users to view own observations" ON storage.objects FOR +SELECT +TO authenticated USING (((bucket_id = 'observations'::text) AND (owner = auth.uid()))); -- --- Name: study; Type: ROW SECURITY; Schema: public; Owner: postgres +-- Name: authenticated Users can upload observations to storage; Type: POLICY, Schema: storage -- -ALTER TABLE public.study ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Allow authenticated Users to upload observations" ON storage.objects FOR +INSERT +TO authenticated WITH CHECK ((bucket_id = 'observations'::text)); -- --- Name: study_invite; Type: ROW SECURITY; Schema: public; Owner: postgres +-- Name: authenticated Users can delete own observations; Type: POLICY, Schema: storage -- -ALTER TABLE public.study_invite ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Allow authenticated Users to delete own observations" ON storage.objects FOR +DELETE +TO authenticated USING (((bucket_id = 'observations'::text) AND (owner = auth.uid()))); -- --- Name: study_subject; Type: ROW SECURITY; Schema: public; Owner: postgres +-- Name: Researchers can view observations of studies which they created; Type: POLICY, Schema: storage -- -ALTER TABLE public.study_subject ENABLE ROW LEVEL SECURITY; +CREATE POLICY "Allow Researchers to view observations of own studies" ON storage.objects FOR +SELECT +TO public USING (((bucket_id = 'observations'::text) AND + (name ~~ ANY (SELECT ('%'::text || ((public.study.id)::text || '%'::text)) AS study_id + FROM public.study + WHERE ((public.study.user_id)::text = (auth.uid())::text))))); + +CREATE POLICY "Joining a closed study should not be possible" ON public.study_subject + AS RESTRICTIVE + FOR INSERT + WITH CHECK (NOT EXISTS ( + SELECT 1 + FROM public.study + WHERE study.id = study_subject.study_id + AND study.status = 'closed'::public.study_status +)); -- --- Name: subject_progress; Type: ROW SECURITY; Schema: public; Owner: postgres +-- Name: app_config; Type: ROW SECURITY; Schema: public; Owner: postgres -- -ALTER TABLE public.subject_progress ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.app_config ENABLE ROW LEVEL SECURITY; -- --- Name: user; Type: ROW SECURITY; Schema: public; Owner: postgres +-- Name: repo; Type: ROW SECURITY; Schema: public; Owner: postgres -- -ALTER TABLE public."user" ENABLE ROW LEVEL SECURITY; +ALTER TABLE public.repo ENABLE ROW LEVEL SECURITY; -- --- Name: study_progress_export; Type: ROW SECURITY; Schema: public; Owner: postgres +-- Name: study; Type: ROW SECURITY; Schema: public; Owner: postgres -- -ALTER VIEW public.study_progress_export SET (security_invoker = on); +ALTER TABLE public.study ENABLE ROW LEVEL SECURITY; -- --- Name: create blob storage bucket for observations; Type: value; Schema: storage; Owner: postgres +-- Name: study_invite; Type: ROW SECURITY; Schema: public; Owner: postgres -- -INSERT INTO storage.buckets (id, name) VALUES ('observations', 'observations'); +ALTER TABLE public.study_invite ENABLE ROW LEVEL SECURITY; -- --- Name: authenticated Users can view their uploaded data; Type: POLICY, Schema: storage +-- Name: study_subject; Type: ROW SECURITY; Schema: public; Owner: postgres -- -CREATE POLICY "Allow authenticated Users to view own observations" ON storage.objects FOR -SELECT -TO authenticated USING (((bucket_id = 'observations'::text) AND (owner = auth.uid()))); +ALTER TABLE public.study_subject ENABLE ROW LEVEL SECURITY; -- --- Name: authenticated Users can upload observations to storage; Type: POLICY, Schema: storage +-- Name: subject_progress; Type: ROW SECURITY; Schema: public; Owner: postgres -- -CREATE POLICY "Allow authenticated Users to upload observations" ON storage.objects FOR -INSERT -TO authenticated WITH CHECK ((bucket_id = 'observations'::text)); +ALTER TABLE public.subject_progress ENABLE ROW LEVEL SECURITY; -- --- Name: authenticated Users can delete own observations; Type: POLICY, Schema: storage +-- Name: user; Type: ROW SECURITY; Schema: public; Owner: postgres -- -CREATE POLICY "Allow authenticated Users to delete own observations" ON storage.objects FOR -DELETE -TO authenticated USING (((bucket_id = 'observations'::text) AND (owner = auth.uid()))); +ALTER TABLE public."user" ENABLE ROW LEVEL SECURITY; -- --- Name: Researchers can view observations of studies which they created; Type: POLICY, Schema: storage +-- Name: study_progress_export; Type: ROW SECURITY; Schema: public; Owner: postgres -- -CREATE POLICY "Allow Researchers to view observations of own studies" ON storage.objects FOR -SELECT -TO public USING (((bucket_id = 'observations'::text) AND - (name ~~ ANY (SELECT ('%'::text || ((public.study.id)::text || '%'::text)) AS study_id - FROM public.study - WHERE ((public.study.user_id)::text = (auth.uid())::text))))); +ALTER VIEW public.study_progress_export SET (security_invoker = on); COMMIT; diff --git a/supabase/migrations/20240526_migrate_close_study.sql b/supabase/migrations/20240526_migrate_close_study.sql deleted file mode 120000 index 5cfba03e0..000000000 --- a/supabase/migrations/20240526_migrate_close_study.sql +++ /dev/null @@ -1 +0,0 @@ -../../database/migration/20240526_migrate_close_study.sql \ No newline at end of file From aed1effbb5cd2e7fba01872514908b0255b12528 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 17:23:17 +0200 Subject: [PATCH 08/22] fix: merge dashboard refactor pr --- designer_v2/lib/repositories/api_client.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/designer_v2/lib/repositories/api_client.dart b/designer_v2/lib/repositories/api_client.dart index 0a6971a18..8348c0cb3 100644 --- a/designer_v2/lib/repositories/api_client.dart +++ b/designer_v2/lib/repositories/api_client.dart @@ -78,7 +78,7 @@ class StudyUApiClient extends SupabaseClientDependant with SupabaseQueryMixin im 'user_id', 'participation', 'result_sharing', - 'published', + 'status', 'registry_published', 'study_participant_count', 'study_ended_count', From 051552d259ab28ebda107cc9fa75f78923883ff5 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 17:23:59 +0200 Subject: [PATCH 09/22] chore: fix seed --- supabase/seed.sql | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/supabase/seed.sql b/supabase/seed.sql index 672244d29..c4f606cdc 100644 --- a/supabase/seed.sql +++ b/supabase/seed.sql @@ -28,7 +28,7 @@ BEGIN title, description, icon_name, - published, + status, registry_published, questionnaire, eligibility_criteria, @@ -49,8 +49,8 @@ BEGIN 'Public demo study of user1', 'This is a Demo Study. This study helps you find out which treatment is more effective for you.', 'accountHeart', - -- published - true, + -- status + 'running', -- registry_published true, '[{"id": "recent_back_pain", "type": "boolean", "prompt": "Have you had back pain in the last 12 weeks?", "rationale": ""}, {"id": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "type": "boolean", "prompt": "Are you pregnant?", "rationale": ""}, {"id": "afeac253-4bfe-47fe-9384-4236ded1bd50", "type": "choice", "prompt": "Does any of the following apply to you and has not been examined by a doctor yet?", "choices": [{"id": "start_of_symptoms_after_spinal_surgery", "text": "Start of symptoms after spinal surgery"}, {"id": "start_of_symptoms_after_diagnosis_of_cancer", "text": "Start of symptoms after diagnosis of cancer"}, {"id": "unexpected_significant_weight_loss", "text": "Unexpected significant weight loss"}, {"id": "start_of_symptoms_after_trauma", "text": "Start of symptoms after trauma"}, {"id": "accompanying_numbness_of_your_legs", "text": "Accompanying numbness of your legs"}], "multiple": true, "rationale": "This question is asked to ensure that you are not suffering from any critical illness."}]','[{"id": "b47d07d8-eb98-4fce-86ab-945bc7c2f2d0", "condition": {"type": "choice", "target": "recent_back_pain", "choices": [true]}}, {"id": "acb368b0-0ca2-496d-98e1-be9fefbe5e89", "condition": {"type": "choice", "target": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "choices": [false]}}, {"id": "d7c3445e-b5b1-43d1-93a6-5637e0cfd44f", "condition": {"type": "choice", "target": "afeac253-4bfe-47fe-9384-4236ded1bd50", "choices": []}}]','[{"id": "rate_your_day", "type": "questionnaire", "title": "Rate your day", "footer": "", "header": "", "schedule": {"reminders": ["19:00"], "completionPeriods": [{"id": "50d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "8:00"}]}, "questions": [{"id": "pain", "step": 1, "type": "visualAnalogue", "prompt": "Rate your pain.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "Well, guess I die now", "minimumAnnotation": "no pain"}, {"id": "painkillers", "type": "boolean", "prompt": "Have you taken any painkillers in the last 24 hours?"}, {"id": "sleep", "step": 1, "type": "visualAnalogue", "prompt": "Rate your sleep.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "terrible", "minimumAnnotation": "no problems"}, {"id": "mood", "step": 1, "type": "annotatedScale", "prompt": "Rate your mood.", "initial": 5, "maximum": 10, "minimum": 0, "annotations": [{"value": 0, "annotation": "☠"}, {"value": 5, "annotation": "😐"}, {"value": 10, "annotation": "😀"}]}]}]','[{"id": "willow_bark_tea", "icon": "coffee", "name": "Willow-Bark tea", "tasks": [{"id": "drink_tea", "type": "checkmark", "title": "Drink a cup of Willow-Bark tea twice a day.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "54d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Willow bark contains powerful anti-inflamatory compunds such as flavonoids and salicin that help relieve the pain."}, {"id": "arnika", "icon": "leaf", "name": "Arnika", "tasks": [{"id": "apply_arnika", "type": "checkmark", "title": "Apply a dime sized amount of Arnica gel to your lower back and massage for 10 mins.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "55d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Arnika gel has been proven to soothe muscle soreness, strain and reduce swelling when rubbed on the affected area."}, {"id": "warming_pad", "icon": "car-seat-heater", "name": "Warming Pad", "tasks": [{"id": "use_pad", "type": "checkmark", "title": "Apply warming pad to your lower back for 5 minutes.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "56d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Applying a warming pad is a quick and easy way to soothe sore muscles and joints."}]','[{"id": "Need", "title": "Why Consent Is Needed", "iconName": "featureSearch", "description": "We need your explicit consent because you are going to enroll to a research study. Therefore, we have to provide you all the information that could potentially have an impact on your decision whether or not you take part in the study. This is study-specific so please go through it carefully. Participation is entirely voluntary."}, {"id": "Risk_benefit", "title": "Risks & Benefits", "iconName": "signCaution", "description": "The main risks to you if you choose to participate are allergic reactions to one of the interventions you are going to apply. If you feel uncomfortable, suffer from itching or rash please pause the study until you have seen a doctor. It is important to know that you may not get any benefit from taking part in this research. Others may not benefit either. However, study results may help you to understand if one of the offered interventions has a positive effect on one of the investigated observations for you."}, {"id": "Data", "title": "Data Handling & Use", "iconName": "databaseExport", "description": "By giving your consent you accept that researchers are allowed to process anonymized data collected from you during this study for research purposes. If you stop being in the research study, already transmitted information may not be removed from the research study database because re-identification is not possible. It will continue to be used to complete the research analysis."}, {"id": "Issues", "title": "Issues to Consider", "iconName": "mapClock", "description": "For being able to use your results for research we need you to actively participate for the indicated minimum study duration. After reaching this you will be able to unlock results but we encourage you to take part at least until you reach the recommended level on the progress bar. Otherwise it might be the case that results can indeed be used for research but are not meaningful for you personally. Note that if you decide to take part in this research study you will be responsible for buying the needed aids."}, {"id": "Rights", "title": "Participant Rights", "iconName": "gavel", "description": "You may stop taking part in this research study at any time without any penalty. If you have any questions, concerns, or complaints at any time about this research, or you think the research has harmed you, please contact the office of the research team. You can find the contact details in your personal study dashboard."}, {"id": "Future", "title": "Future Research", "iconName": "binoculars", "description": "The purpose of this research study is to help you find the most effective supporting agent or behavior. The aim is not to treat your symptom causally. In a broader perspective the purpose of this study is also to get insights which group of persons benefits most from which intervention."}]','{"sequence": "alternating", "phaseDuration": 7, "numberOfCycles": 2, "sequenceCustom": "ABAB", "includeBaseline": true}','{"primary": {"id": "average", "type": "average", "title": "Average", "aggregate": "day", "description": "Average", "resultProperty": {"task": "rate_your_day", "property": "pain"}}, "secondary": []}', From dbd18102117af3768a7452e5585126f75ca0f5f1 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 17:24:40 +0200 Subject: [PATCH 10/22] fix: study visibility policy no longer uses published field --- database/migration/20240526_migrate_close_study.sql | 7 +++++++ database/studyu-schema.sql | 6 ++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/database/migration/20240526_migrate_close_study.sql b/database/migration/20240526_migrate_close_study.sql index dd5d804c8..5451cbfa7 100644 --- a/database/migration/20240526_migrate_close_study.sql +++ b/database/migration/20240526_migrate_close_study.sql @@ -21,6 +21,13 @@ END; -- Migrate policy --DROP POLICY "Editors can do everything with their studies" ON public.study; +DROP POLICY "Everybody can view designated published studies" ON public.study; + +CREATE POLICY "Study visibility" ON public.study FOR SELECT +USING ((status = 'running'::public.study_status OR status = 'closed'::public.study_status) +AND (registry_published = true OR participation = 'open'::public.participation OR result_sharing = 'public'::public.result_sharing)); +-- todo should we allow draft studies in registry if they have been published? + CREATE POLICY "Editors can view their studies" ON public.study FOR SELECT USING (auth.uid() = user_id); --CREATE POLICY "Editor can control their draft studies" ON public.study diff --git a/database/studyu-schema.sql b/database/studyu-schema.sql index 083ece6dc..b57a283b5 100644 --- a/database/studyu-schema.sql +++ b/database/studyu-schema.sql @@ -878,10 +878,12 @@ CREATE POLICY "Editors can do everything with their studies" ON public.study USI -- --- Name: study Everybody can view (published and registry_published) studies; Type: POLICY; Schema: public; Owner: postgres +-- Name: study Study visibility; Type: POLICY; Schema: public; Owner: postgres -- -CREATE POLICY "Everybody can view designated published studies" ON public.study FOR SELECT USING (((published = true) AND (registry_published = true OR participation = 'open'::public.participation OR result_sharing = 'public'::public.result_sharing))); +CREATE POLICY "Study visibility" ON public.study FOR SELECT +USING ((status = 'running'::public.study_status OR status = 'closed'::public.study_status) +AND (registry_published = true OR participation = 'open'::public.participation OR result_sharing = 'public'::public.result_sharing)); -- From 1ce2f78c9866b0b2869ec9485071e8e3e65fa50f Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 18:12:07 +0200 Subject: [PATCH 11/22] fix: potential isClosed error --- designer_v2/lib/repositories/model_repository.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/designer_v2/lib/repositories/model_repository.dart b/designer_v2/lib/repositories/model_repository.dart index d910bd2f3..c2415a57f 100644 --- a/designer_v2/lib/repositories/model_repository.dart +++ b/designer_v2/lib/repositories/model_repository.dart @@ -304,7 +304,7 @@ abstract class ModelRepository extends IModelRepository { if (fetchOnSubscribe) { if (!(wrappedModel != null && wrappedModel.isLocalOnly)) { fetch(modelId).catchError((e) { - if (!modelController.closed) { + if (!modelController.isClosed) { modelController.addError(e); } return e; From 7dcc928442a2bd2a3e8358802857459f5896f984 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 18:12:26 +0200 Subject: [PATCH 12/22] fix: migrate app to status --- core/lib/src/models/tables/study.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/lib/src/models/tables/study.dart b/core/lib/src/models/tables/study.dart index b2d37d748..382eccb05 100644 --- a/core/lib/src/models/tables/study.dart +++ b/core/lib/src/models/tables/study.dart @@ -231,7 +231,7 @@ class Study extends SupabaseObjectFunctions implements Comparable static Future> publishedPublicStudies() async { ExtractionResult result; try { - final response = await env.client.from(tableName).select().eq('participation', 'open').eq("closed", false); + final response = await env.client.from(tableName).select().eq('participation', 'open').neq('status', StudyStatus.closed.name); final extracted = SupabaseQuery.extractSupabaseList(List>.from(response)); result = ExtractionSuccess(extracted); } on ExtractionFailedException catch (error) { From 1cb7740c89337ef20b2c6570cffa6643cfbc6bfd Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 18:12:52 +0200 Subject: [PATCH 13/22] fix: do not show separator if no delete item shown --- designer_v2/lib/repositories/study_repository.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/designer_v2/lib/repositories/study_repository.dart b/designer_v2/lib/repositories/study_repository.dart index 10980492b..8515f6470 100644 --- a/designer_v2/lib/repositories/study_repository.dart +++ b/designer_v2/lib/repositories/study_repository.dart @@ -175,7 +175,7 @@ class StudyRepository extends ModelRepository implements IStudyRepository }, isAvailable: model.canExport(currentUser), ), - ModelAction.addSeparator(), + if (model.canDelete(currentUser)) ModelAction.addSeparator(), ModelAction( type: StudyActionType.delete, label: StudyActionType.delete.string, From b6dad92a4e0f0dc2a17c9b201fb233de17acd127 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 18:13:24 +0200 Subject: [PATCH 14/22] fix: only show close button for editors --- designer_v2/lib/features/study/study_controller_state.dart | 3 +++ designer_v2/lib/features/study/study_scaffold.dart | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/designer_v2/lib/features/study/study_controller_state.dart b/designer_v2/lib/features/study/study_controller_state.dart index bd31b0813..ddae66487 100644 --- a/designer_v2/lib/features/study/study_controller_state.dart +++ b/designer_v2/lib/features/study/study_controller_state.dart @@ -69,6 +69,9 @@ class StudyControllerState extends StudyControllerBaseState implements IStudyApp @override bool get isPublishVisible => studyWithMetadata?.model.status == StudyStatus.draft; + @override + bool get isClosedVisible => studyWithMetadata?.model.status == StudyStatus.running && studyWithMetadata!.model.canEdit(super.currentUser); + @override StudyStatus? get studyStatus => study.value?.status; diff --git a/designer_v2/lib/features/study/study_scaffold.dart b/designer_v2/lib/features/study/study_scaffold.dart index 4536e38bc..b261601f2 100644 --- a/designer_v2/lib/features/study/study_scaffold.dart +++ b/designer_v2/lib/features/study/study_scaffold.dart @@ -26,6 +26,7 @@ abstract class IStudyAppBarViewModel implements IStudyStatusBadgeViewModel, IStu bool get isSyncIndicatorVisible; bool get isStatusBadgeVisible; bool get isPublishVisible; + bool get isClosedVisible; } /// Custom scaffold shared between all pages for an individual [Study] @@ -237,7 +238,7 @@ class _StudyScaffoldState extends ConsumerState { actionButtons.add(const SizedBox(width: 12.0)); // padding } - if (state.isPublished) { + if (state.isClosedVisible) { final formViewModel = ref.watch(studyPublishValidatorProvider(widget.studyId)); final closeButton = ReactiveForm( formGroup: formViewModel.form, From 547c468c2c5e52912bb7cc8a9833d4534a07556a Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 18:30:52 +0200 Subject: [PATCH 15/22] fix: add closed success description --- .../lib/features/dialogs/close/study_close_dialog_success.dart | 2 +- designer_v2/lib/localization/app_de.arb | 3 ++- designer_v2/lib/localization/app_en.arb | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart index c08726a0e..47e852758 100644 --- a/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart @@ -24,7 +24,7 @@ class CloseSuccessDialog extends StudyPageWidget { fontSize: (theme.textTheme.displayLarge?.fontSize ?? 48.0) * 1.5, )), title: tr.notification_study_closed, - description: '', + description: tr.notification_study_closed_description, ), const SizedBox(height: 8.0), ], diff --git a/designer_v2/lib/localization/app_de.arb b/designer_v2/lib/localization/app_de.arb index e19eebf00..b69ad6487 100644 --- a/designer_v2/lib/localization/app_de.arb +++ b/designer_v2/lib/localization/app_de.arb @@ -121,8 +121,9 @@ "action_button_study_close": "Studie schließen", "notification_study_deleted": "Die Studie wurde gelöscht", "notification_study_closed": "Die Studie wurde geschlossen", + "notification_study_closed_description": "Neue Teilnehmer können sich nicht mehr einschreiben", "dialog_study_close_title": "Teilnahme schließen?", - "dialog_study_close_description": "Bist du sicher, dass die Teilnahme an der Studie geschlossen werden soll? Es können keine neuen Teilnehmer mehr eingeschrieben werden.", + "dialog_study_close_description": "Bist du sicher, dass die Teilnahme an der Studie geschlossen werden soll? Es können keine neuen Teilnehmer mehr eingeschrieben werden, aber bereits angemeldete Teilnehmer können die Studie weiterhin durchführen.", "dialog_study_delete_title": "Dauerhaft löschen?", "dialog_study_delete_description": "Bist du sicher, dass die Studie gelöscht werden soll? Die Studie und alle gesammelten Daten gehen dabei unwiderruflich verloren.", "@__________________STUDYPAGE_DESIGN_SHARED__________________": {}, diff --git a/designer_v2/lib/localization/app_en.arb b/designer_v2/lib/localization/app_en.arb index 12b88af00..8e428367a 100644 --- a/designer_v2/lib/localization/app_en.arb +++ b/designer_v2/lib/localization/app_en.arb @@ -121,8 +121,9 @@ "action_button_study_close": "Close study", "notification_study_deleted": "Study was deleted", "notification_study_closed": "Study was closed", + "notification_study_closed_description": "New participants can no longer enroll in this study.", "dialog_study_close_title": "Close participation?", - "dialog_study_close_description": "Are you sure that you want to close participation for this study? New participants can no longer enroll.", + "dialog_study_close_description": "Are you sure that you want to close participation for this study? New participants can no longer enroll, but enrolled participants can continue to participate.", "dialog_study_delete_title": "Permanently delete?", "dialog_study_delete_description": "Are you sure you want to delete this study? You will permanently lose the study and all data that has been collected.", "@__________________STUDYPAGE_DESIGN_SHARED__________________": {}, From 4beb796aae314ec8bcc27d7f1f738c6866698993 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 18:37:07 +0200 Subject: [PATCH 16/22] style: format --- core/lib/src/models/tables/study.dart | 3 ++- designer_v2/lib/features/study/study_controller_state.dart | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/core/lib/src/models/tables/study.dart b/core/lib/src/models/tables/study.dart index 382eccb05..1267c6f17 100644 --- a/core/lib/src/models/tables/study.dart +++ b/core/lib/src/models/tables/study.dart @@ -231,7 +231,8 @@ class Study extends SupabaseObjectFunctions implements Comparable static Future> publishedPublicStudies() async { ExtractionResult result; try { - final response = await env.client.from(tableName).select().eq('participation', 'open').neq('status', StudyStatus.closed.name); + final response = + await env.client.from(tableName).select().eq('participation', 'open').neq('status', StudyStatus.closed.name); final extracted = SupabaseQuery.extractSupabaseList(List>.from(response)); result = ExtractionSuccess(extracted); } on ExtractionFailedException catch (error) { diff --git a/designer_v2/lib/features/study/study_controller_state.dart b/designer_v2/lib/features/study/study_controller_state.dart index ddae66487..13f02e1e2 100644 --- a/designer_v2/lib/features/study/study_controller_state.dart +++ b/designer_v2/lib/features/study/study_controller_state.dart @@ -70,7 +70,8 @@ class StudyControllerState extends StudyControllerBaseState implements IStudyApp bool get isPublishVisible => studyWithMetadata?.model.status == StudyStatus.draft; @override - bool get isClosedVisible => studyWithMetadata?.model.status == StudyStatus.running && studyWithMetadata!.model.canEdit(super.currentUser); + bool get isClosedVisible => + studyWithMetadata?.model.status == StudyStatus.running && studyWithMetadata!.model.canEdit(super.currentUser); @override StudyStatus? get studyStatus => study.value?.status; From 9ffc7ce1fdebf2605fdd7970d519e05288aae7a0 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 18:52:21 +0200 Subject: [PATCH 17/22] tests: migrate db test to status --- supabase/tests/010-study.sql | 58 ++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/supabase/tests/010-study.sql b/supabase/tests/010-study.sql index b7e9ee6ae..bf235df24 100644 --- a/supabase/tests/010-study.sql +++ b/supabase/tests/010-study.sql @@ -20,7 +20,7 @@ INSERT INTO public.study ( title, description, icon_name, - published, + status, registry_published, questionnaire, eligibility_criteria, @@ -38,11 +38,11 @@ INSERT INTO public.study ( collaborator_emails ) VALUES( '{"email":"example@example.com","phone":"0123456789","website":"https://studyu.health","researchers":"StudyU Researcher","organization":"StudyU","institutionalReviewBoard":"This study has not been submitted to the IRB Board. It is for illustration purposes of StudyU only.","institutionalReviewBoardNumber":"N/A"}', - 'Study: published=true, registry_published=true, participation=open, result_sharing=public', + 'Study: status=running, registry_published=true, participation=open, result_sharing=public', 'This is a Demo Study. This study helps you find out which treatment is more effective for you.', 'accountHeart', - -- published - true, + -- status + 'running', -- registry_published true, '[{"id": "recent_back_pain", "type": "boolean", "prompt": "Have you had back pain in the last 12 weeks?", "rationale": ""}, {"id": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "type": "boolean", "prompt": "Are you pregnant?", "rationale": ""}, {"id": "afeac253-4bfe-47fe-9384-4236ded1bd50", "type": "choice", "prompt": "Does any of the following apply to you and has not been examined by a doctor yet?", "choices": [{"id": "start_of_symptoms_after_spinal_surgery", "text": "Start of symptoms after spinal surgery"}, {"id": "start_of_symptoms_after_diagnosis_of_cancer", "text": "Start of symptoms after diagnosis of cancer"}, {"id": "unexpected_significant_weight_loss", "text": "Unexpected significant weight loss"}, {"id": "start_of_symptoms_after_trauma", "text": "Start of symptoms after trauma"}, {"id": "accompanying_numbness_of_your_legs", "text": "Accompanying numbness of your legs"}], "multiple": true, "rationale": "This question is asked to ensure that you are not suffering from any critical illness."}]','[{"id": "b47d07d8-eb98-4fce-86ab-945bc7c2f2d0", "condition": {"type": "choice", "target": "recent_back_pain", "choices": [true]}}, {"id": "acb368b0-0ca2-496d-98e1-be9fefbe5e89", "condition": {"type": "choice", "target": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "choices": [false]}}, {"id": "d7c3445e-b5b1-43d1-93a6-5637e0cfd44f", "condition": {"type": "choice", "target": "afeac253-4bfe-47fe-9384-4236ded1bd50", "choices": []}}]','[{"id": "rate_your_day", "type": "questionnaire", "title": "Rate your day", "footer": "", "header": "", "schedule": {"reminders": ["19:00"], "completionPeriods": [{"id": "50d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "8:00"}]}, "questions": [{"id": "pain", "step": 1, "type": "visualAnalogue", "prompt": "Rate your pain.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "Well, guess I die now", "minimumAnnotation": "no pain"}, {"id": "painkillers", "type": "boolean", "prompt": "Have you taken any painkillers in the last 24 hours?"}, {"id": "sleep", "step": 1, "type": "visualAnalogue", "prompt": "Rate your sleep.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "terrible", "minimumAnnotation": "no problems"}, {"id": "mood", "step": 1, "type": "annotatedScale", "prompt": "Rate your mood.", "initial": 5, "maximum": 10, "minimum": 0, "annotations": [{"value": 0, "annotation": "☠"}, {"value": 5, "annotation": "😐"}, {"value": 10, "annotation": "😀"}]}]}]','[{"id": "willow_bark_tea", "icon": "coffee", "name": "Willow-Bark tea", "tasks": [{"id": "drink_tea", "type": "checkmark", "title": "Drink a cup of Willow-Bark tea twice a day.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "54d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Willow bark contains powerful anti-inflamatory compunds such as flavonoids and salicin that help relieve the pain."}, {"id": "arnika", "icon": "leaf", "name": "Arnika", "tasks": [{"id": "apply_arnika", "type": "checkmark", "title": "Apply a dime sized amount of Arnica gel to your lower back and massage for 10 mins.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "55d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Arnika gel has been proven to soothe muscle soreness, strain and reduce swelling when rubbed on the affected area."}, {"id": "warming_pad", "icon": "car-seat-heater", "name": "Warming Pad", "tasks": [{"id": "use_pad", "type": "checkmark", "title": "Apply warming pad to your lower back for 5 minutes.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "56d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Applying a warming pad is a quick and easy way to soothe sore muscles and joints."}]','[{"id": "Need", "title": "Why Consent Is Needed", "iconName": "featureSearch", "description": "We need your explicit consent because you are going to enroll to a research study. Therefore, we have to provide you all the information that could potentially have an impact on your decision whether or not you take part in the study. This is study-specific so please go through it carefully. Participation is entirely voluntary."}, {"id": "Risk_benefit", "title": "Risks & Benefits", "iconName": "signCaution", "description": "The main risks to you if you choose to participate are allergic reactions to one of the interventions you are going to apply. If you feel uncomfortable, suffer from itching or rash please pause the study until you have seen a doctor. It is important to know that you may not get any benefit from taking part in this research. Others may not benefit either. However, study results may help you to understand if one of the offered interventions has a positive effect on one of the investigated observations for you."}, {"id": "Data", "title": "Data Handling & Use", "iconName": "databaseExport", "description": "By giving your consent you accept that researchers are allowed to process anonymized data collected from you during this study for research purposes. If you stop being in the research study, already transmitted information may not be removed from the research study database because re-identification is not possible. It will continue to be used to complete the research analysis."}, {"id": "Issues", "title": "Issues to Consider", "iconName": "mapClock", "description": "For being able to use your results for research we need you to actively participate for the indicated minimum study duration. After reaching this you will be able to unlock results but we encourage you to take part at least until you reach the recommended level on the progress bar. Otherwise it might be the case that results can indeed be used for research but are not meaningful for you personally. Note that if you decide to take part in this research study you will be responsible for buying the needed aids."}, {"id": "Rights", "title": "Participant Rights", "iconName": "gavel", "description": "You may stop taking part in this research study at any time without any penalty. If you have any questions, concerns, or complaints at any time about this research, or you think the research has harmed you, please contact the office of the research team. You can find the contact details in your personal study dashboard."}, {"id": "Future", "title": "Future Research", "iconName": "binoculars", "description": "The purpose of this research study is to help you find the most effective supporting agent or behavior. The aim is not to treat your symptom causally. In a broader perspective the purpose of this study is also to get insights which group of persons benefits most from which intervention."}]','{"sequence": "alternating", "phaseDuration": 7, "numberOfCycles": 2, "sequenceCustom": "ABAB", "includeBaseline": true}','{"primary": {"id": "average", "type": "average", "title": "Average", "aggregate": "day", "description": "Average", "resultProperty": {"task": "rate_your_day", "property": "pain"}}, "secondary": []}', @@ -63,7 +63,7 @@ INSERT INTO public.study ( title, description, icon_name, - published, + status, registry_published, questionnaire, eligibility_criteria, @@ -81,11 +81,11 @@ INSERT INTO public.study ( collaborator_emails ) VALUES( '{"email":"example@example.com","phone":"0123456789","website":"https://studyu.health","researchers":"StudyU Researcher","organization":"StudyU","institutionalReviewBoard":"This study has not been submitted to the IRB Board. It is for illustration purposes of StudyU only.","institutionalReviewBoardNumber":"N/A"}', - 'Study: published=true, registry_published=false, participation=open, result_sharing=public', + 'Study: status=running, registry_published=false, participation=open, result_sharing=public', 'This is a Demo Study. This study helps you find out which treatment is more effective for you.', 'accountHeart', - -- published - true, + -- status + 'running', -- registry_published false, '[{"id": "recent_back_pain", "type": "boolean", "prompt": "Have you had back pain in the last 12 weeks?", "rationale": ""}, {"id": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "type": "boolean", "prompt": "Are you pregnant?", "rationale": ""}, {"id": "afeac253-4bfe-47fe-9384-4236ded1bd50", "type": "choice", "prompt": "Does any of the following apply to you and has not been examined by a doctor yet?", "choices": [{"id": "start_of_symptoms_after_spinal_surgery", "text": "Start of symptoms after spinal surgery"}, {"id": "start_of_symptoms_after_diagnosis_of_cancer", "text": "Start of symptoms after diagnosis of cancer"}, {"id": "unexpected_significant_weight_loss", "text": "Unexpected significant weight loss"}, {"id": "start_of_symptoms_after_trauma", "text": "Start of symptoms after trauma"}, {"id": "accompanying_numbness_of_your_legs", "text": "Accompanying numbness of your legs"}], "multiple": true, "rationale": "This question is asked to ensure that you are not suffering from any critical illness."}]','[{"id": "b47d07d8-eb98-4fce-86ab-945bc7c2f2d0", "condition": {"type": "choice", "target": "recent_back_pain", "choices": [true]}}, {"id": "acb368b0-0ca2-496d-98e1-be9fefbe5e89", "condition": {"type": "choice", "target": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "choices": [false]}}, {"id": "d7c3445e-b5b1-43d1-93a6-5637e0cfd44f", "condition": {"type": "choice", "target": "afeac253-4bfe-47fe-9384-4236ded1bd50", "choices": []}}]','[{"id": "rate_your_day", "type": "questionnaire", "title": "Rate your day", "footer": "", "header": "", "schedule": {"reminders": ["19:00"], "completionPeriods": [{"id": "50d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "8:00"}]}, "questions": [{"id": "pain", "step": 1, "type": "visualAnalogue", "prompt": "Rate your pain.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "Well, guess I die now", "minimumAnnotation": "no pain"}, {"id": "painkillers", "type": "boolean", "prompt": "Have you taken any painkillers in the last 24 hours?"}, {"id": "sleep", "step": 1, "type": "visualAnalogue", "prompt": "Rate your sleep.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "terrible", "minimumAnnotation": "no problems"}, {"id": "mood", "step": 1, "type": "annotatedScale", "prompt": "Rate your mood.", "initial": 5, "maximum": 10, "minimum": 0, "annotations": [{"value": 0, "annotation": "☠"}, {"value": 5, "annotation": "😐"}, {"value": 10, "annotation": "😀"}]}]}]','[{"id": "willow_bark_tea", "icon": "coffee", "name": "Willow-Bark tea", "tasks": [{"id": "drink_tea", "type": "checkmark", "title": "Drink a cup of Willow-Bark tea twice a day.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "54d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Willow bark contains powerful anti-inflamatory compunds such as flavonoids and salicin that help relieve the pain."}, {"id": "arnika", "icon": "leaf", "name": "Arnika", "tasks": [{"id": "apply_arnika", "type": "checkmark", "title": "Apply a dime sized amount of Arnica gel to your lower back and massage for 10 mins.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "55d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Arnika gel has been proven to soothe muscle soreness, strain and reduce swelling when rubbed on the affected area."}, {"id": "warming_pad", "icon": "car-seat-heater", "name": "Warming Pad", "tasks": [{"id": "use_pad", "type": "checkmark", "title": "Apply warming pad to your lower back for 5 minutes.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "56d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Applying a warming pad is a quick and easy way to soothe sore muscles and joints."}]','[{"id": "Need", "title": "Why Consent Is Needed", "iconName": "featureSearch", "description": "We need your explicit consent because you are going to enroll to a research study. Therefore, we have to provide you all the information that could potentially have an impact on your decision whether or not you take part in the study. This is study-specific so please go through it carefully. Participation is entirely voluntary."}, {"id": "Risk_benefit", "title": "Risks & Benefits", "iconName": "signCaution", "description": "The main risks to you if you choose to participate are allergic reactions to one of the interventions you are going to apply. If you feel uncomfortable, suffer from itching or rash please pause the study until you have seen a doctor. It is important to know that you may not get any benefit from taking part in this research. Others may not benefit either. However, study results may help you to understand if one of the offered interventions has a positive effect on one of the investigated observations for you."}, {"id": "Data", "title": "Data Handling & Use", "iconName": "databaseExport", "description": "By giving your consent you accept that researchers are allowed to process anonymized data collected from you during this study for research purposes. If you stop being in the research study, already transmitted information may not be removed from the research study database because re-identification is not possible. It will continue to be used to complete the research analysis."}, {"id": "Issues", "title": "Issues to Consider", "iconName": "mapClock", "description": "For being able to use your results for research we need you to actively participate for the indicated minimum study duration. After reaching this you will be able to unlock results but we encourage you to take part at least until you reach the recommended level on the progress bar. Otherwise it might be the case that results can indeed be used for research but are not meaningful for you personally. Note that if you decide to take part in this research study you will be responsible for buying the needed aids."}, {"id": "Rights", "title": "Participant Rights", "iconName": "gavel", "description": "You may stop taking part in this research study at any time without any penalty. If you have any questions, concerns, or complaints at any time about this research, or you think the research has harmed you, please contact the office of the research team. You can find the contact details in your personal study dashboard."}, {"id": "Future", "title": "Future Research", "iconName": "binoculars", "description": "The purpose of this research study is to help you find the most effective supporting agent or behavior. The aim is not to treat your symptom causally. In a broader perspective the purpose of this study is also to get insights which group of persons benefits most from which intervention."}]','{"sequence": "alternating", "phaseDuration": 7, "numberOfCycles": 2, "sequenceCustom": "ABAB", "includeBaseline": true}','{"primary": {"id": "average", "type": "average", "title": "Average", "aggregate": "day", "description": "Average", "resultProperty": {"task": "rate_your_day", "property": "pain"}}, "secondary": []}', @@ -106,7 +106,7 @@ INSERT INTO public.study ( title, description, icon_name, - published, + status, registry_published, questionnaire, eligibility_criteria, @@ -124,11 +124,11 @@ INSERT INTO public.study ( collaborator_emails ) VALUES( '{"email":"example@example.com","phone":"0123456789","website":"https://studyu.health","researchers":"StudyU Researcher","organization":"StudyU","institutionalReviewBoard":"This study has not been submitted to the IRB Board. It is for illustration purposes of StudyU only.","institutionalReviewBoardNumber":"N/A"}', - 'Study: published=true, registry_published=false, participation=invite, result_sharing=public', + 'Study: status=running, registry_published=false, participation=invite, result_sharing=public', 'This is a Demo Study. This study helps you find out which treatment is more effective for you.', 'accountHeart', - -- published - true, + -- status + 'running', -- registry_published false, '[{"id": "recent_back_pain", "type": "boolean", "prompt": "Have you had back pain in the last 12 weeks?", "rationale": ""}, {"id": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "type": "boolean", "prompt": "Are you pregnant?", "rationale": ""}, {"id": "afeac253-4bfe-47fe-9384-4236ded1bd50", "type": "choice", "prompt": "Does any of the following apply to you and has not been examined by a doctor yet?", "choices": [{"id": "start_of_symptoms_after_spinal_surgery", "text": "Start of symptoms after spinal surgery"}, {"id": "start_of_symptoms_after_diagnosis_of_cancer", "text": "Start of symptoms after diagnosis of cancer"}, {"id": "unexpected_significant_weight_loss", "text": "Unexpected significant weight loss"}, {"id": "start_of_symptoms_after_trauma", "text": "Start of symptoms after trauma"}, {"id": "accompanying_numbness_of_your_legs", "text": "Accompanying numbness of your legs"}], "multiple": true, "rationale": "This question is asked to ensure that you are not suffering from any critical illness."}]','[{"id": "b47d07d8-eb98-4fce-86ab-945bc7c2f2d0", "condition": {"type": "choice", "target": "recent_back_pain", "choices": [true]}}, {"id": "acb368b0-0ca2-496d-98e1-be9fefbe5e89", "condition": {"type": "choice", "target": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "choices": [false]}}, {"id": "d7c3445e-b5b1-43d1-93a6-5637e0cfd44f", "condition": {"type": "choice", "target": "afeac253-4bfe-47fe-9384-4236ded1bd50", "choices": []}}]','[{"id": "rate_your_day", "type": "questionnaire", "title": "Rate your day", "footer": "", "header": "", "schedule": {"reminders": ["19:00"], "completionPeriods": [{"id": "50d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "8:00"}]}, "questions": [{"id": "pain", "step": 1, "type": "visualAnalogue", "prompt": "Rate your pain.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "Well, guess I die now", "minimumAnnotation": "no pain"}, {"id": "painkillers", "type": "boolean", "prompt": "Have you taken any painkillers in the last 24 hours?"}, {"id": "sleep", "step": 1, "type": "visualAnalogue", "prompt": "Rate your sleep.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "terrible", "minimumAnnotation": "no problems"}, {"id": "mood", "step": 1, "type": "annotatedScale", "prompt": "Rate your mood.", "initial": 5, "maximum": 10, "minimum": 0, "annotations": [{"value": 0, "annotation": "☠"}, {"value": 5, "annotation": "😐"}, {"value": 10, "annotation": "😀"}]}]}]','[{"id": "willow_bark_tea", "icon": "coffee", "name": "Willow-Bark tea", "tasks": [{"id": "drink_tea", "type": "checkmark", "title": "Drink a cup of Willow-Bark tea twice a day.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "54d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Willow bark contains powerful anti-inflamatory compunds such as flavonoids and salicin that help relieve the pain."}, {"id": "arnika", "icon": "leaf", "name": "Arnika", "tasks": [{"id": "apply_arnika", "type": "checkmark", "title": "Apply a dime sized amount of Arnica gel to your lower back and massage for 10 mins.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "55d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Arnika gel has been proven to soothe muscle soreness, strain and reduce swelling when rubbed on the affected area."}, {"id": "warming_pad", "icon": "car-seat-heater", "name": "Warming Pad", "tasks": [{"id": "use_pad", "type": "checkmark", "title": "Apply warming pad to your lower back for 5 minutes.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "56d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Applying a warming pad is a quick and easy way to soothe sore muscles and joints."}]','[{"id": "Need", "title": "Why Consent Is Needed", "iconName": "featureSearch", "description": "We need your explicit consent because you are going to enroll to a research study. Therefore, we have to provide you all the information that could potentially have an impact on your decision whether or not you take part in the study. This is study-specific so please go through it carefully. Participation is entirely voluntary."}, {"id": "Risk_benefit", "title": "Risks & Benefits", "iconName": "signCaution", "description": "The main risks to you if you choose to participate are allergic reactions to one of the interventions you are going to apply. If you feel uncomfortable, suffer from itching or rash please pause the study until you have seen a doctor. It is important to know that you may not get any benefit from taking part in this research. Others may not benefit either. However, study results may help you to understand if one of the offered interventions has a positive effect on one of the investigated observations for you."}, {"id": "Data", "title": "Data Handling & Use", "iconName": "databaseExport", "description": "By giving your consent you accept that researchers are allowed to process anonymized data collected from you during this study for research purposes. If you stop being in the research study, already transmitted information may not be removed from the research study database because re-identification is not possible. It will continue to be used to complete the research analysis."}, {"id": "Issues", "title": "Issues to Consider", "iconName": "mapClock", "description": "For being able to use your results for research we need you to actively participate for the indicated minimum study duration. After reaching this you will be able to unlock results but we encourage you to take part at least until you reach the recommended level on the progress bar. Otherwise it might be the case that results can indeed be used for research but are not meaningful for you personally. Note that if you decide to take part in this research study you will be responsible for buying the needed aids."}, {"id": "Rights", "title": "Participant Rights", "iconName": "gavel", "description": "You may stop taking part in this research study at any time without any penalty. If you have any questions, concerns, or complaints at any time about this research, or you think the research has harmed you, please contact the office of the research team. You can find the contact details in your personal study dashboard."}, {"id": "Future", "title": "Future Research", "iconName": "binoculars", "description": "The purpose of this research study is to help you find the most effective supporting agent or behavior. The aim is not to treat your symptom causally. In a broader perspective the purpose of this study is also to get insights which group of persons benefits most from which intervention."}]','{"sequence": "alternating", "phaseDuration": 7, "numberOfCycles": 2, "sequenceCustom": "ABAB", "includeBaseline": true}','{"primary": {"id": "average", "type": "average", "title": "Average", "aggregate": "day", "description": "Average", "resultProperty": {"task": "rate_your_day", "property": "pain"}}, "secondary": []}', @@ -150,7 +150,7 @@ INSERT INTO public.study ( title, description, icon_name, - published, + status, registry_published, questionnaire, eligibility_criteria, @@ -168,11 +168,11 @@ INSERT INTO public.study ( collaborator_emails ) VALUES( '{"email":"example@example.com","phone":"0123456789","website":"https://studyu.health","researchers":"StudyU Researcher","organization":"StudyU","institutionalReviewBoard":"This study has not been submitted to the IRB Board. It is for illustration purposes of StudyU only.","institutionalReviewBoardNumber":"N/A"}', - 'Study: published=true, registry_published=false, participation=invite, result_sharing=private', + 'Study: status=running, registry_published=false, participation=invite, result_sharing=private', 'This is a Demo Study. This study helps you find out which treatment is more effective for you.', 'accountHeart', - -- published - true, + -- status + 'running', -- registry_published false, '[{"id": "recent_back_pain", "type": "boolean", "prompt": "Have you had back pain in the last 12 weeks?", "rationale": ""}, {"id": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "type": "boolean", "prompt": "Are you pregnant?", "rationale": ""}, {"id": "afeac253-4bfe-47fe-9384-4236ded1bd50", "type": "choice", "prompt": "Does any of the following apply to you and has not been examined by a doctor yet?", "choices": [{"id": "start_of_symptoms_after_spinal_surgery", "text": "Start of symptoms after spinal surgery"}, {"id": "start_of_symptoms_after_diagnosis_of_cancer", "text": "Start of symptoms after diagnosis of cancer"}, {"id": "unexpected_significant_weight_loss", "text": "Unexpected significant weight loss"}, {"id": "start_of_symptoms_after_trauma", "text": "Start of symptoms after trauma"}, {"id": "accompanying_numbness_of_your_legs", "text": "Accompanying numbness of your legs"}], "multiple": true, "rationale": "This question is asked to ensure that you are not suffering from any critical illness."}]','[{"id": "b47d07d8-eb98-4fce-86ab-945bc7c2f2d0", "condition": {"type": "choice", "target": "recent_back_pain", "choices": [true]}}, {"id": "acb368b0-0ca2-496d-98e1-be9fefbe5e89", "condition": {"type": "choice", "target": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "choices": [false]}}, {"id": "d7c3445e-b5b1-43d1-93a6-5637e0cfd44f", "condition": {"type": "choice", "target": "afeac253-4bfe-47fe-9384-4236ded1bd50", "choices": []}}]','[{"id": "rate_your_day", "type": "questionnaire", "title": "Rate your day", "footer": "", "header": "", "schedule": {"reminders": ["19:00"], "completionPeriods": [{"id": "50d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "8:00"}]}, "questions": [{"id": "pain", "step": 1, "type": "visualAnalogue", "prompt": "Rate your pain.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "Well, guess I die now", "minimumAnnotation": "no pain"}, {"id": "painkillers", "type": "boolean", "prompt": "Have you taken any painkillers in the last 24 hours?"}, {"id": "sleep", "step": 1, "type": "visualAnalogue", "prompt": "Rate your sleep.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "terrible", "minimumAnnotation": "no problems"}, {"id": "mood", "step": 1, "type": "annotatedScale", "prompt": "Rate your mood.", "initial": 5, "maximum": 10, "minimum": 0, "annotations": [{"value": 0, "annotation": "☠"}, {"value": 5, "annotation": "😐"}, {"value": 10, "annotation": "😀"}]}]}]','[{"id": "willow_bark_tea", "icon": "coffee", "name": "Willow-Bark tea", "tasks": [{"id": "drink_tea", "type": "checkmark", "title": "Drink a cup of Willow-Bark tea twice a day.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "54d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Willow bark contains powerful anti-inflamatory compunds such as flavonoids and salicin that help relieve the pain."}, {"id": "arnika", "icon": "leaf", "name": "Arnika", "tasks": [{"id": "apply_arnika", "type": "checkmark", "title": "Apply a dime sized amount of Arnica gel to your lower back and massage for 10 mins.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "55d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Arnika gel has been proven to soothe muscle soreness, strain and reduce swelling when rubbed on the affected area."}, {"id": "warming_pad", "icon": "car-seat-heater", "name": "Warming Pad", "tasks": [{"id": "use_pad", "type": "checkmark", "title": "Apply warming pad to your lower back for 5 minutes.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "56d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Applying a warming pad is a quick and easy way to soothe sore muscles and joints."}]','[{"id": "Need", "title": "Why Consent Is Needed", "iconName": "featureSearch", "description": "We need your explicit consent because you are going to enroll to a research study. Therefore, we have to provide you all the information that could potentially have an impact on your decision whether or not you take part in the study. This is study-specific so please go through it carefully. Participation is entirely voluntary."}, {"id": "Risk_benefit", "title": "Risks & Benefits", "iconName": "signCaution", "description": "The main risks to you if you choose to participate are allergic reactions to one of the interventions you are going to apply. If you feel uncomfortable, suffer from itching or rash please pause the study until you have seen a doctor. It is important to know that you may not get any benefit from taking part in this research. Others may not benefit either. However, study results may help you to understand if one of the offered interventions has a positive effect on one of the investigated observations for you."}, {"id": "Data", "title": "Data Handling & Use", "iconName": "databaseExport", "description": "By giving your consent you accept that researchers are allowed to process anonymized data collected from you during this study for research purposes. If you stop being in the research study, already transmitted information may not be removed from the research study database because re-identification is not possible. It will continue to be used to complete the research analysis."}, {"id": "Issues", "title": "Issues to Consider", "iconName": "mapClock", "description": "For being able to use your results for research we need you to actively participate for the indicated minimum study duration. After reaching this you will be able to unlock results but we encourage you to take part at least until you reach the recommended level on the progress bar. Otherwise it might be the case that results can indeed be used for research but are not meaningful for you personally. Note that if you decide to take part in this research study you will be responsible for buying the needed aids."}, {"id": "Rights", "title": "Participant Rights", "iconName": "gavel", "description": "You may stop taking part in this research study at any time without any penalty. If you have any questions, concerns, or complaints at any time about this research, or you think the research has harmed you, please contact the office of the research team. You can find the contact details in your personal study dashboard."}, {"id": "Future", "title": "Future Research", "iconName": "binoculars", "description": "The purpose of this research study is to help you find the most effective supporting agent or behavior. The aim is not to treat your symptom causally. In a broader perspective the purpose of this study is also to get insights which group of persons benefits most from which intervention."}]','{"sequence": "alternating", "phaseDuration": 7, "numberOfCycles": 2, "sequenceCustom": "ABAB", "includeBaseline": true}','{"primary": {"id": "average", "type": "average", "title": "Average", "aggregate": "day", "description": "Average", "resultProperty": {"task": "rate_your_day", "property": "pain"}}, "secondary": []}', @@ -198,7 +198,7 @@ INSERT INTO public.study ( title, description, icon_name, - published, + status, registry_published, questionnaire, eligibility_criteria, @@ -216,11 +216,11 @@ INSERT INTO public.study ( collaborator_emails ) VALUES( '{"email":"example@example.com","phone":"0123456789","website":"https://studyu.health","researchers":"StudyU Researcher","organization":"StudyU","institutionalReviewBoard":"This study has not been submitted to the IRB Board. It is for illustration purposes of StudyU only.","institutionalReviewBoardNumber":"N/A"}', - 'Study: published=false, registry_published=true, participation=open, result_sharing=public', + 'Study: status=draft, registry_published=true, participation=open, result_sharing=public', 'This is a Demo Study. This study helps you find out which treatment is more effective for you.', 'accountHeart', - -- published - false, + -- status + 'draft', -- registry_published true, '[{"id": "recent_back_pain", "type": "boolean", "prompt": "Have you had back pain in the last 12 weeks?", "rationale": ""}, {"id": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "type": "boolean", "prompt": "Are you pregnant?", "rationale": ""}, {"id": "afeac253-4bfe-47fe-9384-4236ded1bd50", "type": "choice", "prompt": "Does any of the following apply to you and has not been examined by a doctor yet?", "choices": [{"id": "start_of_symptoms_after_spinal_surgery", "text": "Start of symptoms after spinal surgery"}, {"id": "start_of_symptoms_after_diagnosis_of_cancer", "text": "Start of symptoms after diagnosis of cancer"}, {"id": "unexpected_significant_weight_loss", "text": "Unexpected significant weight loss"}, {"id": "start_of_symptoms_after_trauma", "text": "Start of symptoms after trauma"}, {"id": "accompanying_numbness_of_your_legs", "text": "Accompanying numbness of your legs"}], "multiple": true, "rationale": "This question is asked to ensure that you are not suffering from any critical illness."}]','[{"id": "b47d07d8-eb98-4fce-86ab-945bc7c2f2d0", "condition": {"type": "choice", "target": "recent_back_pain", "choices": [true]}}, {"id": "acb368b0-0ca2-496d-98e1-be9fefbe5e89", "condition": {"type": "choice", "target": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "choices": [false]}}, {"id": "d7c3445e-b5b1-43d1-93a6-5637e0cfd44f", "condition": {"type": "choice", "target": "afeac253-4bfe-47fe-9384-4236ded1bd50", "choices": []}}]','[{"id": "rate_your_day", "type": "questionnaire", "title": "Rate your day", "footer": "", "header": "", "schedule": {"reminders": ["19:00"], "completionPeriods": [{"id": "50d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "8:00"}]}, "questions": [{"id": "pain", "step": 1, "type": "visualAnalogue", "prompt": "Rate your pain.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "Well, guess I die now", "minimumAnnotation": "no pain"}, {"id": "painkillers", "type": "boolean", "prompt": "Have you taken any painkillers in the last 24 hours?"}, {"id": "sleep", "step": 1, "type": "visualAnalogue", "prompt": "Rate your sleep.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "terrible", "minimumAnnotation": "no problems"}, {"id": "mood", "step": 1, "type": "annotatedScale", "prompt": "Rate your mood.", "initial": 5, "maximum": 10, "minimum": 0, "annotations": [{"value": 0, "annotation": "☠"}, {"value": 5, "annotation": "😐"}, {"value": 10, "annotation": "😀"}]}]}]','[{"id": "willow_bark_tea", "icon": "coffee", "name": "Willow-Bark tea", "tasks": [{"id": "drink_tea", "type": "checkmark", "title": "Drink a cup of Willow-Bark tea twice a day.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "54d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Willow bark contains powerful anti-inflamatory compunds such as flavonoids and salicin that help relieve the pain."}, {"id": "arnika", "icon": "leaf", "name": "Arnika", "tasks": [{"id": "apply_arnika", "type": "checkmark", "title": "Apply a dime sized amount of Arnica gel to your lower back and massage for 10 mins.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "55d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Arnika gel has been proven to soothe muscle soreness, strain and reduce swelling when rubbed on the affected area."}, {"id": "warming_pad", "icon": "car-seat-heater", "name": "Warming Pad", "tasks": [{"id": "use_pad", "type": "checkmark", "title": "Apply warming pad to your lower back for 5 minutes.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "56d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Applying a warming pad is a quick and easy way to soothe sore muscles and joints."}]','[{"id": "Need", "title": "Why Consent Is Needed", "iconName": "featureSearch", "description": "We need your explicit consent because you are going to enroll to a research study. Therefore, we have to provide you all the information that could potentially have an impact on your decision whether or not you take part in the study. This is study-specific so please go through it carefully. Participation is entirely voluntary."}, {"id": "Risk_benefit", "title": "Risks & Benefits", "iconName": "signCaution", "description": "The main risks to you if you choose to participate are allergic reactions to one of the interventions you are going to apply. If you feel uncomfortable, suffer from itching or rash please pause the study until you have seen a doctor. It is important to know that you may not get any benefit from taking part in this research. Others may not benefit either. However, study results may help you to understand if one of the offered interventions has a positive effect on one of the investigated observations for you."}, {"id": "Data", "title": "Data Handling & Use", "iconName": "databaseExport", "description": "By giving your consent you accept that researchers are allowed to process anonymized data collected from you during this study for research purposes. If you stop being in the research study, already transmitted information may not be removed from the research study database because re-identification is not possible. It will continue to be used to complete the research analysis."}, {"id": "Issues", "title": "Issues to Consider", "iconName": "mapClock", "description": "For being able to use your results for research we need you to actively participate for the indicated minimum study duration. After reaching this you will be able to unlock results but we encourage you to take part at least until you reach the recommended level on the progress bar. Otherwise it might be the case that results can indeed be used for research but are not meaningful for you personally. Note that if you decide to take part in this research study you will be responsible for buying the needed aids."}, {"id": "Rights", "title": "Participant Rights", "iconName": "gavel", "description": "You may stop taking part in this research study at any time without any penalty. If you have any questions, concerns, or complaints at any time about this research, or you think the research has harmed you, please contact the office of the research team. You can find the contact details in your personal study dashboard."}, {"id": "Future", "title": "Future Research", "iconName": "binoculars", "description": "The purpose of this research study is to help you find the most effective supporting agent or behavior. The aim is not to treat your symptom causally. In a broader perspective the purpose of this study is also to get insights which group of persons benefits most from which intervention."}]','{"sequence": "alternating", "phaseDuration": 7, "numberOfCycles": 2, "sequenceCustom": "ABAB", "includeBaseline": true}','{"primary": {"id": "average", "type": "average", "title": "Average", "aggregate": "day", "description": "Average", "resultProperty": {"task": "rate_your_day", "property": "pain"}}, "secondary": []}', @@ -241,7 +241,7 @@ INSERT INTO public.study ( title, description, icon_name, - published, + status, registry_published, questionnaire, eligibility_criteria, @@ -259,11 +259,11 @@ INSERT INTO public.study ( collaborator_emails ) VALUES( '{"email":"example@example.com","phone":"0123456789","website":"https://studyu.health","researchers":"StudyU Researcher","organization":"StudyU","institutionalReviewBoard":"This study has not been submitted to the IRB Board. It is for illustration purposes of StudyU only.","institutionalReviewBoardNumber":"N/A"}', - 'Study: published=false, registry_published=false, participation=invite, result_sharing=private', + 'Study: status=draft, registry_published=false, participation=invite, result_sharing=private', 'This is a Demo Study. This study helps you find out which treatment is more effective for you.', 'accountHeart', - -- published - false, + -- status + 'draft', -- registry_published false, '[{"id": "recent_back_pain", "type": "boolean", "prompt": "Have you had back pain in the last 12 weeks?", "rationale": ""}, {"id": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "type": "boolean", "prompt": "Are you pregnant?", "rationale": ""}, {"id": "afeac253-4bfe-47fe-9384-4236ded1bd50", "type": "choice", "prompt": "Does any of the following apply to you and has not been examined by a doctor yet?", "choices": [{"id": "start_of_symptoms_after_spinal_surgery", "text": "Start of symptoms after spinal surgery"}, {"id": "start_of_symptoms_after_diagnosis_of_cancer", "text": "Start of symptoms after diagnosis of cancer"}, {"id": "unexpected_significant_weight_loss", "text": "Unexpected significant weight loss"}, {"id": "start_of_symptoms_after_trauma", "text": "Start of symptoms after trauma"}, {"id": "accompanying_numbness_of_your_legs", "text": "Accompanying numbness of your legs"}], "multiple": true, "rationale": "This question is asked to ensure that you are not suffering from any critical illness."}]','[{"id": "b47d07d8-eb98-4fce-86ab-945bc7c2f2d0", "condition": {"type": "choice", "target": "recent_back_pain", "choices": [true]}}, {"id": "acb368b0-0ca2-496d-98e1-be9fefbe5e89", "condition": {"type": "choice", "target": "f70386c8-f517-4e62-a5fa-ca4badfd4b60", "choices": [false]}}, {"id": "d7c3445e-b5b1-43d1-93a6-5637e0cfd44f", "condition": {"type": "choice", "target": "afeac253-4bfe-47fe-9384-4236ded1bd50", "choices": []}}]','[{"id": "rate_your_day", "type": "questionnaire", "title": "Rate your day", "footer": "", "header": "", "schedule": {"reminders": ["19:00"], "completionPeriods": [{"id": "50d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "8:00"}]}, "questions": [{"id": "pain", "step": 1, "type": "visualAnalogue", "prompt": "Rate your pain.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "Well, guess I die now", "minimumAnnotation": "no pain"}, {"id": "painkillers", "type": "boolean", "prompt": "Have you taken any painkillers in the last 24 hours?"}, {"id": "sleep", "step": 1, "type": "visualAnalogue", "prompt": "Rate your sleep.", "initial": 0, "maximum": 10, "minimum": 0, "maximumColor": 4294901760, "minimumColor": 4294967295, "maximumAnnotation": "terrible", "minimumAnnotation": "no problems"}, {"id": "mood", "step": 1, "type": "annotatedScale", "prompt": "Rate your mood.", "initial": 5, "maximum": 10, "minimum": 0, "annotations": [{"value": 0, "annotation": "☠"}, {"value": 5, "annotation": "😐"}, {"value": 10, "annotation": "😀"}]}]}]','[{"id": "willow_bark_tea", "icon": "coffee", "name": "Willow-Bark tea", "tasks": [{"id": "drink_tea", "type": "checkmark", "title": "Drink a cup of Willow-Bark tea twice a day.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "54d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Willow bark contains powerful anti-inflamatory compunds such as flavonoids and salicin that help relieve the pain."}, {"id": "arnika", "icon": "leaf", "name": "Arnika", "tasks": [{"id": "apply_arnika", "type": "checkmark", "title": "Apply a dime sized amount of Arnica gel to your lower back and massage for 10 mins.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "55d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Arnika gel has been proven to soothe muscle soreness, strain and reduce swelling when rubbed on the affected area."}, {"id": "warming_pad", "icon": "car-seat-heater", "name": "Warming Pad", "tasks": [{"id": "use_pad", "type": "checkmark", "title": "Apply warming pad to your lower back for 5 minutes.", "schedule": {"reminders": ["18:00"], "completionPeriods": [{"id": "56d114f3-e692-4283-9610-17e23edf8f70", "lockTime": "20:00", "unlockTime": "6:00"}]}}], "description": "Applying a warming pad is a quick and easy way to soothe sore muscles and joints."}]','[{"id": "Need", "title": "Why Consent Is Needed", "iconName": "featureSearch", "description": "We need your explicit consent because you are going to enroll to a research study. Therefore, we have to provide you all the information that could potentially have an impact on your decision whether or not you take part in the study. This is study-specific so please go through it carefully. Participation is entirely voluntary."}, {"id": "Risk_benefit", "title": "Risks & Benefits", "iconName": "signCaution", "description": "The main risks to you if you choose to participate are allergic reactions to one of the interventions you are going to apply. If you feel uncomfortable, suffer from itching or rash please pause the study until you have seen a doctor. It is important to know that you may not get any benefit from taking part in this research. Others may not benefit either. However, study results may help you to understand if one of the offered interventions has a positive effect on one of the investigated observations for you."}, {"id": "Data", "title": "Data Handling & Use", "iconName": "databaseExport", "description": "By giving your consent you accept that researchers are allowed to process anonymized data collected from you during this study for research purposes. If you stop being in the research study, already transmitted information may not be removed from the research study database because re-identification is not possible. It will continue to be used to complete the research analysis."}, {"id": "Issues", "title": "Issues to Consider", "iconName": "mapClock", "description": "For being able to use your results for research we need you to actively participate for the indicated minimum study duration. After reaching this you will be able to unlock results but we encourage you to take part at least until you reach the recommended level on the progress bar. Otherwise it might be the case that results can indeed be used for research but are not meaningful for you personally. Note that if you decide to take part in this research study you will be responsible for buying the needed aids."}, {"id": "Rights", "title": "Participant Rights", "iconName": "gavel", "description": "You may stop taking part in this research study at any time without any penalty. If you have any questions, concerns, or complaints at any time about this research, or you think the research has harmed you, please contact the office of the research team. You can find the contact details in your personal study dashboard."}, {"id": "Future", "title": "Future Research", "iconName": "binoculars", "description": "The purpose of this research study is to help you find the most effective supporting agent or behavior. The aim is not to treat your symptom causally. In a broader perspective the purpose of this study is also to get insights which group of persons benefits most from which intervention."}]','{"sequence": "alternating", "phaseDuration": 7, "numberOfCycles": 2, "sequenceCustom": "ABAB", "includeBaseline": true}','{"primary": {"id": "average", "type": "average", "title": "Average", "aggregate": "day", "description": "Average", "resultProperty": {"task": "rate_your_day", "property": "pain"}}, "secondary": []}', @@ -299,7 +299,7 @@ SELECT is(count(*)::int, 3, 'Check if users were created and can be accessed') F SELECT tests.clear_authentication(); SELECT is(count(*)::int, 0, 'Check if users cannot be accessed anonymously') FROM public.user; -SELECT is(published, true, 'Check if all anonymously accessed studies are published') FROM public.study; +SELECT is(status, 'running', 'Check if all anonymously accessed studies are running') FROM public.study; -- CREATOR 1 TESTS @@ -315,12 +315,12 @@ SELECT is(email, 'test_creator_2@studyu.health', 'Check if a user can only retri SELECT tests.authenticate_as('test_consumer'); --- test specific: all published studies are created by test_creator_1 -SELECT is(user_id, (tests.get_supabase_user('test_creator_1') ->> 'id')::uuid, 'All published studies are created by test_creator_1') FROM public.study; +-- test specific: all running studies are created by test_creator_1 +SELECT is(user_id, (tests.get_supabase_user('test_creator_1') ->> 'id')::uuid, 'All running studies are created by test_creator_1') FROM public.study; SELECT is(count(*)::int, 3, 'Verify number of accessible studies') FROM public.study; --- check if the consumer can only access designated published studies -SELECT is(published, true, 'Check if test_consumer can only access studies that are published') FROM public.study; +-- check if the consumer can only access designated running studies +SELECT is(status, 'running', 'Check if test_consumer can only access studies that are running') FROM public.study; SELECT tests.is_either_true( 'Check if test_consumer can only retrieve designated studies', tests.is_equal(registry_published, true), From 7c5b8aeebb08f1a14c1ca9f591ba18899347589d Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Tue, 11 Jun 2024 18:54:11 +0200 Subject: [PATCH 18/22] tests: fix workflow trigger --- .github/workflows/supabase-test.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/supabase-test.yml b/.github/workflows/supabase-test.yml index 54eccfa7a..1635a5d56 100644 --- a/.github/workflows/supabase-test.yml +++ b/.github/workflows/supabase-test.yml @@ -2,8 +2,9 @@ name: Test Supabase on: push: paths: - - ".github/workflows/supabase-test.yml" - "database/**" + - "supabase/**" + - ".github/workflows/supabase-test.yml" workflow_dispatch: jobs: From e27a58027240ffd52906ba61c3f938b1d6c369a0 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Wed, 12 Jun 2024 15:30:48 +0200 Subject: [PATCH 19/22] chore: format --- core/lib/src/models/tables/study.dart | 7 ++++-- core/lib/src/models/tables/study_subject.dart | 4 +++- .../lib/common_views/action_popup_menu.dart | 24 +++++++++---------- .../close/study_close_dialog_confirm.dart | 3 ++- .../close/study_close_dialog_success.dart | 3 ++- .../lib/features/dialogs/study_dialogs.dart | 11 ++++++--- .../features/recruit/study_recruit_page.dart | 4 ++-- .../study/study_controller_state.dart | 9 ++++--- .../lib/features/study/study_scaffold.dart | 11 +++++---- .../features/study/study_status_badge.dart | 1 - designer_v2/lib/utils/model_action.dart | 7 +++--- 11 files changed, 51 insertions(+), 33 deletions(-) diff --git a/core/lib/src/models/tables/study.dart b/core/lib/src/models/tables/study.dart index b59c87ecc..c08a5acb8 100644 --- a/core/lib/src/models/tables/study.dart +++ b/core/lib/src/models/tables/study.dart @@ -241,8 +241,11 @@ class Study extends SupabaseObjectFunctions static Future> publishedPublicStudies() async { ExtractionResult result; try { - final response = - await env.client.from(tableName).select().eq('participation', 'open').neq('status', StudyStatus.closed.name); + final response = await env.client + .from(tableName) + .select() + .eq('participation', 'open') + .neq('status', StudyStatus.closed.name); final extracted = SupabaseQuery.extractSupabaseList( List>.from(response), ); diff --git a/core/lib/src/models/tables/study_subject.dart b/core/lib/src/models/tables/study_subject.dart index 9e90e86f9..5fe742721 100644 --- a/core/lib/src/models/tables/study_subject.dart +++ b/core/lib/src/models/tables/study_subject.dart @@ -298,7 +298,9 @@ class StudySubject extends SupabaseObjectFunctions { Future save({bool onlyUpdate = false}) async { try { final tableQuery = env.client.from(tableName); - final query = onlyUpdate ? tableQuery.update(toJson()).eq("id", id) : tableQuery.upsert(toJson()); + final query = onlyUpdate + ? tableQuery.update(toJson()).eq("id", id) + : tableQuery.upsert(toJson()); final response = await query.select(); final json = toFullJson( partialJson: List>.from(response).single, diff --git a/designer_v2/lib/common_views/action_popup_menu.dart b/designer_v2/lib/common_views/action_popup_menu.dart index 7f1aa602a..e4144f23b 100644 --- a/designer_v2/lib/common_views/action_popup_menu.dart +++ b/designer_v2/lib/common_views/action_popup_menu.dart @@ -86,15 +86,15 @@ class ActionPopUpMenuButton extends StatelessWidget { itemBuilder: (BuildContext context) { final textTheme = theme.textTheme.labelMedium!; final List popupList = []; - for (final action in actions) { - if (action.isSeparator) { - popupList.add(const PopupMenuDivider()); - continue; - } - popupList.add(PopupMenuItem( - value: action, - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), + for (final action in actions) { + if (action.isSeparator) { + popupList.add(const PopupMenuDivider()); + continue; + } + popupList.add(PopupMenuItem( + value: action, + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), horizontalTitleGap: 4.0, leading: (action.icon == null) ? const SizedBox.shrink() @@ -112,9 +112,9 @@ class ActionPopUpMenuButton extends StatelessWidget { : Text(action.label, style: textTheme), ), )); - continue; - } - return popupList; + continue; + } + return popupList; }, ); } diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart index d0ddf8ca9..b80bf76ce 100644 --- a/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart @@ -15,7 +15,8 @@ class CloseConfirmationDialog extends StudyPageWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = ref.watch(studyControllerProvider(studyId).notifier); - final formViewModel = ref.watch(studySettingsFormViewModelProvider(studyId)); + final formViewModel = + ref.watch(studySettingsFormViewModelProvider(studyId)); return ReactiveForm( formGroup: formViewModel.form, diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart index 47e852758..435d71b9e 100644 --- a/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart @@ -21,7 +21,8 @@ class CloseSuccessDialog extends StudyPageWidget { EmptyBody( leading: Text("\u{1f512}".hardcoded, style: theme.textTheme.displayLarge?.copyWith( - fontSize: (theme.textTheme.displayLarge?.fontSize ?? 48.0) * 1.5, + fontSize: + (theme.textTheme.displayLarge?.fontSize ?? 48.0) * 1.5, )), title: tr.notification_study_closed, description: tr.notification_study_closed_description, diff --git a/designer_v2/lib/features/dialogs/study_dialogs.dart b/designer_v2/lib/features/dialogs/study_dialogs.dart index bd22ccf7e..8919b08a1 100644 --- a/designer_v2/lib/features/dialogs/study_dialogs.dart +++ b/designer_v2/lib/features/dialogs/study_dialogs.dart @@ -22,14 +22,19 @@ class StudyDialog extends StudyPageWidget { switch (dialogType) { case StudyDialogType.publish: - return state.isPublished ? PublishSuccessDialog(studyId) : PublishConfirmationDialog(studyId); + return state.isPublished + ? PublishSuccessDialog(studyId) + : PublishConfirmationDialog(studyId); case StudyDialogType.close: - return state.isClosed ? CloseSuccessDialog(studyId) : CloseConfirmationDialog(studyId); + return state.isClosed + ? CloseSuccessDialog(studyId) + : CloseConfirmationDialog(studyId); } } } -showStudyDialog(BuildContext context, StudyID studyId, StudyDialogType dialogType) { +showStudyDialog( + BuildContext context, StudyID studyId, StudyDialogType dialogType) { final theme = Theme.of(context); return showDialog( context: context, diff --git a/designer_v2/lib/features/recruit/study_recruit_page.dart b/designer_v2/lib/features/recruit/study_recruit_page.dart index ba7d7863f..51e0eefa7 100644 --- a/designer_v2/lib/features/recruit/study_recruit_page.dart +++ b/designer_v2/lib/features/recruit/study_recruit_page.dart @@ -105,12 +105,12 @@ class StudyRecruitScreen extends StudyPageWidget { ? null : () { final formViewModel = - ref.read(inviteCodeFormViewModelProvider(studyId)); + ref.read(inviteCodeFormViewModelProvider(studyId)); showFormSideSheet( context: context, formViewModel: formViewModel, formViewBuilder: (formViewModel) => - InviteCodeFormView(formViewModel: formViewModel), + InviteCodeFormView(formViewModel: formViewModel), ); }, ); diff --git a/designer_v2/lib/features/study/study_controller_state.dart b/designer_v2/lib/features/study/study_controller_state.dart index c0edb56e1..9cb3278aa 100644 --- a/designer_v2/lib/features/study/study_controller_state.dart +++ b/designer_v2/lib/features/study/study_controller_state.dart @@ -16,9 +16,11 @@ class StudyControllerState extends StudyControllerBaseState this.lastSynced, }); - bool get isPublished => study.value != null && study.value!.status == StudyStatus.running; + bool get isPublished => + study.value != null && study.value!.status == StudyStatus.running; - bool get isClosed => study.value != null && study.value!.status == StudyStatus.closed; + bool get isClosed => + study.value != null && study.value!.status == StudyStatus.closed; // - ISyncIndicatorViewModel @@ -79,7 +81,8 @@ class StudyControllerState extends StudyControllerBaseState @override bool get isClosedVisible => - studyWithMetadata?.model.status == StudyStatus.running && studyWithMetadata!.model.canEdit(super.currentUser); + studyWithMetadata?.model.status == StudyStatus.running && + studyWithMetadata!.model.canEdit(super.currentUser); @override StudyStatus? get studyStatus => study.value?.status; diff --git a/designer_v2/lib/features/study/study_scaffold.dart b/designer_v2/lib/features/study/study_scaffold.dart index 3e48a5d72..3bfaaaaca 100644 --- a/designer_v2/lib/features/study/study_scaffold.dart +++ b/designer_v2/lib/features/study/study_scaffold.dart @@ -12,6 +12,7 @@ import 'package:studyu_designer_v2/common_views/utils.dart'; import 'package:studyu_designer_v2/constants.dart'; import 'package:studyu_designer_v2/features/app_drawer.dart'; import 'package:studyu_designer_v2/features/design/study_form_providers.dart'; +import 'package:studyu_designer_v2/features/dialogs/study_dialogs.dart'; import 'package:studyu_designer_v2/features/forms/form_validation.dart'; import 'package:studyu_designer_v2/features/study/study_controller.dart'; import 'package:studyu_designer_v2/features/study/study_controller_state.dart'; @@ -20,7 +21,6 @@ import 'package:studyu_designer_v2/features/study/study_page_view.dart'; import 'package:studyu_designer_v2/features/study/study_status_badge.dart'; import 'package:studyu_designer_v2/localization/app_translation.dart'; import 'package:studyu_designer_v2/theme.dart'; -import 'package:studyu_designer_v2/features/dialogs/study_dialogs.dart'; abstract class IStudyAppBarViewModel implements IStudyStatusBadgeViewModel, IStudyNavViewModel { @@ -243,7 +243,8 @@ class _StudyScaffoldState extends ConsumerState { "${tr.form_invalid_prompt}\n\n${form.validationErrorSummary}", icon: null, enabled: formViewModel.isValid, - onPressed: () => showStudyDialog(context, widget.studyId, StudyDialogType.publish), + onPressed: () => showStudyDialog( + context, widget.studyId, StudyDialogType.publish), ); }, ), @@ -253,7 +254,8 @@ class _StudyScaffoldState extends ConsumerState { } if (state.isClosedVisible) { - final formViewModel = ref.watch(studyPublishValidatorProvider(widget.studyId)); + final formViewModel = + ref.watch(studyPublishValidatorProvider(widget.studyId)); final closeButton = ReactiveForm( formGroup: formViewModel.form, child: ReactiveFormConsumer( @@ -262,7 +264,8 @@ class _StudyScaffoldState extends ConsumerState { return SecondaryButton( text: tr.action_button_study_close, icon: null, - onPressed: () => showStudyDialog(context, widget.studyId, StudyDialogType.close), + onPressed: () => + showStudyDialog(context, widget.studyId, StudyDialogType.close), ); }), ); diff --git a/designer_v2/lib/features/study/study_status_badge.dart b/designer_v2/lib/features/study/study_status_badge.dart index 97bd1e34e..b2418c4fa 100644 --- a/designer_v2/lib/features/study/study_status_badge.dart +++ b/designer_v2/lib/features/study/study_status_badge.dart @@ -32,7 +32,6 @@ class StudyStatusBadge extends StatelessWidget { final tooltipMessage = '${status?.description ?? ''}\n${(status == StudyStatus.closed ? null : participation?.description) ?? ''}' - .trim(); Widget inTooltip(Widget child) { diff --git a/designer_v2/lib/utils/model_action.dart b/designer_v2/lib/utils/model_action.dart index c80b12926..67c9b273c 100644 --- a/designer_v2/lib/utils/model_action.dart +++ b/designer_v2/lib/utils/model_action.dart @@ -17,9 +17,10 @@ class ModelAction { required this.label, required this.onExecute, this.isSeparator = false, - this.isAvailable = true, - this.isDestructive = false, - this.icon,}); + this.isAvailable = true, + this.isDestructive = false, + this.icon, + }); static ModelAction addSeparator() { return ModelAction( From dad913eb09baa3007977dea0d30cf3805fa040ad Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Wed, 12 Jun 2024 15:32:59 +0200 Subject: [PATCH 20/22] chore: format --- .../lib/common_views/action_popup_menu.dart | 43 ++++++++++--------- .../close/study_close_dialog_confirm.dart | 37 ++++++++-------- .../close/study_close_dialog_success.dart | 12 +++--- .../lib/features/dialogs/study_dialogs.dart | 7 ++- .../features/recruit/study_recruit_page.dart | 33 +++++++------- .../lib/features/study/study_scaffold.dart | 24 ++++++----- designer_v2/lib/services/notifications.dart | 9 ++-- 7 files changed, 90 insertions(+), 75 deletions(-) diff --git a/designer_v2/lib/common_views/action_popup_menu.dart b/designer_v2/lib/common_views/action_popup_menu.dart index e4144f23b..a85ed4ee2 100644 --- a/designer_v2/lib/common_views/action_popup_menu.dart +++ b/designer_v2/lib/common_views/action_popup_menu.dart @@ -91,27 +91,30 @@ class ActionPopUpMenuButton extends StatelessWidget { popupList.add(const PopupMenuDivider()); continue; } - popupList.add(PopupMenuItem( - value: action, - child: ListTile( - contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), - horizontalTitleGap: 4.0, - leading: (action.icon == null) - ? const SizedBox.shrink() - : Icon( - action.icon, - size: theme.iconTheme.size ?? 14.0, - color: - action.isDestructive ? Colors.red : iconColorDefault, - ), - title: action.isDestructive - ? Text( - action.label, - style: textTheme.copyWith(color: Colors.red), - ) - : Text(action.label, style: textTheme), + popupList.add( + PopupMenuItem( + value: action, + child: ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 8.0), + horizontalTitleGap: 4.0, + leading: (action.icon == null) + ? const SizedBox.shrink() + : Icon( + action.icon, + size: theme.iconTheme.size ?? 14.0, + color: action.isDestructive + ? Colors.red + : iconColorDefault, + ), + title: action.isDestructive + ? Text( + action.label, + style: textTheme.copyWith(color: Colors.red), + ) + : Text(action.label, style: textTheme), + ), ), - )); + ); continue; } return popupList; diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart index b80bf76ce..4d5bd5352 100644 --- a/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart @@ -23,37 +23,38 @@ class CloseConfirmationDialog extends StudyPageWidget { child: StandardDialog( titleText: tr.dialog_study_close_title, body: Column( - mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Flexible( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - text: TextSpan( - text: tr.dialog_study_close_description, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + RichText( + text: TextSpan( + text: tr.dialog_study_close_description, + ), ), - ), - ], - )), + ], + ), + ), ], ), ], ), actionButtons: [ const DismissButton(), - ReactiveFormConsumer(builder: (context, form, child) { - return PrimaryButton( - text: tr.dialog_close, - icon: null, - onPressedFuture: () => controller.closeStudy(), - ); - }), + ReactiveFormConsumer( + builder: (context, form, child) { + return PrimaryButton( + text: tr.dialog_close, + icon: null, + onPressedFuture: () => controller.closeStudy(), + ); + }, + ), ], maxWidth: 650, minWidth: 610, diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart index 435d71b9e..f3a50ff85 100644 --- a/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart @@ -19,11 +19,13 @@ class CloseSuccessDialog extends StudyPageWidget { children: [ const SizedBox(height: 24.0), EmptyBody( - leading: Text("\u{1f512}".hardcoded, - style: theme.textTheme.displayLarge?.copyWith( - fontSize: - (theme.textTheme.displayLarge?.fontSize ?? 48.0) * 1.5, - )), + leading: Text( + "\u{1f512}".hardcoded, + style: theme.textTheme.displayLarge?.copyWith( + fontSize: + (theme.textTheme.displayLarge?.fontSize ?? 48.0) * 1.5, + ), + ), title: tr.notification_study_closed, description: tr.notification_study_closed_description, ), diff --git a/designer_v2/lib/features/dialogs/study_dialogs.dart b/designer_v2/lib/features/dialogs/study_dialogs.dart index 8919b08a1..2176249e7 100644 --- a/designer_v2/lib/features/dialogs/study_dialogs.dart +++ b/designer_v2/lib/features/dialogs/study_dialogs.dart @@ -33,8 +33,11 @@ class StudyDialog extends StudyPageWidget { } } -showStudyDialog( - BuildContext context, StudyID studyId, StudyDialogType dialogType) { +Future showStudyDialog( + BuildContext context, + StudyID studyId, + StudyDialogType dialogType, +) { final theme = Theme.of(context); return showDialog( context: context, diff --git a/designer_v2/lib/features/recruit/study_recruit_page.dart b/designer_v2/lib/features/recruit/study_recruit_page.dart index 51e0eefa7..5395f3c83 100644 --- a/designer_v2/lib/features/recruit/study_recruit_page.dart +++ b/designer_v2/lib/features/recruit/study_recruit_page.dart @@ -33,8 +33,8 @@ class StudyRecruitScreen extends StudyPageWidget { _inviteCodesSectionHeader(context, ref), const SizedBox(height: 24.0), // spacing between body elements StudyInvitesTable( - invites: - studyInvites!, // otherwise falls through to [AsyncValueWidget.empty] + invites: studyInvites!, + // otherwise falls through to [AsyncValueWidget.empty] onSelect: _onSelectInvite(context, ref), getActions: controller.availableActions, getInlineActions: controller.availableInlineActions, @@ -63,20 +63,21 @@ class StudyRecruitScreen extends StudyPageWidget { if (isStudyClosed ?? false) { return BannerBox( - noPrefix: true, - body: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextParagraph( - text: tr.banner_study_closed_title, - style: const TextStyle(fontWeight: FontWeight.bold), - ), - TextParagraph( - text: tr.banner_study_closed_description, - ), - ]), - style: BannerStyle.info); + noPrefix: true, + body: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextParagraph( + text: tr.banner_study_closed_title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + TextParagraph( + text: tr.banner_study_closed_description, + ), + ], + ), + style: BannerStyle.info, + ); } return null; } diff --git a/designer_v2/lib/features/study/study_scaffold.dart b/designer_v2/lib/features/study/study_scaffold.dart index 3bfaaaaca..86299497d 100644 --- a/designer_v2/lib/features/study/study_scaffold.dart +++ b/designer_v2/lib/features/study/study_scaffold.dart @@ -244,7 +244,10 @@ class _StudyScaffoldState extends ConsumerState { icon: null, enabled: formViewModel.isValid, onPressed: () => showStudyDialog( - context, widget.studyId, StudyDialogType.publish), + context, + widget.studyId, + StudyDialogType.publish, + ), ); }, ), @@ -259,15 +262,16 @@ class _StudyScaffoldState extends ConsumerState { final closeButton = ReactiveForm( formGroup: formViewModel.form, child: ReactiveFormConsumer( - // enable re-rendering based on form validation status - builder: (context, form, child) { - return SecondaryButton( - text: tr.action_button_study_close, - icon: null, - onPressed: () => - showStudyDialog(context, widget.studyId, StudyDialogType.close), - ); - }), + // enable re-rendering based on form validation status + builder: (context, form, child) { + return SecondaryButton( + text: tr.action_button_study_close, + icon: null, + onPressed: () => showStudyDialog( + context, widget.studyId, StudyDialogType.close), + ); + }, + ), ); actionButtons.add(closeButton); actionButtons.add(const SizedBox(width: 12.0)); // padding diff --git a/designer_v2/lib/services/notifications.dart b/designer_v2/lib/services/notifications.dart index f2876d9ee..5d9141362 100644 --- a/designer_v2/lib/services/notifications.dart +++ b/designer_v2/lib/services/notifications.dart @@ -32,10 +32,11 @@ class Notifications { actions: [NotificationDefaultActions.cancel], ); static final studyCloseConfirmation = AlertIntent( - title: tr.dialog_study_close_title, - message: tr.dialog_study_close_description, - icon: MdiIcons.accountLock, - actions: [NotificationDefaultActions.cancel]); + title: tr.dialog_study_close_title, + message: tr.dialog_study_close_description, + icon: MdiIcons.accountLock, + actions: [NotificationDefaultActions.cancel], + ); } class NotificationDefaultActions { From cdcbca5f027de0ccc54617c5aef4ad737c4d3342 Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 13 Jun 2024 09:26:37 +0200 Subject: [PATCH 21/22] style: rephrase translation --- designer_v2/lib/localization/app_en.arb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/designer_v2/lib/localization/app_en.arb b/designer_v2/lib/localization/app_en.arb index 8e428367a..46b676dd9 100644 --- a/designer_v2/lib/localization/app_en.arb +++ b/designer_v2/lib/localization/app_en.arb @@ -80,7 +80,8 @@ "phase_sequence_alternating": "Alternating (AB AB)", "phase_sequence_counterbalanced": "Counterbalanced (AB BA)", "phase_sequence_random": "Random", -"phase_sequence_custom": "Custom", "form_enrollment_option_open": "Open", + "phase_sequence_custom": "Custom", + "form_enrollment_option_open": "Open", "form_enrollment_option_invite": "Private (Invite-only)", "notification_code_deleted": "Access code deleted", "notification_code_clipboard": "Code copied to clipboard", @@ -123,7 +124,7 @@ "notification_study_closed": "Study was closed", "notification_study_closed_description": "New participants can no longer enroll in this study.", "dialog_study_close_title": "Close participation?", - "dialog_study_close_description": "Are you sure that you want to close participation for this study? New participants can no longer enroll, but enrolled participants can continue to participate.", + "dialog_study_close_description": "Are you sure that you want to stop new enrollments for this study? New participants can no longer join, but those who are already enrolled can still continue.", "dialog_study_delete_title": "Permanently delete?", "dialog_study_delete_description": "Are you sure you want to delete this study? You will permanently lose the study and all data that has been collected.", "@__________________STUDYPAGE_DESIGN_SHARED__________________": {}, @@ -242,8 +243,6 @@ "free_text_question_type_alphanumeric_explanation": "Alphanumeric input includes letters and numbers only.", "free_text_question_type_numeric_explanation": "Numeric input includes numbers without special characters.", "free_text_question_type_custom_explanation": "The input must match the specified regular expression.", - - "banner_study_readonly_title": "This study cannot be edited.", "banner_study_readonly_description": "You can only make changes to studies where you are an owner or collaborator. Studies that have been launched cannot be changed by anyone.", "banner_study_closed_title": "This study is closed.", From 9b66d338ac1407aff9c7a038ef758816f3f6461c Mon Sep 17 00:00:00 2001 From: Johannes Vedder Date: Thu, 13 Jun 2024 10:12:07 +0200 Subject: [PATCH 22/22] feat: new severe close confirmation --- .../close/study_close_dialog_confirm.dart | 91 +++++++++++-------- .../close/study_close_dialog_success.dart | 3 +- .../lib/features/study/study_scaffold.dart | 8 +- designer_v2/lib/localization/app_de.arb | 3 +- designer_v2/lib/localization/app_en.arb | 2 +- 5 files changed, 61 insertions(+), 46 deletions(-) diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart index 4d5bd5352..1f5099883 100644 --- a/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_confirm.dart @@ -1,10 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:reactive_forms/reactive_forms.dart'; +import 'package:studyu_core/core.dart'; +import 'package:studyu_designer_v2/common_views/async_value_widget.dart'; import 'package:studyu_designer_v2/common_views/dialog.dart'; import 'package:studyu_designer_v2/common_views/form_buttons.dart'; import 'package:studyu_designer_v2/common_views/primary_button.dart'; -import 'package:studyu_designer_v2/features/study/settings/study_settings_form_controller.dart'; import 'package:studyu_designer_v2/features/study/study_controller.dart'; import 'package:studyu_designer_v2/features/study/study_page_view.dart'; import 'package:studyu_designer_v2/localization/app_translation.dart'; @@ -15,51 +15,62 @@ class CloseConfirmationDialog extends StudyPageWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = ref.watch(studyControllerProvider(studyId).notifier); - final formViewModel = - ref.watch(studySettingsFormViewModelProvider(studyId)); + final state = ref.watch(studyControllerProvider(studyId)); + final formKey = GlobalKey(); + String? inputStudyName; - return ReactiveForm( - formGroup: formViewModel.form, - child: StandardDialog( - titleText: tr.dialog_study_close_title, - body: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.start, + return AsyncValueWidget( + value: state.study, + data: (Study study) { + return StandardDialog( + titleText: tr.dialog_study_close_title, + body: Form( + key: formKey, + child: Column( children: [ - Flexible( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - RichText( - text: TextSpan( - text: tr.dialog_study_close_description, - ), - ), - ], + Text(tr.dialog_study_close_description), + const SizedBox(height: 16.0), + SelectableText( + 'Enter the title of the study "${study.title}" to confirm that you want to close it:', + ), + const SizedBox(height: 16.0), + TextFormField( + decoration: const InputDecoration( + labelText: 'Title of the study to close', ), + validator: (value) { + if (value == null || value != study.title) { + return 'Study title does not match'; + } + return null; + }, + onSaved: (value) { + inputStudyName = value; + }, ), ], ), - ], - ), - actionButtons: [ - const DismissButton(), - ReactiveFormConsumer( - builder: (context, form, child) { - return PrimaryButton( - text: tr.dialog_close, - icon: null, - onPressedFuture: () => controller.closeStudy(), - ); - }, ), - ], - maxWidth: 650, - minWidth: 610, - minHeight: 200, - ), + actionButtons: [ + const DismissButton(), + PrimaryButton( + text: tr.dialog_close, + icon: null, + onPressedFuture: () async { + if (formKey.currentState!.validate()) { + formKey.currentState!.save(); + if (inputStudyName == study.title) { + await controller.closeStudy(); + } + } + }, + ), + ], + maxWidth: 650, + minWidth: 610, + minHeight: 200, + ); + }, ); } } diff --git a/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart index f3a50ff85..3fc04196f 100644 --- a/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart +++ b/designer_v2/lib/features/dialogs/close/study_close_dialog_success.dart @@ -13,7 +13,6 @@ class CloseSuccessDialog extends StudyPageWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - return StandardDialog( body: Column( children: [ @@ -34,7 +33,7 @@ class CloseSuccessDialog extends StudyPageWidget { ), actionButtons: [ PrimaryButton( - text: tr.action_button_study_close, + text: tr.dialog_close, icon: null, onPressedFuture: () => Navigator.maybePop(context), ), diff --git a/designer_v2/lib/features/study/study_scaffold.dart b/designer_v2/lib/features/study/study_scaffold.dart index 86299497d..0e0dd305f 100644 --- a/designer_v2/lib/features/study/study_scaffold.dart +++ b/designer_v2/lib/features/study/study_scaffold.dart @@ -25,8 +25,11 @@ import 'package:studyu_designer_v2/theme.dart'; abstract class IStudyAppBarViewModel implements IStudyStatusBadgeViewModel, IStudyNavViewModel { bool get isSyncIndicatorVisible; + bool get isStatusBadgeVisible; + bool get isPublishVisible; + bool get isClosedVisible; } @@ -268,7 +271,10 @@ class _StudyScaffoldState extends ConsumerState { text: tr.action_button_study_close, icon: null, onPressed: () => showStudyDialog( - context, widget.studyId, StudyDialogType.close), + context, + widget.studyId, + StudyDialogType.close, + ), ); }, ), diff --git a/designer_v2/lib/localization/app_de.arb b/designer_v2/lib/localization/app_de.arb index b69ad6487..e192b7a2e 100644 --- a/designer_v2/lib/localization/app_de.arb +++ b/designer_v2/lib/localization/app_de.arb @@ -73,7 +73,6 @@ "phase_sequence_counterbalanced": "Ausgeglichen (AB BA)", "phase_sequence_random": "Zufällig", "phase_sequence_custom": "Benutzerdefiniert", - "form_enrollment_option_open": "Open", "form_enrollment_option_invite": "Private (Invite-only)", "@__________________NAV_DRAWER__________________": {}, @@ -123,7 +122,7 @@ "notification_study_closed": "Die Studie wurde geschlossen", "notification_study_closed_description": "Neue Teilnehmer können sich nicht mehr einschreiben", "dialog_study_close_title": "Teilnahme schließen?", - "dialog_study_close_description": "Bist du sicher, dass die Teilnahme an der Studie geschlossen werden soll? Es können keine neuen Teilnehmer mehr eingeschrieben werden, aber bereits angemeldete Teilnehmer können die Studie weiterhin durchführen.", + "dialog_study_close_description": "Bist du sicher, dass die Teilnahme an der Studie geschlossen werden soll? Dadurch können keine neuen Teilnehmer mehr aufgenommen werden. Bereits eingeschriebene Teilnehmer werden die Studie weiterhin durchführen können. Das Schließen einer Studie kann nicht rückgängig gemacht werden.", "dialog_study_delete_title": "Dauerhaft löschen?", "dialog_study_delete_description": "Bist du sicher, dass die Studie gelöscht werden soll? Die Studie und alle gesammelten Daten gehen dabei unwiderruflich verloren.", "@__________________STUDYPAGE_DESIGN_SHARED__________________": {}, diff --git a/designer_v2/lib/localization/app_en.arb b/designer_v2/lib/localization/app_en.arb index 46b676dd9..fca92717e 100644 --- a/designer_v2/lib/localization/app_en.arb +++ b/designer_v2/lib/localization/app_en.arb @@ -124,7 +124,7 @@ "notification_study_closed": "Study was closed", "notification_study_closed_description": "New participants can no longer enroll in this study.", "dialog_study_close_title": "Close participation?", - "dialog_study_close_description": "Are you sure that you want to stop new enrollments for this study? New participants can no longer join, but those who are already enrolled can still continue.", + "dialog_study_close_description": "Are you sure that you want to stop new enrollments for this study? New participants can no longer join, but those who are already enrolled can still continue. This action cannot be undone.", "dialog_study_delete_title": "Permanently delete?", "dialog_study_delete_description": "Are you sure you want to delete this study? You will permanently lose the study and all data that has been collected.", "@__________________STUDYPAGE_DESIGN_SHARED__________________": {},