From e4f4ef80c1ae28b000b8a5c00409dfc2780ea4ea Mon Sep 17 00:00:00 2001 From: Maija Y Date: Thu, 24 Oct 2024 14:44:35 +0300 Subject: [PATCH 01/16] User can give marketing consent to a specific course --- .../forms/SelectMarketingConsentForm.tsx | 50 ++++++++++++++ .../components/modals/CourseSettingsModal.tsx | 22 ++++++- .../course-material/src/services/backend.ts | 25 +++++++ ...41023104801_add-marketing-consent.down.sql | 2 + ...0241023104801_add-marketing-consent.up.sql | 26 ++++++++ ...7debbec6a84e8df2dc822d5cfb16c01e26ed2.json | 18 +++++ ...373ceff25cf391850e08a2697e0dd03f8d94.json} | 12 +++- ...bc418d2da0f66429c32c48708cd3dccd0f3a.json} | 12 +++- ...f021a4e66262cd6784eef8bc68a0c2727471.json} | 12 +++- ...60864b00491bb34f1559377a36cee1a6485c.json} | 12 +++- ...a5c3fd35a5adbd69b62e5dd5daa0855596b5.json} | 12 +++- ...df6407d71c2e055ddb411f529792b018a69b4.json | 48 ++++++++++++++ ...a6b786df2e76edf819f9decfa7c441591925.json} | 12 +++- ...eba6bf46eaaec156732e307dcaa0ce08f600.json} | 12 +++- ...f73c18e7f97137ea7f5f4ce1f1322e8c137c.json} | 12 +++- ...9a594929d26cdbdebe3ae65a75e63b25d537.json} | 12 +++- ...8b556d48af088e3f489e627565a330b26ffb.json} | 12 +++- ...bdcb2a17e6df2bf5b046fb6ecc92640943f5.json} | 12 +++- ...4be0cd06b4310d4a5156fdb306054916c9e5.json} | 15 +++-- ...10b891d1b22fa2716e958a3de831f22217c8.json} | 14 ++-- ...b72efc11d1cf746c7081e5ca1e25eee9f7cd.json} | 12 +++- services/headless-lms/models/src/chapters.rs | 1 + services/headless-lms/models/src/courses.rs | 46 +++++++++---- services/headless-lms/models/src/exams.rs | 3 +- services/headless-lms/models/src/lib.rs | 1 + .../models/src/library/copying.rs | 12 ++-- .../models/src/marketing_consent.rs | 65 +++++++++++++++++++ .../headless-lms/models/src/test_helper.rs | 1 + .../controllers/course_material/courses.rs | 65 +++++++++++++++++++ .../server/src/programs/seed/seed_courses.rs | 5 ++ .../programs/seed/seed_organizations/uh_cs.rs | 1 + .../seed/seed_organizations/uh_mathstat.rs | 4 ++ .../headless-lms/server/src/test_helper.rs | 1 + .../server/src/ts_binding_generator.rs | 2 + .../src/components/forms/NewCourseForm.tsx | 2 + .../courses/id/index/UpdateCourseForm.tsx | 13 +++- .../packages/common/src/bindings.guard.ts | 27 ++++++-- shared-module/packages/common/src/bindings.ts | 14 ++++ 38 files changed, 558 insertions(+), 69 deletions(-) create mode 100644 services/course-material/src/components/forms/SelectMarketingConsentForm.tsx create mode 100644 services/headless-lms/migrations/20241023104801_add-marketing-consent.down.sql create mode 100644 services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql create mode 100644 services/headless-lms/models/.sqlx/query-21c2e2209a0a7f8bb2a4e8f029b7debbec6a84e8df2dc822d5cfb16c01e26ed2.json rename services/headless-lms/models/.sqlx/{query-e45f9d2d34d5e905575efd5c61b0519ddeb78673c0df31a3e078c642f2b6106d.json => query-32396d664f3f617f45c734993b62373ceff25cf391850e08a2697e0dd03f8d94.json} (86%) rename services/headless-lms/models/.sqlx/{query-dc7313994c18a88543cde9bb43521f7ca63b67950784ce377055eecbd0af0102.json => query-393824929f6e6e064757af094951bc418d2da0f66429c32c48708cd3dccd0f3a.json} (79%) rename services/headless-lms/models/.sqlx/{query-032e00af20a43472c6e91d155e130a6e0790e330a6d943c1b4871eef476e26d0.json => query-401662d18cc190e1bab25142d7c3f021a4e66262cd6784eef8bc68a0c2727471.json} (90%) rename services/headless-lms/models/.sqlx/{query-ebcc56fdcf585b87b73a715bc114a8c5809204b3f3c5a1e6b08de8bcd54b7e44.json => query-668b7cb6022b915f1838997679cc60864b00491bb34f1559377a36cee1a6485c.json} (85%) rename services/headless-lms/models/.sqlx/{query-ae1ed9d41861ec3cd6038dc2aa8c5a86234832d8bcce20b54ae05ffe643e6f99.json => query-748c01cf60043988fba2a02a5d1ea5c3fd35a5adbd69b62e5dd5daa0855596b5.json} (89%) create mode 100644 services/headless-lms/models/.sqlx/query-977f3b25bc61a7d7909ebed92aedf6407d71c2e055ddb411f529792b018a69b4.json rename services/headless-lms/models/.sqlx/{query-82fe4061970937715a5e2d15682c8aed17f804b6a1823dad140ee6f5f4ff5ce6.json => query-9fbc4e80f16e7cab3c8928ed195aa6b786df2e76edf819f9decfa7c441591925.json} (89%) rename services/headless-lms/models/.sqlx/{query-659d8635c8511990904c11ca7a200ae7559ffeca619e7a8ecc975b19fe933f2e.json => query-a9f3390d8ad3debab9850325c54beba6bf46eaaec156732e307dcaa0ce08f600.json} (90%) rename services/headless-lms/models/.sqlx/{query-f85e90f73c74ed500cec06a100a1196db665c91c4b8805d70fa94ffca87da53a.json => query-b5da68bc470f028a050fcca492eaf73c18e7f97137ea7f5f4ce1f1322e8c137c.json} (79%) rename services/headless-lms/models/.sqlx/{query-dc6b04142ac276503be3b714a535f56e008e8516a2c38f2f149fbeaa1571c5ed.json => query-b7647f94557ccce49973abf5a9829a594929d26cdbdebe3ae65a75e63b25d537.json} (84%) rename services/headless-lms/models/.sqlx/{query-50d7e5768bc24ec9a2c18d11ce77ac35db826c01ec7bc05cb905aab1c51ba7e4.json => query-cdf31684a716948b9ed005acb59f8b556d48af088e3f489e627565a330b26ffb.json} (90%) rename services/headless-lms/models/.sqlx/{query-13554c9b1e813c29066c90df0e04a3b84ed33fc39fadae2253c80a9761e39903.json => query-e1a28636054909bb7e9622667a32bdcb2a17e6df2bf5b046fb6ecc92640943f5.json} (89%) rename services/headless-lms/models/.sqlx/{query-5303f036fecb3efa8dce1756eaf2193d7e6c2f5cb7c9267b14e193daf525d997.json => query-ee5080bbfb823d38cfbd65043c874be0cd06b4310d4a5156fdb306054916c9e5.json} (78%) rename services/headless-lms/models/.sqlx/{query-63132d1b35ab7549158a6f5e330221a49cd06eb066e344d338efee8289d68e35.json => query-f53c5545a9f632da9076ce08319f10b891d1b22fa2716e958a3de831f22217c8.json} (76%) rename services/headless-lms/models/.sqlx/{query-2f227e2bf3a4dd0828def3caa59b00246bf72c3a2bc0b40a9ff94e14ce61615f.json => query-fa2a4f76355cf8f829e239ae278ab72efc11d1cf746c7081e5ca1e25eee9f7cd.json} (89%) create mode 100644 services/headless-lms/models/src/marketing_consent.rs diff --git a/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx b/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx new file mode 100644 index 000000000000..a020ec43ac70 --- /dev/null +++ b/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from "react" + +import { fetchUserMarketingConsent, updateMarketingConsent } from "@/services/backend" +import CheckBox from "@/shared-module/common/components/InputFields/CheckBox" +import useToastMutation from "@/shared-module/common/hooks/useToastMutation" + +interface selectMarketingConstentProps { + courseId: string +} + +const SelectMarketingConstentForm: React.FC = ({ courseId }) => { + const [marketingConsent, setMarketingConsent] = useState(false) + + useEffect(() => { + const fetchConsentStatus = async () => { + const response = await fetchUserMarketingConsent(courseId) + setMarketingConsent(response.consent) + } + fetchConsentStatus() + }, [courseId]) + + const handleMarketingConsentChangeMutation = useToastMutation( + async () => { + try { + await updateMarketingConsent(courseId, marketingConsent) + } catch (error) { + setMarketingConsent(!marketingConsent) + } + return null + }, + { notify: false }, + ) + + return ( + <> + { + setMarketingConsent(!marketingConsent) + handleMarketingConsentChangeMutation.mutate() + }} + > + + ) +} + +export default SelectMarketingConstentForm diff --git a/services/course-material/src/components/modals/CourseSettingsModal.tsx b/services/course-material/src/components/modals/CourseSettingsModal.tsx index f523a5e7f286..a067cc97bb70 100644 --- a/services/course-material/src/components/modals/CourseSettingsModal.tsx +++ b/services/course-material/src/components/modals/CourseSettingsModal.tsx @@ -5,9 +5,14 @@ import React, { useContext, useEffect, useId, useState } from "react" import { useTranslation } from "react-i18next" import PageContext from "../../contexts/PageContext" -import { fetchCourseInstances, postSaveCourseSettings } from "../../services/backend" +import { + fetchCourseById, + fetchCourseInstances, + postSaveCourseSettings, +} from "../../services/backend" import SelectCourseLanguage from "../SelectCourseLanguage" import SelectCourseInstanceForm from "../forms/SelectCourseInstanceForm" +import SelectMarketingConstentForm from "../forms/SelectMarketingConsentForm" import { getLanguageName, @@ -71,6 +76,12 @@ const CourseSettingsModal: React.FC fetchCourseById(selectedLangCourseId as NonNullable), + enabled: selectedLangCourseId !== null && open && pageState.state === "ready", + }) + useEffect(() => { getCourseInstances.refetch() sortInstances() @@ -185,6 +196,15 @@ const CourseSettingsModal: React.FC )} +
+ {getCourse.data?.ask_marketing_consent && ( + + )} +
{languageChanged && (
=> const response = await courseMaterialClient.post(`/code-giveaways/${id}/claim`) return validateResponse(response, isString) } + +export const updateMarketingConsent = async ( + courseId: string, + consent: boolean, +): Promise => { + const res = await courseMaterialClient.post( + `courses/${courseId}/user-marketing-consent`, + { + consent, + }, + { + responseType: "json", + }, + ) + return validateResponse(res, isString) +} + +export const fetchUserMarketingConsent = async ( + courseId: string, +): Promise => { + const res = await courseMaterialClient.get(`courses/${courseId}/fetch-user-marketing-consent`) + return validateResponse(res, isUserMarketingConsent) +} diff --git a/services/headless-lms/migrations/20241023104801_add-marketing-consent.down.sql b/services/headless-lms/migrations/20241023104801_add-marketing-consent.down.sql new file mode 100644 index 000000000000..98b3ae849f61 --- /dev/null +++ b/services/headless-lms/migrations/20241023104801_add-marketing-consent.down.sql @@ -0,0 +1,2 @@ +DROP TABLE user_marketing_consents; +ALTER TABLE courses DROP COLUMN ask_marketing_consent; diff --git a/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql new file mode 100644 index 000000000000..fdeb98fbd20f --- /dev/null +++ b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql @@ -0,0 +1,26 @@ +CREATE TABLE user_marketing_consents ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + course_id UUID NOT NULL REFERENCES courses(id), + user_id UUID NOT NULL REFERENCES users(id), + consent BOOLEAN NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT course_specific_marketing_consents_user_uniqueness UNIQUE NULLS NOT DISTINCT(user_id, course_id) +); + +CREATE TRIGGER set_timestamp BEFORE +UPDATE ON user_marketing_consents FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); + +COMMENT ON TABLE user_marketing_consents IS 'This table is used to keep a record if a user has given a marketing consent to a course'; +COMMENT ON COLUMN user_marketing_consents.id IS 'A unique, stable identifier for the record.'; +COMMENT ON COLUMN user_marketing_consents.course_id IS 'Course that the user has access to.'; +COMMENT ON COLUMN user_marketing_consents.user_id IS 'User who has the access to the course.'; +COMMENT ON COLUMN user_marketing_consents.consent IS 'Wheter the user has given a marketing consent for a specific course.'; +COMMENT ON COLUMN user_marketing_consents.created_at IS 'Timestamp of when the record was created.'; +COMMENT ON COLUMN user_marketing_consents.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; +COMMENT ON COLUMN user_marketing_consents.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; + +ALTER TABLE courses +ADD COLUMN ask_marketing_consent BOOLEAN NOT NULL DEFAULT FALSE; +COMMENT ON COLUMN courses.ask_marketing_consent IS 'Whether this course asks the user for marketing consent.'; diff --git a/services/headless-lms/models/.sqlx/query-21c2e2209a0a7f8bb2a4e8f029b7debbec6a84e8df2dc822d5cfb16c01e26ed2.json b/services/headless-lms/models/.sqlx/query-21c2e2209a0a7f8bb2a4e8f029b7debbec6a84e8df2dc822d5cfb16c01e26ed2.json new file mode 100644 index 000000000000..e1be006d1bf5 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-21c2e2209a0a7f8bb2a4e8f029b7debbec6a84e8df2dc822d5cfb16c01e26ed2.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_marketing_consents (user_id, course_id, consent)\n VALUES ($1, $2, $3)\n ON CONFLICT (user_id, course_id)\n DO UPDATE\n SET consent = $3\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Uuid", "Uuid", "Bool"] + }, + "nullable": [false] + }, + "hash": "21c2e2209a0a7f8bb2a4e8f029b7debbec6a84e8df2dc822d5cfb16c01e26ed2" +} diff --git a/services/headless-lms/models/.sqlx/query-e45f9d2d34d5e905575efd5c61b0519ddeb78673c0df31a3e078c642f2b6106d.json b/services/headless-lms/models/.sqlx/query-32396d664f3f617f45c734993b62373ceff25cf391850e08a2697e0dd03f8d94.json similarity index 86% rename from services/headless-lms/models/.sqlx/query-e45f9d2d34d5e905575efd5c61b0519ddeb78673c0df31a3e078c642f2b6106d.json rename to services/headless-lms/models/.sqlx/query-32396d664f3f617f45c734993b62373ceff25cf391850e08a2697e0dd03f8d94.json index 7a736c6584c7..51c4dad44682 100644 --- a/services/headless-lms/models/.sqlx/query-e45f9d2d34d5e905575efd5c61b0519ddeb78673c0df31a3e078c642f2b6106d.json +++ b/services/headless-lms/models/.sqlx/query-32396d664f3f617f45c734993b62373ceff25cf391850e08a2697e0dd03f8d94.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_joinable_by_code_only,\n join_code\nFROM courses\nWHERE courses.deleted_at IS NULL\n AND id IN (\n SELECT current_course_id\n FROM user_course_settings\n WHERE deleted_at IS NULL\n AND user_id = $1\n )\n", + "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\nFROM courses\nWHERE courses.deleted_at IS NULL\n AND id IN (\n SELECT current_course_id\n FROM user_course_settings\n WHERE deleted_at IS NULL\n AND user_id = $1\n )\n", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "e45f9d2d34d5e905575efd5c61b0519ddeb78673c0df31a3e078c642f2b6106d" + "hash": "32396d664f3f617f45c734993b62373ceff25cf391850e08a2697e0dd03f8d94" } diff --git a/services/headless-lms/models/.sqlx/query-dc7313994c18a88543cde9bb43521f7ca63b67950784ce377055eecbd0af0102.json b/services/headless-lms/models/.sqlx/query-393824929f6e6e064757af094951bc418d2da0f66429c32c48708cd3dccd0f3a.json similarity index 79% rename from services/headless-lms/models/.sqlx/query-dc7313994c18a88543cde9bb43521f7ca63b67950784ce377055eecbd0af0102.json rename to services/headless-lms/models/.sqlx/query-393824929f6e6e064757af094951bc418d2da0f66429c32c48708cd3dccd0f3a.json index 371729edf235..23676f55b112 100644 --- a/services/headless-lms/models/.sqlx/query-dc7313994c18a88543cde9bb43521f7ca63b67950784ce377055eecbd0af0102.json +++ b/services/headless-lms/models/.sqlx/query-393824929f6e6e064757af094951bc418d2da0f66429c32c48708cd3dccd0f3a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code\nFROM courses\nWHERE courses.deleted_at IS NULL\n AND (\n id IN (\n SELECT course_id\n FROM roles\n WHERE deleted_at IS NULL\n AND user_id = $1\n AND course_id IS NOT NULL\n )\n OR (\n id IN (\n SELECT ci.course_id\n FROM course_instances ci\n JOIN ROLES r ON r.course_instance_id = ci.id\n WHERE r.user_id = $1\n AND r.deleted_at IS NULL\n AND ci.deleted_at IS NULL\n )\n )\n ) ", + "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\nFROM courses\nWHERE courses.deleted_at IS NULL\n AND (\n id IN (\n SELECT course_id\n FROM roles\n WHERE deleted_at IS NULL\n AND user_id = $1\n AND course_id IS NOT NULL\n )\n OR (\n id IN (\n SELECT ci.course_id\n FROM course_instances ci\n JOIN ROLES r ON r.course_instance_id = ci.id\n WHERE r.user_id = $1\n AND r.deleted_at IS NULL\n AND ci.deleted_at IS NULL\n )\n )\n ) ", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "dc7313994c18a88543cde9bb43521f7ca63b67950784ce377055eecbd0af0102" + "hash": "393824929f6e6e064757af094951bc418d2da0f66429c32c48708cd3dccd0f3a" } diff --git a/services/headless-lms/models/.sqlx/query-032e00af20a43472c6e91d155e130a6e0790e330a6d943c1b4871eef476e26d0.json b/services/headless-lms/models/.sqlx/query-401662d18cc190e1bab25142d7c3f021a4e66262cd6784eef8bc68a0c2727471.json similarity index 90% rename from services/headless-lms/models/.sqlx/query-032e00af20a43472c6e91d155e130a6e0790e330a6d943c1b4871eef476e26d0.json rename to services/headless-lms/models/.sqlx/query-401662d18cc190e1bab25142d7c3f021a4e66262cd6784eef8bc68a0c2727471.json index 715a90b38505..e47b15eb0d9d 100644 --- a/services/headless-lms/models/.sqlx/query-032e00af20a43472c6e91d155e130a6e0790e330a6d943c1b4871eef476e26d0.json +++ b/services/headless-lms/models/.sqlx/query-401662d18cc190e1bab25142d7c3f021a4e66262cd6784eef8bc68a0c2727471.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code\nFROM courses\nWHERE join_code = $1\n AND deleted_at IS NULL;\n ", + "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\nFROM courses\nWHERE slug = $1\n AND deleted_at IS NULL\n", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "032e00af20a43472c6e91d155e130a6e0790e330a6d943c1b4871eef476e26d0" + "hash": "401662d18cc190e1bab25142d7c3f021a4e66262cd6784eef8bc68a0c2727471" } diff --git a/services/headless-lms/models/.sqlx/query-ebcc56fdcf585b87b73a715bc114a8c5809204b3f3c5a1e6b08de8bcd54b7e44.json b/services/headless-lms/models/.sqlx/query-668b7cb6022b915f1838997679cc60864b00491bb34f1559377a36cee1a6485c.json similarity index 85% rename from services/headless-lms/models/.sqlx/query-ebcc56fdcf585b87b73a715bc114a8c5809204b3f3c5a1e6b08de8bcd54b7e44.json rename to services/headless-lms/models/.sqlx/query-668b7cb6022b915f1838997679cc60864b00491bb34f1559377a36cee1a6485c.json index 2e2ab4675f52..9ed4a212ef6b 100644 --- a/services/headless-lms/models/.sqlx/query-ebcc56fdcf585b87b73a715bc114a8c5809204b3f3c5a1e6b08de8bcd54b7e44.json +++ b/services/headless-lms/models/.sqlx/query-668b7cb6022b915f1838997679cc60864b00491bb34f1559377a36cee1a6485c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n slug,\n courses.created_at,\n courses.updated_at,\n courses.deleted_at,\n name,\n description,\n organization_id,\n language_code,\n copied_from,\n content_search_language::text,\n course_language_group_id,\n is_draft,\n is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code\nFROM courses\n JOIN course_exams ON courses.id = course_exams.course_id\nWHERE course_exams.exam_id = $1\n AND courses.deleted_at IS NULL\n AND course_exams.deleted_at IS NULL\n", + "query": "\nSELECT id,\n slug,\n courses.created_at,\n courses.updated_at,\n courses.deleted_at,\n name,\n description,\n organization_id,\n language_code,\n copied_from,\n content_search_language::text,\n course_language_group_id,\n is_draft,\n is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\nFROM courses\n JOIN course_exams ON courses.id = course_exams.course_id\nWHERE course_exams.exam_id = $1\n AND courses.deleted_at IS NULL\n AND course_exams.deleted_at IS NULL\n", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "ebcc56fdcf585b87b73a715bc114a8c5809204b3f3c5a1e6b08de8bcd54b7e44" + "hash": "668b7cb6022b915f1838997679cc60864b00491bb34f1559377a36cee1a6485c" } diff --git a/services/headless-lms/models/.sqlx/query-ae1ed9d41861ec3cd6038dc2aa8c5a86234832d8bcce20b54ae05ffe643e6f99.json b/services/headless-lms/models/.sqlx/query-748c01cf60043988fba2a02a5d1ea5c3fd35a5adbd69b62e5dd5daa0855596b5.json similarity index 89% rename from services/headless-lms/models/.sqlx/query-ae1ed9d41861ec3cd6038dc2aa8c5a86234832d8bcce20b54ae05ffe643e6f99.json rename to services/headless-lms/models/.sqlx/query-748c01cf60043988fba2a02a5d1ea5c3fd35a5adbd69b62e5dd5daa0855596b5.json index 1dd13c29dcff..6505df02c202 100644 --- a/services/headless-lms/models/.sqlx/query-ae1ed9d41861ec3cd6038dc2aa8c5a86234832d8bcce20b54ae05ffe643e6f99.json +++ b/services/headless-lms/models/.sqlx/query-748c01cf60043988fba2a02a5d1ea5c3fd35a5adbd69b62e5dd5daa0855596b5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code\nFROM courses\nWHERE course_language_group_id = $1\nAND deleted_at IS NULL\n ", + "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\nFROM courses\nWHERE course_language_group_id = $1\nAND deleted_at IS NULL\n ", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "ae1ed9d41861ec3cd6038dc2aa8c5a86234832d8bcce20b54ae05ffe643e6f99" + "hash": "748c01cf60043988fba2a02a5d1ea5c3fd35a5adbd69b62e5dd5daa0855596b5" } diff --git a/services/headless-lms/models/.sqlx/query-977f3b25bc61a7d7909ebed92aedf6407d71c2e055ddb411f529792b018a69b4.json b/services/headless-lms/models/.sqlx/query-977f3b25bc61a7d7909ebed92aedf6407d71c2e055ddb411f529792b018a69b4.json new file mode 100644 index 000000000000..5e63fca0ae11 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-977f3b25bc61a7d7909ebed92aedf6407d71c2e055ddb411f529792b018a69b4.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id,\n course_id,\n user_id,\n consent,\n created_at,\n updated_at,\n deleted_at\n FROM user_marketing_consents\n WHERE user_id = $1 AND course_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "course_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "consent", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": ["Uuid", "Uuid"] + }, + "nullable": [false, false, false, false, false, false, true] + }, + "hash": "977f3b25bc61a7d7909ebed92aedf6407d71c2e055ddb411f529792b018a69b4" +} diff --git a/services/headless-lms/models/.sqlx/query-82fe4061970937715a5e2d15682c8aed17f804b6a1823dad140ee6f5f4ff5ce6.json b/services/headless-lms/models/.sqlx/query-9fbc4e80f16e7cab3c8928ed195aa6b786df2e76edf819f9decfa7c441591925.json similarity index 89% rename from services/headless-lms/models/.sqlx/query-82fe4061970937715a5e2d15682c8aed17f804b6a1823dad140ee6f5f4ff5ce6.json rename to services/headless-lms/models/.sqlx/query-9fbc4e80f16e7cab3c8928ed195aa6b786df2e76edf819f9decfa7c441591925.json index ab5b9a6555d6..4807ce0f6f81 100644 --- a/services/headless-lms/models/.sqlx/query-82fe4061970937715a5e2d15682c8aed17f804b6a1823dad140ee6f5f4ff5ce6.json +++ b/services/headless-lms/models/.sqlx/query-9fbc4e80f16e7cab3c8928ed195aa6b786df2e76edf819f9decfa7c441591925.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nUPDATE courses\nSET deleted_at = now()\nWHERE id = $1\nRETURNING id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code\n ", + "query": "\nUPDATE courses\nSET deleted_at = now()\nWHERE id = $1\nRETURNING id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\n ", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "82fe4061970937715a5e2d15682c8aed17f804b6a1823dad140ee6f5f4ff5ce6" + "hash": "9fbc4e80f16e7cab3c8928ed195aa6b786df2e76edf819f9decfa7c441591925" } diff --git a/services/headless-lms/models/.sqlx/query-659d8635c8511990904c11ca7a200ae7559ffeca619e7a8ecc975b19fe933f2e.json b/services/headless-lms/models/.sqlx/query-a9f3390d8ad3debab9850325c54beba6bf46eaaec156732e307dcaa0ce08f600.json similarity index 90% rename from services/headless-lms/models/.sqlx/query-659d8635c8511990904c11ca7a200ae7559ffeca619e7a8ecc975b19fe933f2e.json rename to services/headless-lms/models/.sqlx/query-a9f3390d8ad3debab9850325c54beba6bf46eaaec156732e307dcaa0ce08f600.json index b6f0a6ccca3d..aad335a47252 100644 --- a/services/headless-lms/models/.sqlx/query-659d8635c8511990904c11ca7a200ae7559ffeca619e7a8ecc975b19fe933f2e.json +++ b/services/headless-lms/models/.sqlx/query-a9f3390d8ad3debab9850325c54beba6bf46eaaec156732e307dcaa0ce08f600.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code\nFROM courses\nWHERE id = $1;\n ", + "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\nFROM courses\nWHERE id = $1;\n ", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "659d8635c8511990904c11ca7a200ae7559ffeca619e7a8ecc975b19fe933f2e" + "hash": "a9f3390d8ad3debab9850325c54beba6bf46eaaec156732e307dcaa0ce08f600" } diff --git a/services/headless-lms/models/.sqlx/query-f85e90f73c74ed500cec06a100a1196db665c91c4b8805d70fa94ffca87da53a.json b/services/headless-lms/models/.sqlx/query-b5da68bc470f028a050fcca492eaf73c18e7f97137ea7f5f4ce1f1322e8c137c.json similarity index 79% rename from services/headless-lms/models/.sqlx/query-f85e90f73c74ed500cec06a100a1196db665c91c4b8805d70fa94ffca87da53a.json rename to services/headless-lms/models/.sqlx/query-b5da68bc470f028a050fcca492eaf73c18e7f97137ea7f5f4ce1f1322e8c137c.json index 0befed3a0998..3efd80c78a7e 100644 --- a/services/headless-lms/models/.sqlx/query-f85e90f73c74ed500cec06a100a1196db665c91c4b8805d70fa94ffca87da53a.json +++ b/services/headless-lms/models/.sqlx/query-b5da68bc470f028a050fcca492eaf73c18e7f97137ea7f5f4ce1f1322e8c137c.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT courses.id,\n courses.name,\n courses.created_at,\n courses.updated_at,\n courses.organization_id,\n courses.deleted_at,\n courses.slug,\n courses.content_search_language::text,\n courses.language_code,\n courses.copied_from,\n courses.course_language_group_id,\n courses.description,\n courses.is_draft,\n courses.is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n courses.is_unlisted,\n courses.is_joinable_by_code_only,\n courses.join_code\nFROM courses\nWHERE courses.organization_id = $1\n AND (\n (\n courses.is_draft IS FALSE\n AND courses.is_unlisted IS FALSE\n )\n OR EXISTS (\n SELECT id\n FROM roles\n WHERE user_id = $2\n AND (\n course_id = courses.id\n OR roles.organization_id = courses.organization_id\n OR roles.is_global IS TRUE\n )\n )\n )\n AND courses.deleted_at IS NULL\nORDER BY courses.name\nLIMIT $3 OFFSET $4;\n", + "query": "\nSELECT courses.id,\n courses.name,\n courses.created_at,\n courses.updated_at,\n courses.organization_id,\n courses.deleted_at,\n courses.slug,\n courses.content_search_language::text,\n courses.language_code,\n courses.copied_from,\n courses.course_language_group_id,\n courses.description,\n courses.is_draft,\n courses.is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n courses.is_unlisted,\n courses.is_joinable_by_code_only,\n courses.join_code,\n courses.ask_marketing_consent\nFROM courses\nWHERE courses.organization_id = $1\n AND (\n (\n courses.is_draft IS FALSE\n AND courses.is_unlisted IS FALSE\n )\n OR EXISTS (\n SELECT id\n FROM roles\n WHERE user_id = $2\n AND (\n course_id = courses.id\n OR roles.organization_id = courses.organization_id\n OR roles.is_global IS TRUE\n )\n )\n )\n AND courses.deleted_at IS NULL\nORDER BY courses.name\nLIMIT $3 OFFSET $4;\n", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "f85e90f73c74ed500cec06a100a1196db665c91c4b8805d70fa94ffca87da53a" + "hash": "b5da68bc470f028a050fcca492eaf73c18e7f97137ea7f5f4ce1f1322e8c137c" } diff --git a/services/headless-lms/models/.sqlx/query-dc6b04142ac276503be3b714a535f56e008e8516a2c38f2f149fbeaa1571c5ed.json b/services/headless-lms/models/.sqlx/query-b7647f94557ccce49973abf5a9829a594929d26cdbdebe3ae65a75e63b25d537.json similarity index 84% rename from services/headless-lms/models/.sqlx/query-dc6b04142ac276503be3b714a535f56e008e8516a2c38f2f149fbeaa1571c5ed.json rename to services/headless-lms/models/.sqlx/query-b7647f94557ccce49973abf5a9829a594929d26cdbdebe3ae65a75e63b25d537.json index 376c847197cd..e4832c5867c4 100644 --- a/services/headless-lms/models/.sqlx/query-dc6b04142ac276503be3b714a535f56e008e8516a2c38f2f149fbeaa1571c5ed.json +++ b/services/headless-lms/models/.sqlx/query-b7647f94557ccce49973abf5a9829a594929d26cdbdebe3ae65a75e63b25d537.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT\n DISTINCT(c.id),\n c.name,\n c.created_at,\n c.updated_at,\n c.organization_id,\n c.deleted_at,\n c.slug,\n c.content_search_language::text,\n c.language_code,\n c.copied_from,\n c.course_language_group_id,\n c.description,\n c.is_draft,\n c.is_test_mode,\n c.base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n c.is_unlisted,\n c.is_joinable_by_code_only,\n c.join_code\nFROM courses as c\n LEFT JOIN course_instances as ci on c.id = ci.course_id\nWHERE\n c.organization_id = $1 AND\n ci.starts_at < NOW() AND ci.ends_at > NOW() AND\n c.deleted_at IS NULL AND ci.deleted_at IS NULL\n LIMIT $2 OFFSET $3;\n ", + "query": "\nSELECT\n DISTINCT(c.id),\n c.name,\n c.created_at,\n c.updated_at,\n c.organization_id,\n c.deleted_at,\n c.slug,\n c.content_search_language::text,\n c.language_code,\n c.copied_from,\n c.course_language_group_id,\n c.description,\n c.is_draft,\n c.is_test_mode,\n c.base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n c.is_unlisted,\n c.is_joinable_by_code_only,\n c.join_code,\n c.ask_marketing_consent\nFROM courses as c\n LEFT JOIN course_instances as ci on c.id = ci.course_id\nWHERE\n c.organization_id = $1 AND\n ci.starts_at < NOW() AND ci.ends_at > NOW() AND\n c.deleted_at IS NULL AND ci.deleted_at IS NULL\n LIMIT $2 OFFSET $3;\n ", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "dc6b04142ac276503be3b714a535f56e008e8516a2c38f2f149fbeaa1571c5ed" + "hash": "b7647f94557ccce49973abf5a9829a594929d26cdbdebe3ae65a75e63b25d537" } diff --git a/services/headless-lms/models/.sqlx/query-50d7e5768bc24ec9a2c18d11ce77ac35db826c01ec7bc05cb905aab1c51ba7e4.json b/services/headless-lms/models/.sqlx/query-cdf31684a716948b9ed005acb59f8b556d48af088e3f489e627565a330b26ffb.json similarity index 90% rename from services/headless-lms/models/.sqlx/query-50d7e5768bc24ec9a2c18d11ce77ac35db826c01ec7bc05cb905aab1c51ba7e4.json rename to services/headless-lms/models/.sqlx/query-cdf31684a716948b9ed005acb59f8b556d48af088e3f489e627565a330b26ffb.json index c626d4e923bb..e14b52236fec 100644 --- a/services/headless-lms/models/.sqlx/query-50d7e5768bc24ec9a2c18d11ce77ac35db826c01ec7bc05cb905aab1c51ba7e4.json +++ b/services/headless-lms/models/.sqlx/query-cdf31684a716948b9ed005acb59f8b556d48af088e3f489e627565a330b26ffb.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code\nFROM courses\nWHERE deleted_at IS NULL;\n", + "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\nFROM courses\nWHERE deleted_at IS NULL;\n", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "50d7e5768bc24ec9a2c18d11ce77ac35db826c01ec7bc05cb905aab1c51ba7e4" + "hash": "cdf31684a716948b9ed005acb59f8b556d48af088e3f489e627565a330b26ffb" } diff --git a/services/headless-lms/models/.sqlx/query-13554c9b1e813c29066c90df0e04a3b84ed33fc39fadae2253c80a9761e39903.json b/services/headless-lms/models/.sqlx/query-e1a28636054909bb7e9622667a32bdcb2a17e6df2bf5b046fb6ecc92640943f5.json similarity index 89% rename from services/headless-lms/models/.sqlx/query-13554c9b1e813c29066c90df0e04a3b84ed33fc39fadae2253c80a9761e39903.json rename to services/headless-lms/models/.sqlx/query-e1a28636054909bb7e9622667a32bdcb2a17e6df2bf5b046fb6ecc92640943f5.json index e5e2739dbf36..8a4baf1bbeb6 100644 --- a/services/headless-lms/models/.sqlx/query-13554c9b1e813c29066c90df0e04a3b84ed33fc39fadae2253c80a9761e39903.json +++ b/services/headless-lms/models/.sqlx/query-e1a28636054909bb7e9622667a32bdcb2a17e6df2bf5b046fb6ecc92640943f5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code\nFROM courses\nWHERE id IN (SELECT * FROM UNNEST($1::uuid[]))\n ", + "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\nFROM courses\nWHERE id IN (SELECT * FROM UNNEST($1::uuid[]))\n ", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "13554c9b1e813c29066c90df0e04a3b84ed33fc39fadae2253c80a9761e39903" + "hash": "e1a28636054909bb7e9622667a32bdcb2a17e6df2bf5b046fb6ecc92640943f5" } diff --git a/services/headless-lms/models/.sqlx/query-5303f036fecb3efa8dce1756eaf2193d7e6c2f5cb7c9267b14e193daf525d997.json b/services/headless-lms/models/.sqlx/query-ee5080bbfb823d38cfbd65043c874be0cd06b4310d4a5156fdb306054916c9e5.json similarity index 78% rename from services/headless-lms/models/.sqlx/query-5303f036fecb3efa8dce1756eaf2193d7e6c2f5cb7c9267b14e193daf525d997.json rename to services/headless-lms/models/.sqlx/query-ee5080bbfb823d38cfbd65043c874be0cd06b4310d4a5156fdb306054916c9e5.json index a7f5f5a27a01..228b2784b987 100644 --- a/services/headless-lms/models/.sqlx/query-5303f036fecb3efa8dce1756eaf2193d7e6c2f5cb7c9267b14e193daf525d997.json +++ b/services/headless-lms/models/.sqlx/query-ee5080bbfb823d38cfbd65043c874be0cd06b4310d4a5156fdb306054916c9e5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nINSERT INTO courses (\n name,\n organization_id,\n slug,\n content_search_language,\n language_code,\n copied_from,\n course_language_group_id,\n is_draft,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code\n )\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)\nRETURNING id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code\n ", + "query": "\nINSERT INTO courses (\n name,\n organization_id,\n slug,\n content_search_language,\n language_code,\n copied_from,\n course_language_group_id,\n is_draft,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\n )\nVALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)\nRETURNING id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n base_module_completion_requires_n_submodule_completions,\n can_add_chatbot,\n is_unlisted,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\n ", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -113,7 +118,8 @@ "Bool", "Bool", "Bool", - "Varchar" + "Varchar", + "Bool" ] }, "nullable": [ @@ -135,8 +141,9 @@ false, false, false, - true + true, + false ] }, - "hash": "5303f036fecb3efa8dce1756eaf2193d7e6c2f5cb7c9267b14e193daf525d997" + "hash": "ee5080bbfb823d38cfbd65043c874be0cd06b4310d4a5156fdb306054916c9e5" } diff --git a/services/headless-lms/models/.sqlx/query-63132d1b35ab7549158a6f5e330221a49cd06eb066e344d338efee8289d68e35.json b/services/headless-lms/models/.sqlx/query-f53c5545a9f632da9076ce08319f10b891d1b22fa2716e958a3de831f22217c8.json similarity index 76% rename from services/headless-lms/models/.sqlx/query-63132d1b35ab7549158a6f5e330221a49cd06eb066e344d338efee8289d68e35.json rename to services/headless-lms/models/.sqlx/query-f53c5545a9f632da9076ce08319f10b891d1b22fa2716e958a3de831f22217c8.json index f6fe7417df8c..3411412c4dd7 100644 --- a/services/headless-lms/models/.sqlx/query-63132d1b35ab7549158a6f5e330221a49cd06eb066e344d338efee8289d68e35.json +++ b/services/headless-lms/models/.sqlx/query-f53c5545a9f632da9076ce08319f10b891d1b22fa2716e958a3de831f22217c8.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nUPDATE courses\nSET name = $1,\n description = $2,\n is_draft = $3,\n is_test_mode = $4,\n can_add_chatbot = $5,\n is_unlisted = $6,\n is_joinable_by_code_only = $7\nWHERE id = $8\nRETURNING id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code\n ", + "query": "\nUPDATE courses\nSET name = $1,\n description = $2,\n is_draft = $3,\n is_test_mode = $4,\n can_add_chatbot = $5,\n is_unlisted = $6,\n is_joinable_by_code_only = $7,\n ask_marketing_consent = $8\nWHERE id = $9\nRETURNING id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\n ", "describe": { "columns": [ { @@ -97,10 +97,15 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { - "Left": ["Varchar", "Text", "Bool", "Bool", "Bool", "Bool", "Bool", "Uuid"] + "Left": ["Varchar", "Text", "Bool", "Bool", "Bool", "Bool", "Bool", "Bool", "Uuid"] }, "nullable": [ false, @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "63132d1b35ab7549158a6f5e330221a49cd06eb066e344d338efee8289d68e35" + "hash": "f53c5545a9f632da9076ce08319f10b891d1b22fa2716e958a3de831f22217c8" } diff --git a/services/headless-lms/models/.sqlx/query-2f227e2bf3a4dd0828def3caa59b00246bf72c3a2bc0b40a9ff94e14ce61615f.json b/services/headless-lms/models/.sqlx/query-fa2a4f76355cf8f829e239ae278ab72efc11d1cf746c7081e5ca1e25eee9f7cd.json similarity index 89% rename from services/headless-lms/models/.sqlx/query-2f227e2bf3a4dd0828def3caa59b00246bf72c3a2bc0b40a9ff94e14ce61615f.json rename to services/headless-lms/models/.sqlx/query-fa2a4f76355cf8f829e239ae278ab72efc11d1cf746c7081e5ca1e25eee9f7cd.json index 90ecc8bfa63a..04de3c258834 100644 --- a/services/headless-lms/models/.sqlx/query-2f227e2bf3a4dd0828def3caa59b00246bf72c3a2bc0b40a9ff94e14ce61615f.json +++ b/services/headless-lms/models/.sqlx/query-fa2a4f76355cf8f829e239ae278ab72efc11d1cf746c7081e5ca1e25eee9f7cd.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code\nFROM courses\nWHERE slug = $1\n AND deleted_at IS NULL\n", + "query": "\nSELECT id,\n name,\n created_at,\n updated_at,\n organization_id,\n deleted_at,\n slug,\n content_search_language::text,\n language_code,\n copied_from,\n course_language_group_id,\n description,\n is_draft,\n is_test_mode,\n can_add_chatbot,\n is_unlisted,\n base_module_completion_requires_n_submodule_completions,\n is_joinable_by_code_only,\n join_code,\n ask_marketing_consent\nFROM courses\nWHERE join_code = $1\n AND deleted_at IS NULL;\n ", "describe": { "columns": [ { @@ -97,6 +97,11 @@ "ordinal": 18, "name": "join_code", "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "ask_marketing_consent", + "type_info": "Bool" } ], "parameters": { @@ -121,8 +126,9 @@ false, false, false, - true + true, + false ] }, - "hash": "2f227e2bf3a4dd0828def3caa59b00246bf72c3a2bc0b40a9ff94e14ce61615f" + "hash": "fa2a4f76355cf8f829e239ae278ab72efc11d1cf746c7081e5ca1e25eee9f7cd" } diff --git a/services/headless-lms/models/src/chapters.rs b/services/headless-lms/models/src/chapters.rs index 3bbde4796ad6..7c621e3accc2 100644 --- a/services/headless-lms/models/src/chapters.rs +++ b/services/headless-lms/models/src/chapters.rs @@ -620,6 +620,7 @@ mod tests { copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }, user, |_, _, _| unimplemented!(), diff --git a/services/headless-lms/models/src/courses.rs b/services/headless-lms/models/src/courses.rs index ed903a8ae4df..e3625590a900 100644 --- a/services/headless-lms/models/src/courses.rs +++ b/services/headless-lms/models/src/courses.rs @@ -48,6 +48,7 @@ pub struct Course { pub can_add_chatbot: bool, pub is_joinable_by_code_only: bool, pub join_code: Option, + pub ask_marketing_consent: bool, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] @@ -80,6 +81,7 @@ pub struct NewCourse { pub copy_user_permissions: bool, pub is_joinable_by_code_only: bool, pub join_code: Option, + pub ask_marketing_consent: bool, } pub async fn insert( @@ -166,7 +168,8 @@ SELECT id, can_add_chatbot, is_unlisted, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent FROM courses WHERE deleted_at IS NULL; "# @@ -201,7 +204,8 @@ SELECT id, base_module_completion_requires_n_submodule_completions, can_add_chatbot, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent FROM courses WHERE courses.deleted_at IS NULL AND id IN ( @@ -243,7 +247,8 @@ SELECT id, is_unlisted, base_module_completion_requires_n_submodule_completions, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent FROM courses WHERE courses.deleted_at IS NULL AND ( @@ -297,7 +302,8 @@ SELECT id, can_add_chatbot, is_unlisted, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent FROM courses WHERE course_language_group_id = $1 AND deleted_at IS NULL @@ -336,7 +342,8 @@ SELECT can_add_chatbot, c.is_unlisted, c.is_joinable_by_code_only, - c.join_code + c.join_code, + c.ask_marketing_consent FROM courses as c LEFT JOIN course_instances as ci on c.id = ci.course_id WHERE @@ -400,7 +407,8 @@ SELECT id, is_unlisted, base_module_completion_requires_n_submodule_completions, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent FROM courses WHERE id = $1; "#, @@ -506,7 +514,8 @@ SELECT courses.id, can_add_chatbot, courses.is_unlisted, courses.is_joinable_by_code_only, - courses.join_code + courses.join_code, + courses.ask_marketing_consent FROM courses WHERE courses.organization_id = $1 AND ( @@ -570,6 +579,7 @@ pub struct CourseUpdate { pub can_add_chatbot: bool, pub is_unlisted: bool, pub is_joinable_by_code_only: bool, + pub ask_marketing_consent: bool, } pub async fn update_course( @@ -587,8 +597,9 @@ SET name = $1, is_test_mode = $4, can_add_chatbot = $5, is_unlisted = $6, - is_joinable_by_code_only = $7 -WHERE id = $8 + is_joinable_by_code_only = $7, + ask_marketing_consent = $8 +WHERE id = $9 RETURNING id, name, created_at, @@ -607,7 +618,8 @@ RETURNING id, is_unlisted, base_module_completion_requires_n_submodule_completions, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent "#, course_update.name, course_update.description, @@ -616,6 +628,7 @@ RETURNING id, course_update.can_add_chatbot, course_update.is_unlisted, course_update.is_joinable_by_code_only, + course_update.ask_marketing_consent, course_id ) .fetch_one(conn) @@ -668,7 +681,8 @@ RETURNING id, is_unlisted, base_module_completion_requires_n_submodule_completions, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent "#, course_id ) @@ -699,7 +713,8 @@ SELECT id, is_unlisted, base_module_completion_requires_n_submodule_completions, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent FROM courses WHERE slug = $1 AND deleted_at IS NULL @@ -789,7 +804,8 @@ SELECT id, is_unlisted, base_module_completion_requires_n_submodule_completions, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent FROM courses WHERE id IN (SELECT * FROM UNNEST($1::uuid[])) ", @@ -844,7 +860,8 @@ SELECT id, is_unlisted, base_module_completion_requires_n_submodule_completions, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent FROM courses WHERE join_code = $1 AND deleted_at IS NULL; @@ -959,6 +976,7 @@ mod test { copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, } } } diff --git a/services/headless-lms/models/src/exams.rs b/services/headless-lms/models/src/exams.rs index ec1fd136abda..a1d5667fadb9 100644 --- a/services/headless-lms/models/src/exams.rs +++ b/services/headless-lms/models/src/exams.rs @@ -97,7 +97,8 @@ SELECT id, can_add_chatbot, is_unlisted, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent FROM courses JOIN course_exams ON courses.id = course_exams.course_id WHERE course_exams.exam_id = $1 diff --git a/services/headless-lms/models/src/lib.rs b/services/headless-lms/models/src/lib.rs index 27c65a6888a7..f532e0ea255d 100644 --- a/services/headless-lms/models/src/lib.rs +++ b/services/headless-lms/models/src/lib.rs @@ -46,6 +46,7 @@ pub mod generated_certificates; pub mod glossary; pub mod join_code_uses; pub mod library; +pub mod marketing_consent; pub mod material_references; pub mod offered_answers_to_peer_review_temporary; pub mod open_university_registration_links; diff --git a/services/headless-lms/models/src/library/copying.rs b/services/headless-lms/models/src/library/copying.rs index 48bf87d37de7..69d8ec8ddad5 100644 --- a/services/headless-lms/models/src/library/copying.rs +++ b/services/headless-lms/models/src/library/copying.rs @@ -50,9 +50,10 @@ INSERT INTO courses ( can_add_chatbot, is_unlisted, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent ) -VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, name, created_at, @@ -71,7 +72,8 @@ RETURNING id, can_add_chatbot, is_unlisted, is_joinable_by_code_only, - join_code + join_code, + ask_marketing_consent ", new_course.name, new_course.organization_id, @@ -85,7 +87,8 @@ RETURNING id, parent_course.can_add_chatbot, new_course.is_unlisted, new_course.is_joinable_by_code_only, - new_course.join_code + new_course.join_code, + new_course.ask_marketing_consent ) .fetch_one(&mut *tx) .await?; @@ -1172,6 +1175,7 @@ mod tests { copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, } } } diff --git a/services/headless-lms/models/src/marketing_consent.rs b/services/headless-lms/models/src/marketing_consent.rs new file mode 100644 index 000000000000..ffd0325067da --- /dev/null +++ b/services/headless-lms/models/src/marketing_consent.rs @@ -0,0 +1,65 @@ +use crate::prelude::*; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct UserMarketingConsent { + pub id: Uuid, + pub course_id: Uuid, + pub user_id: Uuid, + pub consent: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +pub async fn upsert_marketing_consent( + conn: &mut PgConnection, + course_id: Uuid, + user_id: &Uuid, + consent: bool, +) -> sqlx::Result { + let result = sqlx::query!( + r#" + INSERT INTO user_marketing_consents (user_id, course_id, consent) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, course_id) + DO UPDATE + SET consent = $3 + RETURNING id + "#, + user_id, + course_id, + consent + ) + .fetch_one(conn) + .await?; + + Ok(result.id) +} + +pub async fn fetch_user_marketing_consent( + conn: &mut PgConnection, + course_id: Uuid, + user_id: &Uuid, +) -> sqlx::Result { + let result = sqlx::query_as!( + UserMarketingConsent, + " + SELECT id, + course_id, + user_id, + consent, + created_at, + updated_at, + deleted_at + FROM user_marketing_consents + WHERE user_id = $1 AND course_id = $2 + ", + user_id, + course_id, + ) + .fetch_one(conn) + .await?; + + Ok(result) +} diff --git a/services/headless-lms/models/src/test_helper.rs b/services/headless-lms/models/src/test_helper.rs index c746eeee78af..73ca1707f923 100644 --- a/services/headless-lms/models/src/test_helper.rs +++ b/services/headless-lms/models/src/test_helper.rs @@ -176,6 +176,7 @@ macro_rules! insert_data { copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent:false, }, $user, |_, _, _| unimplemented!(), diff --git a/services/headless-lms/server/src/controllers/course_material/courses.rs b/services/headless-lms/server/src/controllers/course_material/courses.rs index d7da7ef6dae7..cf46750017b9 100644 --- a/services/headless-lms/server/src/controllers/course_material/courses.rs +++ b/services/headless-lms/server/src/controllers/course_material/courses.rs @@ -6,6 +6,7 @@ use actix_http::header::{self, X_FORWARDED_FOR}; use actix_web::web::Json; use chrono::Utc; use futures::{future::OptionFuture, FutureExt}; +use headless_lms_models::marketing_consent::UserMarketingConsent; use headless_lms_utils::ip_to_country::IpToCountryMapper; use isbot::Bots; use models::{ @@ -901,6 +902,62 @@ async fn get_research_form_answers_with_user_id( token.authorized_ok(web::Json(res)) } +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct UserMarketingConsentPayload { + pub consent: bool, +} +/** +POST `/api/v0/course-material/courses/:course_id/user-marketing-consent` - Adds or updates user's marketing consent for a specific course. +*/ + +#[instrument(skip(pool, payload))] +async fn update_marketing_consent( + payload: web::Json, + pool: web::Data, + course_id: web::Path, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let user_id = Some(user.id); + + let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?; + + let consent_data = payload.into_inner(); + + let result = models::marketing_consent::upsert_marketing_consent( + &mut conn, + *course_id, + &user.id, + consent_data.consent, + ) + .await?; + + token.authorized_ok(web::Json(result)) +} + +/** +GET `/api/v0/course-material/courses/:course_id/-fetch-user-marketing-consent` +*/ + +#[instrument(skip(pool))] +async fn fetch_user_marketing_consent( + pool: web::Data, + course_id: web::Path, + user: AuthUser, +) -> ControllerResult> { + let mut conn = pool.acquire().await?; + let user_id = Some(user.id); + + let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?; + + let result = + models::marketing_consent::fetch_user_marketing_consent(&mut conn, *course_id, &user.id) + .await?; + + token.authorized_ok(web::Json(result)) +} + /** Add a route for each controller in this module. @@ -982,5 +1039,13 @@ pub fn _add_routes(cfg: &mut ServiceConfig) { .route( "/{course_id}/research-consent-form-questions", web::get().to(get_research_form_questions_with_course_id), + ) + .route( + "/{course_id}/user-marketing-consent", + web::post().to(update_marketing_consent), + ) + .route( + "/{course_id}/fetch-user-marketing-consent", + web::get().to(fetch_user_marketing_consent), ); } diff --git a/services/headless-lms/server/src/programs/seed/seed_courses.rs b/services/headless-lms/server/src/programs/seed/seed_courses.rs index f03b0b96235c..5c695f815d1b 100644 --- a/services/headless-lms/server/src/programs/seed/seed_courses.rs +++ b/services/headless-lms/server/src/programs/seed/seed_courses.rs @@ -96,6 +96,7 @@ pub async fn seed_sample_course( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }; let (course, _front_page, default_instance, default_module) = library::content_management::create_new_course( @@ -2030,6 +2031,7 @@ pub async fn create_glossary_course( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }; let (course, _front_page, _default_instance, default_module) = @@ -2156,6 +2158,7 @@ pub async fn seed_cs_course_material( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }; let (course, front_page, default_instance, default_module) = library::content_management::create_new_course( @@ -3035,6 +3038,7 @@ pub async fn seed_course_without_submissions( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }; let (course, _front_page, _, default_module) = library::content_management::create_new_course( &mut conn, @@ -4442,6 +4446,7 @@ pub async fn seed_peer_review_course_without_submissions( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }; let (course, _front_page, _, default_module) = library::content_management::create_new_course( diff --git a/services/headless-lms/server/src/programs/seed/seed_organizations/uh_cs.rs b/services/headless-lms/server/src/programs/seed/seed_organizations/uh_cs.rs index 1dc59ebd0767..7f974d292d28 100644 --- a/services/headless-lms/server/src/programs/seed/seed_organizations/uh_cs.rs +++ b/services/headless-lms/server/src/programs/seed/seed_organizations/uh_cs.rs @@ -497,6 +497,7 @@ pub async fn seed_organization_uh_cs( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }; let (cs_course, _cs_front_page, _cs_default_course_instance, _cs_default_course_module) = library::content_management::create_new_course( diff --git a/services/headless-lms/server/src/programs/seed/seed_organizations/uh_mathstat.rs b/services/headless-lms/server/src/programs/seed/seed_organizations/uh_mathstat.rs index 518815c7009c..ead8ec2493d6 100644 --- a/services/headless-lms/server/src/programs/seed/seed_organizations/uh_mathstat.rs +++ b/services/headless-lms/server/src/programs/seed/seed_organizations/uh_mathstat.rs @@ -88,6 +88,7 @@ pub async fn seed_organization_uh_mathstat( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }; let ( statistics_course, @@ -136,6 +137,7 @@ pub async fn seed_organization_uh_mathstat( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }; library::content_management::create_new_course( &mut conn, @@ -166,6 +168,7 @@ pub async fn seed_organization_uh_mathstat( join_code: Some( "zARvZARjYhESMPVceEgZyJGQZZuUHVVgcUepyzEqzSqCMdbSCDrTaFhkJTxBshWU".to_string(), ), + ask_marketing_consent: false, }; library::content_management::create_new_course( &mut conn, @@ -216,6 +219,7 @@ pub async fn seed_organization_uh_mathstat( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }, true, admin_user_id, diff --git a/services/headless-lms/server/src/test_helper.rs b/services/headless-lms/server/src/test_helper.rs index 5f56be874e12..13b17dd9ded8 100644 --- a/services/headless-lms/server/src/test_helper.rs +++ b/services/headless-lms/server/src/test_helper.rs @@ -189,6 +189,7 @@ macro_rules! insert_data { copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent:false, }, $user, |_, _, _| unimplemented!(), diff --git a/services/headless-lms/server/src/ts_binding_generator.rs b/services/headless-lms/server/src/ts_binding_generator.rs index 5d814c983bfe..e004f1a0462d 100644 --- a/services/headless-lms/server/src/ts_binding_generator.rs +++ b/services/headless-lms/server/src/ts_binding_generator.rs @@ -170,6 +170,8 @@ fn models(target: &mut File) { library::progressing::UserModuleCompletionStatus, library::progressing::UserWithModuleCompletions, + marketing_consent::UserMarketingConsent, + material_references::MaterialReference, material_references::NewMaterialReference, diff --git a/services/main-frontend/src/components/forms/NewCourseForm.tsx b/services/main-frontend/src/components/forms/NewCourseForm.tsx index 2641ec481cc4..eda3f04cc6cb 100644 --- a/services/main-frontend/src/components/forms/NewCourseForm.tsx +++ b/services/main-frontend/src/components/forms/NewCourseForm.tsx @@ -86,6 +86,7 @@ const NewCourseForm: React.FC> = ({ copy_user_permissions: copyCourseUserPermissions, is_joinable_by_code_only: false, join_code: null, + ask_marketing_consent: false, } if (courseId) { await onSubmitDuplicateCourseForm(courseId, newCourse) @@ -122,6 +123,7 @@ const NewCourseForm: React.FC> = ({ copy_user_permissions: copyCourseUserPermissions, is_joinable_by_code_only: false, join_code: null, + ask_marketing_consent: false, }) setName("") setSlug("") diff --git a/services/main-frontend/src/components/page-specific/manage/courses/id/index/UpdateCourseForm.tsx b/services/main-frontend/src/components/page-specific/manage/courses/id/index/UpdateCourseForm.tsx index 9d2f93aa8c1f..b7f0476383c0 100644 --- a/services/main-frontend/src/components/page-specific/manage/courses/id/index/UpdateCourseForm.tsx +++ b/services/main-frontend/src/components/page-specific/manage/courses/id/index/UpdateCourseForm.tsx @@ -35,8 +35,8 @@ const UpdateCourseForm: React.FC> const [joinableByCodeOnlyStatus, setjoinableByCodeOnlyStatus] = useState( course.is_joinable_by_code_only, ) - const [canAddChatbot, setCanAddChatbot] = useState(course.can_add_chatbot) + const [askMarketingConsent, setAskMarketingConsent] = useState(course.ask_marketing_consent) const updateCourseMutation = useToastMutation( async () => { @@ -53,6 +53,7 @@ const UpdateCourseForm: React.FC> is_unlisted: unlisted, can_add_chatbot: canAddChatbot, is_joinable_by_code_only: joinableByCodeOnlyStatus, + ask_marketing_consent: askMarketingConsent, }) onSubmitForm() }, @@ -138,6 +139,16 @@ const UpdateCourseForm: React.FC> checked={joinableByCodeOnlyStatus} /> + + { + setAskMarketingConsent(!askMarketingConsent) + }} + checked={askMarketingConsent} + /> +
{languageChanged && ( diff --git a/services/course-material/src/services/backend.ts b/services/course-material/src/services/backend.ts index 9c4a0bbff891..174f5be18971 100644 --- a/services/course-material/src/services/backend.ts +++ b/services/course-material/src/services/backend.ts @@ -748,11 +748,13 @@ export const claimCodeFromCodeGiveaway = async (id: string): Promise => export const updateMarketingConsent = async ( courseId: string, + courseLanguageGroupsId: string, consent: boolean, ): Promise => { const res = await courseMaterialClient.post( `courses/${courseId}/user-marketing-consent`, { + course_language_groups_id: courseLanguageGroupsId, consent, }, { diff --git a/services/headless-lms/entrypoint/src/main.rs b/services/headless-lms/entrypoint/src/main.rs index f5a0fecc380d..67ad7fd70055 100644 --- a/services/headless-lms/entrypoint/src/main.rs +++ b/services/headless-lms/entrypoint/src/main.rs @@ -68,6 +68,10 @@ fn main() -> Result<()> { name: "chatbot-syncer", execute: Box::new(|| tokio_run(programs::chatbot_syncer::main())), }, + Program { + name: "mailchimp-syncer", + execute: Box::new(|| tokio_run(programs::mailchimp_syncer::main())), + }, ]; let program_name = std::env::args().nth(1).unwrap_or_else(|| { diff --git a/services/headless-lms/migrations/20241023104801_add-marketing-consent.down.sql b/services/headless-lms/migrations/20241023104801_add-marketing-consent.down.sql index 98b3ae849f61..677379c8bf5a 100644 --- a/services/headless-lms/migrations/20241023104801_add-marketing-consent.down.sql +++ b/services/headless-lms/migrations/20241023104801_add-marketing-consent.down.sql @@ -1,2 +1,3 @@ -DROP TABLE user_marketing_consents; ALTER TABLE courses DROP COLUMN ask_marketing_consent; +DROP TABLE user_marketing_consents; +DROP TABLE marketing_mailing_list_access_tokens; diff --git a/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql index fdeb98fbd20f..efee73a2c38e 100644 --- a/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql +++ b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql @@ -1,12 +1,19 @@ +ALTER TABLE courses +ADD COLUMN ask_marketing_consent BOOLEAN NOT NULL DEFAULT FALSE; + +COMMENT ON COLUMN courses.ask_marketing_consent IS 'Whether this course asks the user for marketing consent.'; + CREATE TABLE user_marketing_consents ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, course_id UUID NOT NULL REFERENCES courses(id), + course_language_groups_id UUID NOT NULL REFERENCES course_language_groups(id), user_id UUID NOT NULL REFERENCES users(id), consent BOOLEAN NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), deleted_at TIMESTAMP WITH TIME ZONE, - CONSTRAINT course_specific_marketing_consents_user_uniqueness UNIQUE NULLS NOT DISTINCT(user_id, course_id) + synced_to_mailchimp_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT course_language_group_specific_marketing_user_uniqueness UNIQUE NULLS NOT DISTINCT(user_id, course_language_groups_id) ); CREATE TRIGGER set_timestamp BEFORE @@ -15,12 +22,37 @@ UPDATE ON user_marketing_consents FOR EACH ROW EXECUTE PROCEDURE trigger_set_tim COMMENT ON TABLE user_marketing_consents IS 'This table is used to keep a record if a user has given a marketing consent to a course'; COMMENT ON COLUMN user_marketing_consents.id IS 'A unique, stable identifier for the record.'; COMMENT ON COLUMN user_marketing_consents.course_id IS 'Course that the user has access to.'; +COMMENT ON COLUMN user_marketing_consents.course_language_groups_id IS 'The course language group id that the mailing list is related to'; COMMENT ON COLUMN user_marketing_consents.user_id IS 'User who has the access to the course.'; COMMENT ON COLUMN user_marketing_consents.consent IS 'Wheter the user has given a marketing consent for a specific course.'; COMMENT ON COLUMN user_marketing_consents.created_at IS 'Timestamp of when the record was created.'; COMMENT ON COLUMN user_marketing_consents.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; COMMENT ON COLUMN user_marketing_consents.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; +COMMENT ON COLUMN user_marketing_consents.synced_to_mailchimp_at IS 'Timestamp when the record was synced to mailchimp. If null, the record has not been synced.'; -ALTER TABLE courses -ADD COLUMN ask_marketing_consent BOOLEAN NOT NULL DEFAULT FALSE; -COMMENT ON COLUMN courses.ask_marketing_consent IS 'Whether this course asks the user for marketing consent.'; + +CREATE TABLE marketing_mailing_list_access_tokens ( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + course_id UUID NOT NULL REFERENCES courses(id), + course_language_groups_id UUID NOT NULL REFERENCES course_language_groups(id), + server_prefix VARCHAR(255) NOT NULL, + access_token VARCHAR(255) NOT NULL, + mailchimp_mailing_list_id VARCHAR(255) NOT NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMP WITH TIME ZONE +); + +CREATE TRIGGER set_timestamp BEFORE +UPDATE ON marketing_mailing_list_access_tokens FOR EACH ROW EXECUTE PROCEDURE trigger_set_timestamp(); + +COMMENT ON TABLE marketing_mailing_list_access_tokens IS 'This table is used to keep a record of marketing mailing lists access tokens for each course language group'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.id IS 'A unique, stable identifier for the record.'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.course_id IS 'The course id that the the mailing list is related to'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.course_language_groups_id IS 'The course language group id that the mailing list is related to'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.server_prefix IS 'This value is used to configure API requests to the correct Mailchimp server.'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.access_token IS 'Token used for access authentication.'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.mailchimp_mailing_list_id IS 'Id of the mailing list used for marketing in Mailchimp'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.created_at IS 'Timestamp when the record was created.'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; diff --git a/services/headless-lms/models/.sqlx/query-0a19c9653e258dd34bce3053b2c7cecf9c5427530798ed0225f4a64d6f3880ba.json b/services/headless-lms/models/.sqlx/query-0a19c9653e258dd34bce3053b2c7cecf9c5427530798ed0225f4a64d6f3880ba.json new file mode 100644 index 000000000000..8459ff2f0c41 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-0a19c9653e258dd34bce3053b2c7cecf9c5427530798ed0225f4a64d6f3880ba.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n id,\n course_id,\n course_language_groups_id,\n server_prefix,\n access_token,\n mailchimp_mailing_list_id,\n created_at,\n updated_at,\n deleted_at\n FROM marketing_mailing_list_access_tokens\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "course_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "course_language_groups_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "server_prefix", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "access_token", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "mailchimp_mailing_list_id", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [false, false, false, false, false, false, false, false, true] + }, + "hash": "0a19c9653e258dd34bce3053b2c7cecf9c5427530798ed0225f4a64d6f3880ba" +} diff --git a/services/headless-lms/models/.sqlx/query-977f3b25bc61a7d7909ebed92aedf6407d71c2e055ddb411f529792b018a69b4.json b/services/headless-lms/models/.sqlx/query-111cad0889b81f60beab43abbe7a4f05bcae51d3838108f22872e54cd7b9c866.json similarity index 55% rename from services/headless-lms/models/.sqlx/query-977f3b25bc61a7d7909ebed92aedf6407d71c2e055ddb411f529792b018a69b4.json rename to services/headless-lms/models/.sqlx/query-111cad0889b81f60beab43abbe7a4f05bcae51d3838108f22872e54cd7b9c866.json index 5e63fca0ae11..341ec27dbdb0 100644 --- a/services/headless-lms/models/.sqlx/query-977f3b25bc61a7d7909ebed92aedf6407d71c2e055ddb411f529792b018a69b4.json +++ b/services/headless-lms/models/.sqlx/query-111cad0889b81f60beab43abbe7a4f05bcae51d3838108f22872e54cd7b9c866.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id,\n course_id,\n user_id,\n consent,\n created_at,\n updated_at,\n deleted_at\n FROM user_marketing_consents\n WHERE user_id = $1 AND course_id = $2\n ", + "query": "\n SELECT id,\n course_id,\n course_language_groups_id,\n user_id,\n consent,\n created_at,\n updated_at,\n deleted_at,\n synced_to_mailchimp_at\n FROM user_marketing_consents\n WHERE user_id = $1 AND course_id = $2\n ", "describe": { "columns": [ { @@ -15,34 +15,44 @@ }, { "ordinal": 2, - "name": "user_id", + "name": "course_language_groups_id", "type_info": "Uuid" }, { "ordinal": 3, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, "name": "consent", "type_info": "Bool" }, { - "ordinal": 4, + "ordinal": 5, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 5, + "ordinal": 6, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 7, "name": "deleted_at", "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "synced_to_mailchimp_at", + "type_info": "Timestamptz" } ], "parameters": { "Left": ["Uuid", "Uuid"] }, - "nullable": [false, false, false, false, false, false, true] + "nullable": [false, false, false, false, false, false, false, true, true] }, - "hash": "977f3b25bc61a7d7909ebed92aedf6407d71c2e055ddb411f529792b018a69b4" + "hash": "111cad0889b81f60beab43abbe7a4f05bcae51d3838108f22872e54cd7b9c866" } diff --git a/services/headless-lms/models/.sqlx/query-11a20e21e4996d77f0cef1949b9efebe3aa4c2616f15ae2ef95e6940849a6fed.json b/services/headless-lms/models/.sqlx/query-11a20e21e4996d77f0cef1949b9efebe3aa4c2616f15ae2ef95e6940849a6fed.json new file mode 100644 index 000000000000..e2ab13c487ae --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-11a20e21e4996d77f0cef1949b9efebe3aa4c2616f15ae2ef95e6940849a6fed.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_marketing_consents (user_id, course_id, course_language_groups_id, consent)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id, course_language_groups_id)\n DO UPDATE\n SET consent = $4\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Uuid", "Uuid", "Uuid", "Bool"] + }, + "nullable": [false] + }, + "hash": "11a20e21e4996d77f0cef1949b9efebe3aa4c2616f15ae2ef95e6940849a6fed" +} diff --git a/services/headless-lms/models/.sqlx/query-21c2e2209a0a7f8bb2a4e8f029b7debbec6a84e8df2dc822d5cfb16c01e26ed2.json b/services/headless-lms/models/.sqlx/query-21c2e2209a0a7f8bb2a4e8f029b7debbec6a84e8df2dc822d5cfb16c01e26ed2.json deleted file mode 100644 index e1be006d1bf5..000000000000 --- a/services/headless-lms/models/.sqlx/query-21c2e2209a0a7f8bb2a4e8f029b7debbec6a84e8df2dc822d5cfb16c01e26ed2.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO user_marketing_consents (user_id, course_id, consent)\n VALUES ($1, $2, $3)\n ON CONFLICT (user_id, course_id)\n DO UPDATE\n SET consent = $3\n RETURNING id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": ["Uuid", "Uuid", "Bool"] - }, - "nullable": [false] - }, - "hash": "21c2e2209a0a7f8bb2a4e8f029b7debbec6a84e8df2dc822d5cfb16c01e26ed2" -} diff --git a/services/headless-lms/models/.sqlx/query-9b2953438e17b270fe3e2d75065d57a9594cedd6b919eb083589c67f41f2fb78.json b/services/headless-lms/models/.sqlx/query-9b2953438e17b270fe3e2d75065d57a9594cedd6b919eb083589c67f41f2fb78.json new file mode 100644 index 000000000000..fe2076f8e9ea --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-9b2953438e17b270fe3e2d75065d57a9594cedd6b919eb083589c67f41f2fb78.json @@ -0,0 +1,92 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_groups_id,\n umc.user_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n WHERE umc.course_language_groups_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL\n OR umc.synced_to_mailchimp_at < umc.updated_at\n OR umc.synced_to_mailchimp_at < u.updated_at)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "course_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "course_language_groups_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "consent", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "synced_to_mailchimp_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "first_name", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "last_name", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "course_name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "9b2953438e17b270fe3e2d75065d57a9594cedd6b919eb083589c67f41f2fb78" +} diff --git a/services/headless-lms/models/.sqlx/query-b4f20a804262249fc3d78231ba9c44311c1c06fa87cbec9f5f9c87417f554c10.json b/services/headless-lms/models/.sqlx/query-b4f20a804262249fc3d78231ba9c44311c1c06fa87cbec9f5f9c87417f554c10.json new file mode 100644 index 000000000000..94a7695bbc6a --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-b4f20a804262249fc3d78231ba9c44311c1c06fa87cbec9f5f9c87417f554c10.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE user_marketing_consents\nSET synced_to_mailchimp_at = now()\nWHERE id IN (\n SELECT UNNEST($1::uuid [])\n )\n", + "describe": { + "columns": [], + "parameters": { + "Left": ["UuidArray"] + }, + "nullable": [] + }, + "hash": "b4f20a804262249fc3d78231ba9c44311c1c06fa87cbec9f5f9c87417f554c10" +} diff --git a/services/headless-lms/models/src/lib.rs b/services/headless-lms/models/src/lib.rs index f532e0ea255d..9265e998829b 100644 --- a/services/headless-lms/models/src/lib.rs +++ b/services/headless-lms/models/src/lib.rs @@ -46,7 +46,7 @@ pub mod generated_certificates; pub mod glossary; pub mod join_code_uses; pub mod library; -pub mod marketing_consent; +pub mod marketing_consents; pub mod material_references; pub mod offered_answers_to_peer_review_temporary; pub mod open_university_registration_links; diff --git a/services/headless-lms/models/src/marketing_consent.rs b/services/headless-lms/models/src/marketing_consent.rs deleted file mode 100644 index ffd0325067da..000000000000 --- a/services/headless-lms/models/src/marketing_consent.rs +++ /dev/null @@ -1,65 +0,0 @@ -use crate::prelude::*; - -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] -#[cfg_attr(feature = "ts_rs", derive(TS))] -pub struct UserMarketingConsent { - pub id: Uuid, - pub course_id: Uuid, - pub user_id: Uuid, - pub consent: bool, - pub created_at: DateTime, - pub updated_at: DateTime, - pub deleted_at: Option>, -} - -pub async fn upsert_marketing_consent( - conn: &mut PgConnection, - course_id: Uuid, - user_id: &Uuid, - consent: bool, -) -> sqlx::Result { - let result = sqlx::query!( - r#" - INSERT INTO user_marketing_consents (user_id, course_id, consent) - VALUES ($1, $2, $3) - ON CONFLICT (user_id, course_id) - DO UPDATE - SET consent = $3 - RETURNING id - "#, - user_id, - course_id, - consent - ) - .fetch_one(conn) - .await?; - - Ok(result.id) -} - -pub async fn fetch_user_marketing_consent( - conn: &mut PgConnection, - course_id: Uuid, - user_id: &Uuid, -) -> sqlx::Result { - let result = sqlx::query_as!( - UserMarketingConsent, - " - SELECT id, - course_id, - user_id, - consent, - created_at, - updated_at, - deleted_at - FROM user_marketing_consents - WHERE user_id = $1 AND course_id = $2 - ", - user_id, - course_id, - ) - .fetch_one(conn) - .await?; - - Ok(result) -} diff --git a/services/headless-lms/models/src/marketing_consents.rs b/services/headless-lms/models/src/marketing_consents.rs new file mode 100644 index 000000000000..6bbd50ff0315 --- /dev/null +++ b/services/headless-lms/models/src/marketing_consents.rs @@ -0,0 +1,187 @@ +use crate::prelude::*; + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct UserMarketingConsent { + pub id: Uuid, + pub course_id: Uuid, + pub course_language_groups_id: Uuid, + pub user_id: Uuid, + pub consent: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, + pub synced_to_mailchimp_at: Option>, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct UserMarketingConsentWithDetails { + pub id: Uuid, + pub course_id: Uuid, + pub course_language_groups_id: Uuid, + pub user_id: Uuid, + pub consent: bool, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, + pub synced_to_mailchimp_at: Option>, + pub first_name: Option, + pub last_name: Option, + pub email: String, + pub course_name: String, +} + +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] + +pub struct MarketingMailingListAccessToken { + pub id: Uuid, + pub course_id: Uuid, + pub course_language_groups_id: Uuid, + pub server_prefix: String, + pub access_token: String, + pub mailchimp_mailing_list_id: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub deleted_at: Option>, +} + +pub async fn upsert_marketing_consent( + conn: &mut PgConnection, + course_id: Uuid, + course_language_groups_id: Uuid, + user_id: &Uuid, + consent: bool, +) -> sqlx::Result { + let result = sqlx::query!( + r#" + INSERT INTO user_marketing_consents (user_id, course_id, course_language_groups_id, consent) + VALUES ($1, $2, $3, $4) + ON CONFLICT (user_id, course_language_groups_id) + DO UPDATE + SET consent = $4 + RETURNING id + "#, + user_id, + course_id, + course_language_groups_id, + consent + ) + .fetch_one(conn) + .await?; + + Ok(result.id) +} + +pub async fn fetch_user_marketing_consent( + conn: &mut PgConnection, + course_id: Uuid, + user_id: &Uuid, +) -> sqlx::Result { + let result = sqlx::query_as!( + UserMarketingConsent, + " + SELECT id, + course_id, + course_language_groups_id, + user_id, + consent, + created_at, + updated_at, + deleted_at, + synced_to_mailchimp_at + FROM user_marketing_consents + WHERE user_id = $1 AND course_id = $2 + ", + user_id, + course_id, + ) + .fetch_one(conn) + .await?; + + Ok(result) +} + +// Fetches all user marketing consents with detailed user information for a specific course language group, if they haven't been synced to Mailchimp or if there have been updates since the last sync. +pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_groups_id( + conn: &mut PgConnection, + course_language_groups_id: Uuid, +) -> sqlx::Result> { + let result = sqlx::query_as!( + UserMarketingConsentWithDetails, + " + SELECT + umc.id, + umc.course_id, + umc.course_language_groups_id, + umc.user_id, + umc.consent, + umc.created_at, + umc.updated_at, + umc.deleted_at, + umc.synced_to_mailchimp_at, + u.first_name AS first_name, + u.last_name AS last_name, + u.email AS email, + c.name AS course_name + FROM user_marketing_consents AS umc + JOIN user_details AS u ON u.user_id = umc.user_id + JOIN courses AS c ON c.id = umc.course_id + WHERE umc.course_language_groups_id = $1 + AND (umc.synced_to_mailchimp_at IS NULL + OR umc.synced_to_mailchimp_at < umc.updated_at + OR umc.synced_to_mailchimp_at < u.updated_at) + ", + course_language_groups_id + ) + .fetch_all(conn) + .await?; + + Ok(result) +} + +// Used to update the synced_to_mailchimp_at to a list of users when they are successfully synced to mailchimp +pub async fn update_synced_to_mailchimp_at_to_all_synced_users( + conn: &mut PgConnection, + ids: &[Uuid], +) -> ModelResult<()> { + sqlx::query!( + " +UPDATE user_marketing_consents +SET synced_to_mailchimp_at = now() +WHERE id IN ( + SELECT UNNEST($1::uuid []) + ) +", + &ids + ) + .execute(conn) + .await?; + Ok(()) +} + +pub async fn fetch_all_marketing_mailing_list_access_tokens( + conn: &mut PgConnection, +) -> sqlx::Result> { + let results = sqlx::query_as!( + MarketingMailingListAccessToken, + " + SELECT + id, + course_id, + course_language_groups_id, + server_prefix, + access_token, + mailchimp_mailing_list_id, + created_at, + updated_at, + deleted_at + FROM marketing_mailing_list_access_tokens + " + ) + .fetch_all(conn) + .await?; + + Ok(results) +} diff --git a/services/headless-lms/server/src/controllers/course_material/courses.rs b/services/headless-lms/server/src/controllers/course_material/courses.rs index cf46750017b9..3d2243e49c14 100644 --- a/services/headless-lms/server/src/controllers/course_material/courses.rs +++ b/services/headless-lms/server/src/controllers/course_material/courses.rs @@ -6,7 +6,7 @@ use actix_http::header::{self, X_FORWARDED_FOR}; use actix_web::web::Json; use chrono::Utc; use futures::{future::OptionFuture, FutureExt}; -use headless_lms_models::marketing_consent::UserMarketingConsent; +use headless_lms_models::marketing_consents::UserMarketingConsent; use headless_lms_utils::ip_to_country::IpToCountryMapper; use isbot::Bots; use models::{ @@ -905,6 +905,7 @@ async fn get_research_form_answers_with_user_id( #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[cfg_attr(feature = "ts_rs", derive(TS))] pub struct UserMarketingConsentPayload { + pub course_language_groups_id: Uuid, pub consent: bool, } /** @@ -923,13 +924,12 @@ async fn update_marketing_consent( let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?; - let consent_data = payload.into_inner(); - - let result = models::marketing_consent::upsert_marketing_consent( + let result = models::marketing_consents::upsert_marketing_consent( &mut conn, *course_id, + payload.course_language_groups_id, &user.id, - consent_data.consent, + payload.consent, ) .await?; @@ -945,15 +945,16 @@ async fn fetch_user_marketing_consent( pool: web::Data, course_id: web::Path, user: AuthUser, -) -> ControllerResult> { +) -> ControllerResult>> { let mut conn = pool.acquire().await?; let user_id = Some(user.id); let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?; let result = - models::marketing_consent::fetch_user_marketing_consent(&mut conn, *course_id, &user.id) - .await?; + models::marketing_consents::fetch_user_marketing_consent(&mut conn, *course_id, &user.id) + .await + .ok(); token.authorized_ok(web::Json(result)) } diff --git a/services/headless-lms/server/src/programs/mod.rs b/services/headless-lms/server/src/programs/mod.rs index 903390f0677c..22503f5a7710 100644 --- a/services/headless-lms/server/src/programs/mod.rs +++ b/services/headless-lms/server/src/programs/mod.rs @@ -6,6 +6,7 @@ pub mod chatbot_syncer; pub mod doc_file_generator; pub mod email_deliver; pub mod ended_exams_processor; +pub mod mailchimp_syncer; pub mod open_university_registration_link_fetcher; pub mod peer_review_updater; pub mod regrader; From 4a7a7709e36d31aa759761e9301e37167b44525b Mon Sep 17 00:00:00 2001 From: Maija Y Date: Fri, 22 Nov 2024 16:02:10 +0200 Subject: [PATCH 03/16] Mailchimp syncer --- ...0241023104801_add-marketing-consent.up.sql | 2 + ...c160f8bd276628e02678031d28e6545dfbb22.json | 110 ++++ ...e30eb4e094a84a06a884c432bfc841c7f8bc.json} | 19 +- ...f2f884ee622559d13dfa0d47261cc9f8d4a2.json} | 4 +- ...56c796ee19b751ffe4740d06600cdae0fd69.json} | 40 +- .../models/src/marketing_consents.rs | 147 ++++- .../server/src/programs/mailchimp_syncer.rs | 615 ++++++++++++++++++ 7 files changed, 913 insertions(+), 24 deletions(-) create mode 100644 services/headless-lms/models/.sqlx/query-074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22.json rename services/headless-lms/models/.sqlx/{query-111cad0889b81f60beab43abbe7a4f05bcae51d3838108f22872e54cd7b9c866.json => query-7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc.json} (68%) rename services/headless-lms/models/.sqlx/{query-b4f20a804262249fc3d78231ba9c44311c1c06fa87cbec9f5f9c87417f554c10.json => query-a6659b3a5772ec77fc86b1a7e6abf2f884ee622559d13dfa0d47261cc9f8d4a2.json} (57%) rename services/headless-lms/models/.sqlx/{query-9b2953438e17b270fe3e2d75065d57a9594cedd6b919eb083589c67f41f2fb78.json => query-b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69.json} (57%) create mode 100644 services/headless-lms/server/src/programs/mailchimp_syncer.rs diff --git a/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql index efee73a2c38e..b11403711143 100644 --- a/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql +++ b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql @@ -8,6 +8,7 @@ CREATE TABLE user_marketing_consents ( course_id UUID NOT NULL REFERENCES courses(id), course_language_groups_id UUID NOT NULL REFERENCES course_language_groups(id), user_id UUID NOT NULL REFERENCES users(id), + user_mailchimp_id VARCHAR(255), consent BOOLEAN NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), @@ -24,6 +25,7 @@ COMMENT ON COLUMN user_marketing_consents.id IS 'A unique, stable identifier for COMMENT ON COLUMN user_marketing_consents.course_id IS 'Course that the user has access to.'; COMMENT ON COLUMN user_marketing_consents.course_language_groups_id IS 'The course language group id that the mailing list is related to'; COMMENT ON COLUMN user_marketing_consents.user_id IS 'User who has the access to the course.'; +COMMENT ON COLUMN user_marketing_consents.user_mailchimp_id IS 'Unique id for the user, provided by Mailchimp'; COMMENT ON COLUMN user_marketing_consents.consent IS 'Wheter the user has given a marketing consent for a specific course.'; COMMENT ON COLUMN user_marketing_consents.created_at IS 'Timestamp of when the record was created.'; COMMENT ON COLUMN user_marketing_consents.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; diff --git a/services/headless-lms/models/.sqlx/query-074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22.json b/services/headless-lms/models/.sqlx/query-074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22.json new file mode 100644 index 000000000000..f9872615952b --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22.json @@ -0,0 +1,110 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_groups_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_groups_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL\n OR umc.synced_to_mailchimp_at < umc.updated_at)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "course_id", + "type_info": "Uuid" + }, + { + "ordinal": 2, + "name": "course_language_groups_id", + "type_info": "Uuid" + }, + { + "ordinal": 3, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 4, + "name": "user_mailchimp_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "consent", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "synced_to_mailchimp_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "first_name", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "last_name", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "course_name", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "locale", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "completed_course", + "type_info": "Bool" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + false, + false, + true, + true, + true, + true, + false, + false, + false, + null + ] + }, + "hash": "074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22" +} diff --git a/services/headless-lms/models/.sqlx/query-111cad0889b81f60beab43abbe7a4f05bcae51d3838108f22872e54cd7b9c866.json b/services/headless-lms/models/.sqlx/query-7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc.json similarity index 68% rename from services/headless-lms/models/.sqlx/query-111cad0889b81f60beab43abbe7a4f05bcae51d3838108f22872e54cd7b9c866.json rename to services/headless-lms/models/.sqlx/query-7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc.json index 341ec27dbdb0..67b8333e2e76 100644 --- a/services/headless-lms/models/.sqlx/query-111cad0889b81f60beab43abbe7a4f05bcae51d3838108f22872e54cd7b9c866.json +++ b/services/headless-lms/models/.sqlx/query-7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id,\n course_id,\n course_language_groups_id,\n user_id,\n consent,\n created_at,\n updated_at,\n deleted_at,\n synced_to_mailchimp_at\n FROM user_marketing_consents\n WHERE user_id = $1 AND course_id = $2\n ", + "query": "\n SELECT id,\n course_id,\n course_language_groups_id,\n user_id,\n user_mailchimp_id,\n consent,\n created_at,\n updated_at,\n deleted_at,\n synced_to_mailchimp_at\n FROM user_marketing_consents\n WHERE user_id = $1 AND course_id = $2\n ", "describe": { "columns": [ { @@ -25,26 +25,31 @@ }, { "ordinal": 4, + "name": "user_mailchimp_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "consent", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 7, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "deleted_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "synced_to_mailchimp_at", "type_info": "Timestamptz" } @@ -52,7 +57,7 @@ "parameters": { "Left": ["Uuid", "Uuid"] }, - "nullable": [false, false, false, false, false, false, false, true, true] + "nullable": [false, false, false, false, true, false, false, false, true, true] }, - "hash": "111cad0889b81f60beab43abbe7a4f05bcae51d3838108f22872e54cd7b9c866" + "hash": "7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc" } diff --git a/services/headless-lms/models/.sqlx/query-b4f20a804262249fc3d78231ba9c44311c1c06fa87cbec9f5f9c87417f554c10.json b/services/headless-lms/models/.sqlx/query-a6659b3a5772ec77fc86b1a7e6abf2f884ee622559d13dfa0d47261cc9f8d4a2.json similarity index 57% rename from services/headless-lms/models/.sqlx/query-b4f20a804262249fc3d78231ba9c44311c1c06fa87cbec9f5f9c87417f554c10.json rename to services/headless-lms/models/.sqlx/query-a6659b3a5772ec77fc86b1a7e6abf2f884ee622559d13dfa0d47261cc9f8d4a2.json index 94a7695bbc6a..b297e6c6c716 100644 --- a/services/headless-lms/models/.sqlx/query-b4f20a804262249fc3d78231ba9c44311c1c06fa87cbec9f5f9c87417f554c10.json +++ b/services/headless-lms/models/.sqlx/query-a6659b3a5772ec77fc86b1a7e6abf2f884ee622559d13dfa0d47261cc9f8d4a2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\nUPDATE user_marketing_consents\nSET synced_to_mailchimp_at = now()\nWHERE id IN (\n SELECT UNNEST($1::uuid [])\n )\n", + "query": "\nUPDATE user_marketing_consents\nSET synced_to_mailchimp_at = now()\nWHERE user_id IN (\n SELECT UNNEST($1::uuid [])\n )\n", "describe": { "columns": [], "parameters": { @@ -8,5 +8,5 @@ }, "nullable": [] }, - "hash": "b4f20a804262249fc3d78231ba9c44311c1c06fa87cbec9f5f9c87417f554c10" + "hash": "a6659b3a5772ec77fc86b1a7e6abf2f884ee622559d13dfa0d47261cc9f8d4a2" } diff --git a/services/headless-lms/models/.sqlx/query-9b2953438e17b270fe3e2d75065d57a9594cedd6b919eb083589c67f41f2fb78.json b/services/headless-lms/models/.sqlx/query-b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69.json similarity index 57% rename from services/headless-lms/models/.sqlx/query-9b2953438e17b270fe3e2d75065d57a9594cedd6b919eb083589c67f41f2fb78.json rename to services/headless-lms/models/.sqlx/query-b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69.json index fe2076f8e9ea..f236a15d33fe 100644 --- a/services/headless-lms/models/.sqlx/query-9b2953438e17b270fe3e2d75065d57a9594cedd6b919eb083589c67f41f2fb78.json +++ b/services/headless-lms/models/.sqlx/query-b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_groups_id,\n umc.user_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n WHERE umc.course_language_groups_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL\n OR umc.synced_to_mailchimp_at < umc.updated_at\n OR umc.synced_to_mailchimp_at < u.updated_at)\n ", + "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_groups_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_groups_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < u.updated_at)\n ", "describe": { "columns": [ { @@ -25,48 +25,63 @@ }, { "ordinal": 4, + "name": "user_mailchimp_id", + "type_info": "Varchar" + }, + { + "ordinal": 5, "name": "consent", "type_info": "Bool" }, { - "ordinal": 5, + "ordinal": 6, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 7, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "deleted_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "synced_to_mailchimp_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "first_name", "type_info": "Varchar" }, { - "ordinal": 10, + "ordinal": 11, "name": "last_name", "type_info": "Varchar" }, { - "ordinal": 11, + "ordinal": 12, "name": "email", "type_info": "Varchar" }, { - "ordinal": 12, + "ordinal": 13, "name": "course_name", "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "locale", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "completed_course", + "type_info": "Bool" } ], "parameters": { @@ -77,6 +92,7 @@ false, false, false, + true, false, false, false, @@ -85,8 +101,10 @@ true, true, false, - false + false, + false, + null ] }, - "hash": "9b2953438e17b270fe3e2d75065d57a9594cedd6b919eb083589c67f41f2fb78" + "hash": "b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69" } diff --git a/services/headless-lms/models/src/marketing_consents.rs b/services/headless-lms/models/src/marketing_consents.rs index 6bbd50ff0315..0c1c1494af8b 100644 --- a/services/headless-lms/models/src/marketing_consents.rs +++ b/services/headless-lms/models/src/marketing_consents.rs @@ -7,6 +7,7 @@ pub struct UserMarketingConsent { pub course_id: Uuid, pub course_language_groups_id: Uuid, pub user_id: Uuid, + pub user_mailchimp_id: Option, pub consent: bool, pub created_at: DateTime, pub updated_at: DateTime, @@ -21,6 +22,7 @@ pub struct UserMarketingConsentWithDetails { pub course_id: Uuid, pub course_language_groups_id: Uuid, pub user_id: Uuid, + pub user_mailchimp_id: Option, pub consent: bool, pub created_at: DateTime, pub updated_at: DateTime, @@ -30,6 +32,8 @@ pub struct UserMarketingConsentWithDetails { pub last_name: Option, pub email: String, pub course_name: String, + pub locale: Option, + pub completed_course: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] @@ -86,6 +90,7 @@ pub async fn fetch_user_marketing_consent( course_id, course_language_groups_id, user_id, + user_mailchimp_id, consent, created_at, updated_at, @@ -116,6 +121,7 @@ pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_group umc.course_id, umc.course_language_groups_id, umc.user_id, + umc.user_mailchimp_id, umc.consent, umc.created_at, umc.updated_at, @@ -124,14 +130,58 @@ pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_group u.first_name AS first_name, u.last_name AS last_name, u.email AS email, - c.name AS course_name + c.name AS course_name, + c.language_code AS locale, + CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course FROM user_marketing_consents AS umc JOIN user_details AS u ON u.user_id = umc.user_id JOIN courses AS c ON c.id = umc.course_id + LEFT JOIN course_module_completions AS cmc + ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id WHERE umc.course_language_groups_id = $1 AND (umc.synced_to_mailchimp_at IS NULL - OR umc.synced_to_mailchimp_at < umc.updated_at - OR umc.synced_to_mailchimp_at < u.updated_at) + OR umc.synced_to_mailchimp_at < umc.updated_at) + ", + course_language_groups_id + ) + .fetch_all(conn) + .await?; + + Ok(result) +} + +/// Fetches all user details that have been updated after their marketing consent was last synced to Mailchimp +pub async fn fetch_all_unsynced_updated_emails( + conn: &mut PgConnection, + course_language_groups_id: Uuid, +) -> sqlx::Result> { + let result = sqlx::query_as!( + UserMarketingConsentWithDetails, + " + SELECT + umc.id, + umc.course_id, + umc.course_language_groups_id, + umc.user_id, + umc.user_mailchimp_id, + umc.consent, + umc.created_at, + umc.updated_at, + umc.deleted_at, + umc.synced_to_mailchimp_at, + u.first_name AS first_name, + u.last_name AS last_name, + u.email AS email, + c.name AS course_name, + c.language_code AS locale, + CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course + FROM user_marketing_consents AS umc + JOIN user_details AS u ON u.user_id = umc.user_id + JOIN courses AS c ON c.id = umc.course_id + LEFT JOIN course_module_completions AS cmc + ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id + WHERE umc.course_language_groups_id = $1 + AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < u.updated_at) ", course_language_groups_id ) @@ -150,7 +200,7 @@ pub async fn update_synced_to_mailchimp_at_to_all_synced_users( " UPDATE user_marketing_consents SET synced_to_mailchimp_at = now() -WHERE id IN ( +WHERE user_id IN ( SELECT UNNEST($1::uuid []) ) ", @@ -161,6 +211,95 @@ WHERE id IN ( Ok(()) } +// Used to add the user_mailchimp_ids to a list of new users when they are successfully synced to mailchimp +pub async fn update_user_mailchimp_id_at_to_all_synced_users( + pool: &mut PgConnection, + user_contact_pairs: Vec<(String, String)>, +) -> ModelResult<()> { + let user_ids: Vec = user_contact_pairs + .iter() + .map(|(user_id, _)| user_id.clone()) + .collect(); + let user_mailchimp_ids: Vec = user_contact_pairs + .iter() + .map(|(_, mailchimp_id)| mailchimp_id.clone()) + .collect(); + + // Updated query + let query = r#" + UPDATE user_marketing_consents + SET user_mailchimp_id = updated_data.user_mailchimp_id + FROM ( + SELECT UNNEST($1::Uuid[]) AS user_id, UNNEST($2::text[]) AS user_mailchimp_id + ) AS updated_data + WHERE user_marketing_consents.user_id = updated_data.user_id + "#; + + // Execute the query with the `user_ids` and `user_mailchimp_ids` + sqlx::query(query) + .bind(&user_ids) + .bind(&user_mailchimp_ids) + .execute(pool) + .await?; + + Ok(()) +} + +/// Updates user consents in bulk using Mailchimp data. +pub async fn update_bulk_user_consent( + conn: &mut PgConnection, + mailchimp_data: Vec<(String, bool, String, String)>, +) -> anyhow::Result<()> { + let user_ids: Vec = mailchimp_data + .iter() + .filter_map(|(user_id, _, _, _)| Uuid::parse_str(user_id).ok()) + .collect(); + + let consents: Vec = mailchimp_data + .iter() + .map(|(_, consent, _, _)| *consent) + .collect(); + + let timestamps: Vec> = mailchimp_data + .iter() + .filter_map(|(_, _, ts, _)| { + DateTime::parse_from_rfc3339(ts) + .ok() + .map(|dt| dt.with_timezone(&Utc)) // Convert to Utc + }) + .collect(); + + let course_language_groups_ids: Vec = mailchimp_data + .iter() + .filter_map(|(_, _, _, lang_id)| Uuid::parse_str(lang_id).ok()) + .collect(); + + let query = r#" + UPDATE user_marketing_consents + SET consent = updated_data.consent, + synced_to_mailchimp_at = updated_data.last_updated + FROM ( + SELECT UNNEST($1::Uuid[]) AS user_id, + UNNEST($2::bool[]) AS consent, + UNNEST($3::timestamptz[]) AS last_updated, + UNNEST($4::Uuid[]) AS course_language_groups_id + ) AS updated_data + WHERE user_marketing_consents.user_id = updated_data.user_id + AND user_marketing_consents.synced_to_mailchimp_at < updated_data.last_updated + AND user_marketing_consents.course_language_groups_id = updated_data.course_language_groups_id + "#; + + // Execute the query + sqlx::query(query) + .bind(&user_ids) + .bind(&consents) + .bind(×tamps) + .bind(&course_language_groups_ids) + .execute(conn) + .await?; + Ok(()) +} + pub async fn fetch_all_marketing_mailing_list_access_tokens( conn: &mut PgConnection, ) -> sqlx::Result> { diff --git a/services/headless-lms/server/src/programs/mailchimp_syncer.rs b/services/headless-lms/server/src/programs/mailchimp_syncer.rs new file mode 100644 index 000000000000..fae6fa6dca9b --- /dev/null +++ b/services/headless-lms/server/src/programs/mailchimp_syncer.rs @@ -0,0 +1,615 @@ +use crate::setup_tracing; +use dotenv::dotenv; +use headless_lms_models::marketing_consents::MarketingMailingListAccessToken; +use headless_lms_models::marketing_consents::UserMarketingConsentWithDetails; +use headless_lms_utils::http::REQWEST_CLIENT; +use serde::Deserialize; +use serde_json::json; +use sqlx::{PgConnection, PgPool}; +use std::{env, time::Duration}; +use uuid::Uuid; + +#[derive(Debug, Deserialize)] +struct MailchimpField { + field_id: String, + field_name: String, +} + +const REQUIRED_FIELDS: &[&str] = &[ + "FNAME", + "LNAME", + "MARKETING", + "LOCALE", + "GRADUATED", + "COURSEID", + "LANGGRPID", + "USERID", +]; + +const SYNC_INTERVAL_SECS: u64 = 10; +const PRINT_STILL_RUNNING_MESSAGE_TICKS_THRESHOLD: u32 = 60; + +/// The main function that initializes environment variables, config, and sync process. +pub async fn main() -> anyhow::Result<()> { + initialize_environment()?; + + let config = initialize_configuration().await?; + + let db_pool = initialize_database_pool(&config.database_url).await?; + let mut conn = db_pool.acquire().await?; + + let mut interval = tokio::time::interval(Duration::from_secs(SYNC_INTERVAL_SECS)); + let mut ticks = 0; + + let access_tokens = + headless_lms_models::marketing_consents::fetch_all_marketing_mailing_list_access_tokens( + &mut conn, + ) + .await?; + + // Iterate through access tokens and ensure Mailchimp schema is set up + for token in &access_tokens { + if let Err(e) = ensure_mailchimp_schema( + &token.mailchimp_mailing_list_id, + &token.server_prefix, + &token.access_token, + ) + .await + { + error!( + "Failed to set up Mailchimp schema for list '{}': {:?}", + token.mailchimp_mailing_list_id, e + ); + return Err(e); + } + } + + info!("Starting mailchimp syncer."); + + loop { + interval.tick().await; + ticks += 1; + + if ticks >= PRINT_STILL_RUNNING_MESSAGE_TICKS_THRESHOLD { + ticks = 0; + info!("Still syncing."); + } + if let Err(e) = sync_contacts(&mut conn, &config).await { + error!("Error during synchronization: {:?}", e); + } + } +} + +/// Initializes environment variables, logging, and tracing setup. +fn initialize_environment() -> anyhow::Result<()> { + env::set_var("RUST_LOG", "info,actix_web=info,sqlx=warn"); + dotenv().ok(); + setup_tracing()?; + Ok(()) +} + +/// Structure to hold the configuration settings, such as the database URL. +struct SyncerConfig { + database_url: String, +} + +/// Initializes and returns configuration settings (database URL). +async fn initialize_configuration() -> anyhow::Result { + let database_url = env::var("DATABASE_URL") + .unwrap_or_else(|_| "postgres://localhost/headless_lms_dev".to_string()); + + Ok(SyncerConfig { database_url }) +} + +/// Initializes the PostgreSQL connection pool from the provided database URL. +async fn initialize_database_pool(database_url: &str) -> anyhow::Result { + PgPool::connect(database_url).await.map_err(|e| { + anyhow::anyhow!( + "Failed to connect to the database at {}: {:?}", + database_url, + e + ) + }) +} + +/// Ensures the Mailchimp schema is up to date, adding required fields and removing any extra ones. +async fn ensure_mailchimp_schema( + list_id: &str, + server_prefix: &str, + access_token: &str, +) -> anyhow::Result<()> { + let existing_fields = + fetch_current_mailchimp_fields(list_id, server_prefix, access_token).await?; + + // Remove extra fields not in REQUIRED_FIELDS + for field in existing_fields.iter() { + if !REQUIRED_FIELDS.contains(&field.field_name.as_str()) { + if let Err(e) = + remove_field_from_mailchimp(list_id, &field.field_id, server_prefix, access_token) + .await + { + warn!("Could not remove field '{}': {}", field.field_name, e); + } else { + info!("Removed field '{}'", field.field_name); + } + } + } + + // Add any required fields that are missing + for &required_field in REQUIRED_FIELDS.iter() { + if !existing_fields + .iter() + .any(|f| f.field_name == required_field) + { + // If the required field is missing, add it + if let Err(e) = + add_field_to_mailchimp(list_id, required_field, server_prefix, access_token).await + { + warn!("Failed to add required field '{}': {}", required_field, e); + } else { + info!("Successfully added required field '{}'", required_field); + } + } else { + info!( + "Field '{}' already exists, skipping addition.", + required_field + ); + } + } + + Ok(()) +} + +/// Fetches the current merge fields from the Mailchimp list schema. +async fn fetch_current_mailchimp_fields( + list_id: &str, + server_prefix: &str, + access_token: &str, +) -> Result, anyhow::Error> { + let url = format!( + "https://{}.api.mailchimp.com/3.0/lists/{}/merge-fields", + server_prefix, list_id + ); + + let response = REQWEST_CLIENT + .get(&url) + .header("Authorization", format!("apikey {}", access_token)) + .send() + .await?; + + if response.status().is_success() { + let json = response.json::().await?; + let fields: Vec = json["merge_fields"] + .as_array() + .unwrap_or(&vec![]) + .iter() + .filter_map(|field| { + let field_id = field["merge_id"].as_u64(); + let field_name = field["tag"].as_str(); + + if let (Some(field_id), Some(field_name)) = (field_id, field_name) { + Some(MailchimpField { + field_id: field_id.to_string(), + field_name: field_name.to_string(), + }) + } else { + None + } + }) + .collect(); + + Ok(fields) + } else { + let error_text = response.text().await?; + error!("Error fetching merge fields: {}", error_text); + Err(anyhow::anyhow!("Failed to fetch current Mailchimp fields.").into()) + } +} + +/// Adds a new merge field to the Mailchimp list. +async fn add_field_to_mailchimp( + list_id: &str, + field_name: &str, + server_prefix: &str, + access_token: &str, +) -> anyhow::Result<()> { + let url = format!( + "https://{}.api.mailchimp.com/3.0/lists/{}/merge-fields", + server_prefix, list_id + ); + + let body = json!({ + "tag": field_name, + "name": field_name, + "type": "text" + }); + + let response = REQWEST_CLIENT + .post(&url) + .header("Authorization", format!("apikey {}", access_token)) + .json(&body) + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "No additional error info.".to_string()); + Err(anyhow::anyhow!( + "Failed to add field to Mailchimp. Status: {}. Error: {}", + status, + error_text + )) + } +} + +/// Removes a merge field from the Mailchimp list by with a field ID. +async fn remove_field_from_mailchimp( + list_id: &str, + field_id: &str, + server_prefix: &str, + access_token: &str, +) -> anyhow::Result<()> { + let url = format!( + "https://{}.api.mailchimp.com/3.0/lists/{}/merge-fields/{}", + server_prefix, list_id, field_id + ); + + let response = REQWEST_CLIENT + .delete(&url) + .header("Authorization", format!("apikey {}", access_token)) + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + let status = response.status(); + let error_text = response + .text() + .await + .unwrap_or_else(|_| "No additional error info.".to_string()); + Err(anyhow::anyhow!( + "Failed to remove field from Mailchimp. Status: {}. Error: {}", + status, + error_text + )) + } +} + +/// Synchronizes the user contacts with Mailchimp. +async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyhow::Result<()> { + let access_tokens = + headless_lms_models::marketing_consents::fetch_all_marketing_mailing_list_access_tokens( + conn, + ) + .await?; + + // Store successfully synced user IDs + let mut successfully_synced_user_ids = Vec::new(); + + // Iterate through tokens and fetch and send user details to Mailchimp + for token in access_tokens { + // Fetch all users from Mailchimp and sync possible changes locally + let mailchimp_data = fetch_mailchimp_data_in_chunks( + &token.mailchimp_mailing_list_id, + &token.server_prefix, + &token.access_token, + 1000, + ) + .await?; + + println!( + "Processing Mailchimp data for list: {}", + token.mailchimp_mailing_list_id + ); + + process_mailchimp_data(conn, mailchimp_data).await?; + + // Fetch unsynced emails and update them in Mailchimp + let users_with_unsynced_emails = + headless_lms_models::marketing_consents::fetch_all_unsynced_updated_emails( + conn, + token.course_language_groups_id, + ) + .await?; + + println!( + "Prosessing unsynced emails for list: {}", + token.mailchimp_mailing_list_id + ); + + if !users_with_unsynced_emails.is_empty() { + let email_synced_user_ids = update_emails_in_mailchimp( + users_with_unsynced_emails, + &token.mailchimp_mailing_list_id, + &token.server_prefix, + &token.access_token, + ) + .await?; + + // Store the successfully synced user IDs from updating emails + successfully_synced_user_ids.extend(email_synced_user_ids); + } + + // Fetch unsynced user consents and update them in Mailchimp + let unsynced_users_details = + headless_lms_models::marketing_consents::fetch_all_unsynced_user_marketing_consents_by_course_language_groups_id( + conn, + token.course_language_groups_id, + ) + .await?; + + println!( + "Found {} unsynced user consent(s) for course language group: {}", + unsynced_users_details.len(), + token.course_language_groups_id + ); + + if !unsynced_users_details.is_empty() { + let consent_synced_user_ids = + send_users_to_mailchimp(conn, token, unsynced_users_details).await?; + + // Store the successfully synced user IDs from syncing user consents + successfully_synced_user_ids.extend(consent_synced_user_ids); + } + } + + // If there are any successfully synced users, update the database to mark them as synced + if !successfully_synced_user_ids.is_empty() { + headless_lms_models::marketing_consents::update_synced_to_mailchimp_at_to_all_synced_users( + conn, + &successfully_synced_user_ids, + ) + .await?; + println!( + "Successfully updated synced status for {} users.", + successfully_synced_user_ids.len() + ); + } + + Ok(()) +} + +/// Sends a batch of users to Mailchimp for synchronization. +pub async fn send_users_to_mailchimp( + conn: &mut PgConnection, + token: MarketingMailingListAccessToken, + users_details: Vec, +) -> anyhow::Result> { + let mut users_data_in_json = vec![]; + let mut user_ids = vec![]; + let mut successfully_synced_user_ids = Vec::new(); + let mut user_id_contact_id_pairs = Vec::new(); + + // Prepare each user's data for Mailchimp + for user in &users_details { + let user_details = json!({ + "email_address": user.email, + "status": "subscribed", + "merge_fields": { + "FNAME": user.first_name, + "LNAME": user.last_name, + "MARKETING": if user.consent { "allowed" } else { "disallowed" }, + "LOCALE": user.locale, + "GRADUATED": if user.completed_course.unwrap_or(false) { "passed" } else { "not passed" }, + "USERID": user.user_id, + "COURSEID": user.course_id, + "LANGGRPID": user.course_language_groups_id, + }, + }); + users_data_in_json.push(user_details); + user_ids.push(user.id); + } + + let batch_request = json!({ + "members": users_data_in_json, + "update_existing":true + }); + + let url = format!( + "https://{}.api.mailchimp.com/3.0/lists/{}", + token.server_prefix, token.mailchimp_mailing_list_id + ); + + // Check if batch is empty before sending + if users_data_in_json.is_empty() { + info!("No new users to sync."); + return Ok(vec![]); + } + + // Send the batch request to Mailchimp + let response = REQWEST_CLIENT + .post(&url) + .header("Content-Type", "application/json") + .header("Authorization", format!("apikey {}", token.access_token)) + .json(&batch_request) + .send() + .await?; + + if response.status().is_success() { + let response_data: serde_json::Value = response.json().await?; + + // Iterate over both new_members and updated_members to extract user_ids and contact_ids + for key in &["new_members", "updated_members"] { + if let Some(members) = response_data[key].as_array() { + for member in members { + if let Some(user_id) = member["merge_fields"]["USERID"].as_str() { + if let Ok(uuid) = uuid::Uuid::parse_str(user_id) { + successfully_synced_user_ids.push(uuid); + } + if let Some(contact_id) = member["contact_id"].as_str() { + user_id_contact_id_pairs + .push((user_id.to_string(), contact_id.to_string())); + } + } + } + } + } + // Update the users contact_id from Mailchimp to the database as user_mailchimp_id + headless_lms_models::marketing_consents::update_user_mailchimp_id_at_to_all_synced_users( + conn, + user_id_contact_id_pairs, + ) + .await?; + + // Return the list of successfully synced user_ids + Ok(successfully_synced_user_ids) + } else { + let status = response.status(); + let error_text = response.text().await?; + Err(anyhow::anyhow!( + "Error syncing users to Mailchimp. Status: {}. Error: {}", + status, + error_text + )) + } +} + +/// Updates the email addresses of multiple users in a Mailchimp mailing list. +async fn update_emails_in_mailchimp( + users: Vec, + list_id: &str, + server_prefix: &str, + access_token: &str, +) -> anyhow::Result> { + let mut successfully_synced_user_ids = Vec::new(); + let mut failed_user_ids = Vec::new(); + + for user in users { + if let Some(ref user_mailchimp_id) = user.user_mailchimp_id { + let url = format!( + "https://{}.api.mailchimp.com/3.0/lists/{}/members/{}", + server_prefix, list_id, user_mailchimp_id + ); + + // Prepare the body for the PUT request + let body = serde_json::json!({ + "email_address": &user.email, + "status": "subscribed", + }); + + // Update the email + let update_response = REQWEST_CLIENT + .put(&url) + .header("Authorization", format!("apikey {}", access_token)) + .json(&body) + .send() + .await?; + + if update_response.status().is_success() { + successfully_synced_user_ids.push(user.user_id); + } else { + failed_user_ids.push(user.user_id.clone()); + } + } else { + continue; + } + } + Ok(successfully_synced_user_ids) +} + +// + +/// Fetches data from Mailchimp in chunks. +async fn fetch_mailchimp_data_in_chunks( + list_id: &str, + server_prefix: &str, + access_token: &str, + chunk_size: usize, +) -> anyhow::Result> { + let mut all_data = Vec::new(); + let mut offset = 0; + + loop { + let url = format!( + "https://{}.api.mailchimp.com/3.0/lists/{}/members?offset={}&count={}&fields=members.merge_fields,members.status,members.last_changed?merge_fields[MARKETING]=disallowed", + server_prefix, list_id, offset, chunk_size + ); + + let response = reqwest::Client::new() + .get(&url) + .bearer_auth(access_token) + .send() + .await? + .json::() + .await?; + + let empty_vec = vec![]; + let members = response["members"].as_array().unwrap_or(&empty_vec); + if members.is_empty() { + break; + } + + for member in members { + // Process the member, but only if necessary fields are present and valid + if let (Some(status), Some(last_changed), Some(merge_fields)) = ( + member["status"].as_str(), + member["last_changed"].as_str(), + member["merge_fields"].as_object(), + ) { + // Ensure both USERID and LANGGRPID are present and valid + if let (Some(user_id), Some(language_groups_id)) = ( + merge_fields.get("USERID").and_then(|v| v.as_str()), + merge_fields.get("LANGGRPID").and_then(|v| v.as_str()), + ) { + // Avoid adding data if any field is missing or empty + if !user_id.is_empty() && !language_groups_id.is_empty() { + let is_subscribed = status == "subscribed"; // or another condition as needed + all_data.push(( + user_id.to_string(), + is_subscribed, + last_changed.to_string(), + language_groups_id.to_string(), + )); + } + } + } + } + + // Check the pagination info from the response + let total_items = response["total_items"].as_u64().unwrap_or(0) as usize; + if offset + chunk_size >= total_items { + break; + } + + offset += chunk_size; // Move the offset forward + } + + Ok(all_data) +} + +const BATCH_SIZE: usize = 1000; // Adjust based on DB capabilities + +async fn process_mailchimp_data( + conn: &mut PgConnection, + mailchimp_data: Vec<(String, bool, String, String)>, +) -> anyhow::Result<()> { + // Log the total size of the Mailchimp data + let total_records = mailchimp_data.len(); + + for chunk in mailchimp_data.chunks(BATCH_SIZE) { + if chunk.is_empty() { + continue; + } + + // Attempt to process the current chunk + if let Err(e) = + headless_lms_models::marketing_consents::update_bulk_user_consent(conn, chunk.to_vec()) + .await + { + // Log the error with chunk-specific context + eprintln!( + "Error while processing chunk {}/{}: ", + (total_records + BATCH_SIZE - 1) / BATCH_SIZE, + e + ); + } + } + + Ok(()) +} From 84949f91c122907ff0f4277e741549f7f87e9ba5 Mon Sep 17 00:00:00 2001 From: Maija Y Date: Mon, 25 Nov 2024 23:25:46 +0200 Subject: [PATCH 04/16] Updated comments --- services/headless-lms/models/src/marketing_consents.rs | 8 +++----- .../server/src/programs/mailchimp_syncer.rs | 10 +++------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/services/headless-lms/models/src/marketing_consents.rs b/services/headless-lms/models/src/marketing_consents.rs index 0c1c1494af8b..518c4addef19 100644 --- a/services/headless-lms/models/src/marketing_consents.rs +++ b/services/headless-lms/models/src/marketing_consents.rs @@ -108,7 +108,7 @@ pub async fn fetch_user_marketing_consent( Ok(result) } -// Fetches all user marketing consents with detailed user information for a specific course language group, if they haven't been synced to Mailchimp or if there have been updates since the last sync. +/// Fetches all user marketing consents with detailed user information for a specific course language group, if they haven't been synced to Mailchimp or if there have been updates since the last sync. pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_groups_id( conn: &mut PgConnection, course_language_groups_id: Uuid, @@ -191,7 +191,7 @@ pub async fn fetch_all_unsynced_updated_emails( Ok(result) } -// Used to update the synced_to_mailchimp_at to a list of users when they are successfully synced to mailchimp +/// Used to update the synced_to_mailchimp_at to a list of users when they are successfully synced to mailchimp pub async fn update_synced_to_mailchimp_at_to_all_synced_users( conn: &mut PgConnection, ids: &[Uuid], @@ -211,7 +211,7 @@ WHERE user_id IN ( Ok(()) } -// Used to add the user_mailchimp_ids to a list of new users when they are successfully synced to mailchimp +/// Used to add the user_mailchimp_ids to a list of new users when they are successfully synced to mailchimp pub async fn update_user_mailchimp_id_at_to_all_synced_users( pool: &mut PgConnection, user_contact_pairs: Vec<(String, String)>, @@ -225,7 +225,6 @@ pub async fn update_user_mailchimp_id_at_to_all_synced_users( .map(|(_, mailchimp_id)| mailchimp_id.clone()) .collect(); - // Updated query let query = r#" UPDATE user_marketing_consents SET user_mailchimp_id = updated_data.user_mailchimp_id @@ -235,7 +234,6 @@ pub async fn update_user_mailchimp_id_at_to_all_synced_users( WHERE user_marketing_consents.user_id = updated_data.user_id "#; - // Execute the query with the `user_ids` and `user_mailchimp_ids` sqlx::query(query) .bind(&user_ids) .bind(&user_mailchimp_ids) diff --git a/services/headless-lms/server/src/programs/mailchimp_syncer.rs b/services/headless-lms/server/src/programs/mailchimp_syncer.rs index fae6fa6dca9b..03a81b350282 100644 --- a/services/headless-lms/server/src/programs/mailchimp_syncer.rs +++ b/services/headless-lms/server/src/programs/mailchimp_syncer.rs @@ -141,7 +141,6 @@ async fn ensure_mailchimp_schema( .iter() .any(|f| f.field_name == required_field) { - // If the required field is missing, add it if let Err(e) = add_field_to_mailchimp(list_id, required_field, server_prefix, access_token).await { @@ -289,7 +288,6 @@ async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyho ) .await?; - // Store successfully synced user IDs let mut successfully_synced_user_ids = Vec::new(); // Iterate through tokens and fetch and send user details to Mailchimp @@ -513,8 +511,6 @@ async fn update_emails_in_mailchimp( Ok(successfully_synced_user_ids) } -// - /// Fetches data from Mailchimp in chunks. async fn fetch_mailchimp_data_in_chunks( list_id: &str, @@ -559,7 +555,7 @@ async fn fetch_mailchimp_data_in_chunks( ) { // Avoid adding data if any field is missing or empty if !user_id.is_empty() && !language_groups_id.is_empty() { - let is_subscribed = status == "subscribed"; // or another condition as needed + let is_subscribed = status == "subscribed"; all_data.push(( user_id.to_string(), is_subscribed, @@ -577,13 +573,13 @@ async fn fetch_mailchimp_data_in_chunks( break; } - offset += chunk_size; // Move the offset forward + offset += chunk_size; } Ok(all_data) } -const BATCH_SIZE: usize = 1000; // Adjust based on DB capabilities +const BATCH_SIZE: usize = 1000; async fn process_mailchimp_data( conn: &mut PgConnection, From 34c6650bcd77da224d9d5286fd48c2e29d91a50f Mon Sep 17 00:00:00 2001 From: Maija Y Date: Mon, 25 Nov 2024 23:38:16 +0200 Subject: [PATCH 05/16] Updated comments --- services/headless-lms/server/src/programs/mailchimp_syncer.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/services/headless-lms/server/src/programs/mailchimp_syncer.rs b/services/headless-lms/server/src/programs/mailchimp_syncer.rs index 03a81b350282..30330b5e8510 100644 --- a/services/headless-lms/server/src/programs/mailchimp_syncer.rs +++ b/services/headless-lms/server/src/programs/mailchimp_syncer.rs @@ -201,7 +201,7 @@ async fn fetch_current_mailchimp_fields( } else { let error_text = response.text().await?; error!("Error fetching merge fields: {}", error_text); - Err(anyhow::anyhow!("Failed to fetch current Mailchimp fields.").into()) + Err(anyhow::anyhow!("Failed to fetch current Mailchimp fields.")) } } @@ -502,7 +502,7 @@ async fn update_emails_in_mailchimp( if update_response.status().is_success() { successfully_synced_user_ids.push(user.user_id); } else { - failed_user_ids.push(user.user_id.clone()); + failed_user_ids.push(user.user_id); } } else { continue; From 5d5507162d2fa214b171a729f7a166049aa421b0 Mon Sep 17 00:00:00 2001 From: Maija Y Date: Wed, 27 Nov 2024 17:16:42 +0200 Subject: [PATCH 06/16] Changes to mergefields and renaming --- .../forms/SelectMarketingConsentForm.tsx | 16 +- .../components/modals/CourseSettingsModal.tsx | 2 +- ...0241023104801_add-marketing-consent.up.sql | 10 +- ...be217790808cb8751ae50d121321f0be9856.json} | 6 +- ...50aba48b2bb79374b55e9c0bcf42c6141b80f.json | 12 ++ ...c9e73cacecfb2b845f0d35895295e47e4d9a.json} | 4 +- ...5f1575e6f8557a1612d9560cb7deed06269d.json} | 6 +- ...3460a7f3089988d959d05fba690f17d9d456.json} | 6 +- ...36f8709a86d2e33ec9f57b4a0e3a111c05ea.json} | 6 +- ...743103c99859dc65a648774fdd8c6bb7d89b4.json | 12 ++ .../models/src/marketing_consents.rs | 169 +++++++++--------- .../controllers/course_material/courses.rs | 5 +- .../server/src/programs/mailchimp_syncer.rs | 148 ++++++++++----- .../server/tests/study_registry_test.rs | 1 + 14 files changed, 246 insertions(+), 157 deletions(-) rename services/headless-lms/models/.sqlx/{query-7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc.json => query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json} (73%) create mode 100644 services/headless-lms/models/.sqlx/query-1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f.json rename services/headless-lms/models/.sqlx/{query-11a20e21e4996d77f0cef1949b9efebe3aa4c2616f15ae2ef95e6940849a6fed.json => query-398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a.json} (53%) rename services/headless-lms/models/.sqlx/{query-b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69.json => query-89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d.json} (66%) rename services/headless-lms/models/.sqlx/{query-0a19c9653e258dd34bce3053b2c7cecf9c5427530798ed0225f4a64d6f3880ba.json => query-8b8d471ed21eb709b5332c5bb4ab3460a7f3089988d959d05fba690f17d9d456.json} (77%) rename services/headless-lms/models/.sqlx/{query-074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22.json => query-a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea.json} (65%) create mode 100644 services/headless-lms/models/.sqlx/query-d2b6cb427cd241cddbfb9f4fabd743103c99859dc65a648774fdd8c6bb7d89b4.json diff --git a/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx b/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx index 98ed81818263..2dd9ebe0d9a7 100644 --- a/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx +++ b/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx @@ -1,8 +1,10 @@ +import { useQuery } from "@tanstack/react-query" import React, { useEffect, useState } from "react" import { fetchUserMarketingConsent, updateMarketingConsent } from "@/services/backend" import CheckBox from "@/shared-module/common/components/InputFields/CheckBox" import useToastMutation from "@/shared-module/common/hooks/useToastMutation" +import { assertNotNullOrUndefined } from "@/shared-module/common/utils/nullability" interface selectMarketingConstentProps { courseId: string @@ -15,13 +17,17 @@ const SelectMarketingConstentForm: React.FC = ({ }) => { const [marketingConsent, setMarketingConsent] = useState(false) + const fetchInitialMarketingConsent = useQuery({ + queryKey: ["marketing-consent", courseId], + queryFn: () => fetchUserMarketingConsent(assertNotNullOrUndefined(courseId)), + enabled: courseId !== undefined, + }) + useEffect(() => { - const fetchConsentStatus = async () => { - const response = await fetchUserMarketingConsent(courseId) - setMarketingConsent(response.consent) + if (fetchInitialMarketingConsent.isSuccess) { + setMarketingConsent(fetchInitialMarketingConsent.data.consent) } - fetchConsentStatus() - }, [courseId, courseLanguageGroupsId]) + }, [fetchInitialMarketingConsent.data, fetchInitialMarketingConsent.isSuccess]) const handleMarketingConsentChangeMutation = useToastMutation( async () => { diff --git a/services/course-material/src/components/modals/CourseSettingsModal.tsx b/services/course-material/src/components/modals/CourseSettingsModal.tsx index 1eae13e1c5d7..37580c57d6b5 100644 --- a/services/course-material/src/components/modals/CourseSettingsModal.tsx +++ b/services/course-material/src/components/modals/CourseSettingsModal.tsx @@ -77,7 +77,7 @@ const CourseSettingsModal: React.FC fetchCourseById(selectedLangCourseId as NonNullable), enabled: selectedLangCourseId !== null && open && pageState.state === "ready", }) diff --git a/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql index b11403711143..ccfa3165009c 100644 --- a/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql +++ b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql @@ -6,7 +6,7 @@ COMMENT ON COLUMN courses.ask_marketing_consent IS 'Whether this course asks the CREATE TABLE user_marketing_consents ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, course_id UUID NOT NULL REFERENCES courses(id), - course_language_groups_id UUID NOT NULL REFERENCES course_language_groups(id), + course_language_group_id UUID NOT NULL REFERENCES course_language_groups(id), user_id UUID NOT NULL REFERENCES users(id), user_mailchimp_id VARCHAR(255), consent BOOLEAN NOT NULL, @@ -14,7 +14,7 @@ CREATE TABLE user_marketing_consents ( updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), deleted_at TIMESTAMP WITH TIME ZONE, synced_to_mailchimp_at TIMESTAMP WITH TIME ZONE, - CONSTRAINT course_language_group_specific_marketing_user_uniqueness UNIQUE NULLS NOT DISTINCT(user_id, course_language_groups_id) + CONSTRAINT course_language_group_specific_marketing_user_uniqueness UNIQUE NULLS NOT DISTINCT(user_id, course_language_group_id) ); CREATE TRIGGER set_timestamp BEFORE @@ -23,7 +23,7 @@ UPDATE ON user_marketing_consents FOR EACH ROW EXECUTE PROCEDURE trigger_set_tim COMMENT ON TABLE user_marketing_consents IS 'This table is used to keep a record if a user has given a marketing consent to a course'; COMMENT ON COLUMN user_marketing_consents.id IS 'A unique, stable identifier for the record.'; COMMENT ON COLUMN user_marketing_consents.course_id IS 'Course that the user has access to.'; -COMMENT ON COLUMN user_marketing_consents.course_language_groups_id IS 'The course language group id that the mailing list is related to'; +COMMENT ON COLUMN user_marketing_consents.course_language_group_id IS 'The course language group id that the mailing list is related to'; COMMENT ON COLUMN user_marketing_consents.user_id IS 'User who has the access to the course.'; COMMENT ON COLUMN user_marketing_consents.user_mailchimp_id IS 'Unique id for the user, provided by Mailchimp'; COMMENT ON COLUMN user_marketing_consents.consent IS 'Wheter the user has given a marketing consent for a specific course.'; @@ -36,7 +36,7 @@ COMMENT ON COLUMN user_marketing_consents.synced_to_mailchimp_at IS 'Timestamp w CREATE TABLE marketing_mailing_list_access_tokens ( id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, course_id UUID NOT NULL REFERENCES courses(id), - course_language_groups_id UUID NOT NULL REFERENCES course_language_groups(id), + course_language_group_id UUID NOT NULL REFERENCES course_language_groups(id), server_prefix VARCHAR(255) NOT NULL, access_token VARCHAR(255) NOT NULL, mailchimp_mailing_list_id VARCHAR(255) NOT NULL, @@ -51,7 +51,7 @@ UPDATE ON marketing_mailing_list_access_tokens FOR EACH ROW EXECUTE PROCEDURE tr COMMENT ON TABLE marketing_mailing_list_access_tokens IS 'This table is used to keep a record of marketing mailing lists access tokens for each course language group'; COMMENT ON COLUMN marketing_mailing_list_access_tokens.id IS 'A unique, stable identifier for the record.'; COMMENT ON COLUMN marketing_mailing_list_access_tokens.course_id IS 'The course id that the the mailing list is related to'; -COMMENT ON COLUMN marketing_mailing_list_access_tokens.course_language_groups_id IS 'The course language group id that the mailing list is related to'; +COMMENT ON COLUMN marketing_mailing_list_access_tokens.course_language_group_id IS 'The course language group id that the mailing list is related to'; COMMENT ON COLUMN marketing_mailing_list_access_tokens.server_prefix IS 'This value is used to configure API requests to the correct Mailchimp server.'; COMMENT ON COLUMN marketing_mailing_list_access_tokens.access_token IS 'Token used for access authentication.'; COMMENT ON COLUMN marketing_mailing_list_access_tokens.mailchimp_mailing_list_id IS 'Id of the mailing list used for marketing in Mailchimp'; diff --git a/services/headless-lms/models/.sqlx/query-7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc.json b/services/headless-lms/models/.sqlx/query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json similarity index 73% rename from services/headless-lms/models/.sqlx/query-7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc.json rename to services/headless-lms/models/.sqlx/query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json index 67b8333e2e76..72099e082491 100644 --- a/services/headless-lms/models/.sqlx/query-7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc.json +++ b/services/headless-lms/models/.sqlx/query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT id,\n course_id,\n course_language_groups_id,\n user_id,\n user_mailchimp_id,\n consent,\n created_at,\n updated_at,\n deleted_at,\n synced_to_mailchimp_at\n FROM user_marketing_consents\n WHERE user_id = $1 AND course_id = $2\n ", + "query": "\n SELECT *\n FROM user_marketing_consents\n WHERE user_id = $1 AND course_id = $2\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "course_language_groups_id", + "name": "course_language_group_id", "type_info": "Uuid" }, { @@ -59,5 +59,5 @@ }, "nullable": [false, false, false, false, true, false, false, false, true, true] }, - "hash": "7cb0af035cdafd0dadcd587670bde30eb4e094a84a06a884c432bfc841c7f8bc" + "hash": "0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856" } diff --git a/services/headless-lms/models/.sqlx/query-1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f.json b/services/headless-lms/models/.sqlx/query-1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f.json new file mode 100644 index 000000000000..76f24a2a71ef --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_marketing_consents\n SET consent = updated_data.consent,\n synced_to_mailchimp_at = updated_data.last_updated\n FROM (\n SELECT UNNEST($1::Uuid[]) AS user_id,\n UNNEST($2::bool[]) AS consent,\n UNNEST($3::timestamptz[]) AS last_updated,\n UNNEST($4::Uuid[]) AS course_language_group_id\n ) AS updated_data\n WHERE user_marketing_consents.user_id = updated_data.user_id\n AND user_marketing_consents.synced_to_mailchimp_at < updated_data.last_updated\n AND user_marketing_consents.course_language_group_id = updated_data.course_language_group_id\n ", + "describe": { + "columns": [], + "parameters": { + "Left": ["UuidArray", "BoolArray", "TimestamptzArray", "UuidArray"] + }, + "nullable": [] + }, + "hash": "1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f" +} diff --git a/services/headless-lms/models/.sqlx/query-11a20e21e4996d77f0cef1949b9efebe3aa4c2616f15ae2ef95e6940849a6fed.json b/services/headless-lms/models/.sqlx/query-398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a.json similarity index 53% rename from services/headless-lms/models/.sqlx/query-11a20e21e4996d77f0cef1949b9efebe3aa4c2616f15ae2ef95e6940849a6fed.json rename to services/headless-lms/models/.sqlx/query-398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a.json index e2ab13c487ae..e8e03908dba1 100644 --- a/services/headless-lms/models/.sqlx/query-11a20e21e4996d77f0cef1949b9efebe3aa4c2616f15ae2ef95e6940849a6fed.json +++ b/services/headless-lms/models/.sqlx/query-398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n INSERT INTO user_marketing_consents (user_id, course_id, course_language_groups_id, consent)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id, course_language_groups_id)\n DO UPDATE\n SET consent = $4\n RETURNING id\n ", + "query": "\n INSERT INTO user_marketing_consents (user_id, course_id, course_language_group_id, consent)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id, course_language_group_id)\n DO UPDATE\n SET consent = $4\n RETURNING id\n ", "describe": { "columns": [ { @@ -14,5 +14,5 @@ }, "nullable": [false] }, - "hash": "11a20e21e4996d77f0cef1949b9efebe3aa4c2616f15ae2ef95e6940849a6fed" + "hash": "398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a" } diff --git a/services/headless-lms/models/.sqlx/query-b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69.json b/services/headless-lms/models/.sqlx/query-89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d.json similarity index 66% rename from services/headless-lms/models/.sqlx/query-b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69.json rename to services/headless-lms/models/.sqlx/query-89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d.json index f236a15d33fe..13feeaaedab3 100644 --- a/services/headless-lms/models/.sqlx/query-b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69.json +++ b/services/headless-lms/models/.sqlx/query-89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_groups_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_groups_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < u.updated_at)\n ", + "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_group_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_group_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < u.updated_at)\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "course_language_groups_id", + "name": "course_language_group_id", "type_info": "Uuid" }, { @@ -106,5 +106,5 @@ null ] }, - "hash": "b57d40a7daf9d2ae609f953f48f756c796ee19b751ffe4740d06600cdae0fd69" + "hash": "89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d" } diff --git a/services/headless-lms/models/.sqlx/query-0a19c9653e258dd34bce3053b2c7cecf9c5427530798ed0225f4a64d6f3880ba.json b/services/headless-lms/models/.sqlx/query-8b8d471ed21eb709b5332c5bb4ab3460a7f3089988d959d05fba690f17d9d456.json similarity index 77% rename from services/headless-lms/models/.sqlx/query-0a19c9653e258dd34bce3053b2c7cecf9c5427530798ed0225f4a64d6f3880ba.json rename to services/headless-lms/models/.sqlx/query-8b8d471ed21eb709b5332c5bb4ab3460a7f3089988d959d05fba690f17d9d456.json index 8459ff2f0c41..813e67388e30 100644 --- a/services/headless-lms/models/.sqlx/query-0a19c9653e258dd34bce3053b2c7cecf9c5427530798ed0225f4a64d6f3880ba.json +++ b/services/headless-lms/models/.sqlx/query-8b8d471ed21eb709b5332c5bb4ab3460a7f3089988d959d05fba690f17d9d456.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id,\n course_id,\n course_language_groups_id,\n server_prefix,\n access_token,\n mailchimp_mailing_list_id,\n created_at,\n updated_at,\n deleted_at\n FROM marketing_mailing_list_access_tokens\n ", + "query": "\n SELECT\n id,\n course_id,\n course_language_group_id,\n server_prefix,\n access_token,\n mailchimp_mailing_list_id,\n created_at,\n updated_at,\n deleted_at\n FROM marketing_mailing_list_access_tokens\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "course_language_groups_id", + "name": "course_language_group_id", "type_info": "Uuid" }, { @@ -54,5 +54,5 @@ }, "nullable": [false, false, false, false, false, false, false, false, true] }, - "hash": "0a19c9653e258dd34bce3053b2c7cecf9c5427530798ed0225f4a64d6f3880ba" + "hash": "8b8d471ed21eb709b5332c5bb4ab3460a7f3089988d959d05fba690f17d9d456" } diff --git a/services/headless-lms/models/.sqlx/query-074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22.json b/services/headless-lms/models/.sqlx/query-a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea.json similarity index 65% rename from services/headless-lms/models/.sqlx/query-074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22.json rename to services/headless-lms/models/.sqlx/query-a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea.json index f9872615952b..08fbd68db59c 100644 --- a/services/headless-lms/models/.sqlx/query-074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22.json +++ b/services/headless-lms/models/.sqlx/query-a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_groups_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_groups_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL\n OR umc.synced_to_mailchimp_at < umc.updated_at)\n ", + "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_group_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_group_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL\n OR umc.synced_to_mailchimp_at < umc.updated_at)\n ", "describe": { "columns": [ { @@ -15,7 +15,7 @@ }, { "ordinal": 2, - "name": "course_language_groups_id", + "name": "course_language_group_id", "type_info": "Uuid" }, { @@ -106,5 +106,5 @@ null ] }, - "hash": "074ad35b8fa2588b0c36be3f18ec160f8bd276628e02678031d28e6545dfbb22" + "hash": "a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea" } diff --git a/services/headless-lms/models/.sqlx/query-d2b6cb427cd241cddbfb9f4fabd743103c99859dc65a648774fdd8c6bb7d89b4.json b/services/headless-lms/models/.sqlx/query-d2b6cb427cd241cddbfb9f4fabd743103c99859dc65a648774fdd8c6bb7d89b4.json new file mode 100644 index 000000000000..98b0cb1fa227 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-d2b6cb427cd241cddbfb9f4fabd743103c99859dc65a648774fdd8c6bb7d89b4.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\nUPDATE user_marketing_consents\nSET user_mailchimp_id = updated_data.user_mailchimp_id\nFROM (\n SELECT UNNEST($1::uuid[]) AS user_id, UNNEST($2::text[]) AS user_mailchimp_id\n) AS updated_data\nWHERE user_marketing_consents.user_id = updated_data.user_id\n", + "describe": { + "columns": [], + "parameters": { + "Left": ["UuidArray", "TextArray"] + }, + "nullable": [] + }, + "hash": "d2b6cb427cd241cddbfb9f4fabd743103c99859dc65a648774fdd8c6bb7d89b4" +} diff --git a/services/headless-lms/models/src/marketing_consents.rs b/services/headless-lms/models/src/marketing_consents.rs index 518c4addef19..d9149b31d19d 100644 --- a/services/headless-lms/models/src/marketing_consents.rs +++ b/services/headless-lms/models/src/marketing_consents.rs @@ -1,3 +1,5 @@ +use itertools::multiunzip; + use crate::prelude::*; #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] @@ -5,7 +7,7 @@ use crate::prelude::*; pub struct UserMarketingConsent { pub id: Uuid, pub course_id: Uuid, - pub course_language_groups_id: Uuid, + pub course_language_group_id: Uuid, pub user_id: Uuid, pub user_mailchimp_id: Option, pub consent: bool, @@ -20,7 +22,7 @@ pub struct UserMarketingConsent { pub struct UserMarketingConsentWithDetails { pub id: Uuid, pub course_id: Uuid, - pub course_language_groups_id: Uuid, + pub course_language_group_id: Uuid, pub user_id: Uuid, pub user_mailchimp_id: Option, pub consent: bool, @@ -42,7 +44,7 @@ pub struct UserMarketingConsentWithDetails { pub struct MarketingMailingListAccessToken { pub id: Uuid, pub course_id: Uuid, - pub course_language_groups_id: Uuid, + pub course_language_group_id: Uuid, pub server_prefix: String, pub access_token: String, pub mailchimp_mailing_list_id: String, @@ -54,22 +56,22 @@ pub struct MarketingMailingListAccessToken { pub async fn upsert_marketing_consent( conn: &mut PgConnection, course_id: Uuid, - course_language_groups_id: Uuid, + course_language_group_id: Uuid, user_id: &Uuid, consent: bool, ) -> sqlx::Result { let result = sqlx::query!( r#" - INSERT INTO user_marketing_consents (user_id, course_id, course_language_groups_id, consent) + INSERT INTO user_marketing_consents (user_id, course_id, course_language_group_id, consent) VALUES ($1, $2, $3, $4) - ON CONFLICT (user_id, course_language_groups_id) + ON CONFLICT (user_id, course_language_group_id) DO UPDATE SET consent = $4 RETURNING id "#, user_id, course_id, - course_language_groups_id, + course_language_group_id, consent ) .fetch_one(conn) @@ -86,16 +88,7 @@ pub async fn fetch_user_marketing_consent( let result = sqlx::query_as!( UserMarketingConsent, " - SELECT id, - course_id, - course_language_groups_id, - user_id, - user_mailchimp_id, - consent, - created_at, - updated_at, - deleted_at, - synced_to_mailchimp_at + SELECT * FROM user_marketing_consents WHERE user_id = $1 AND course_id = $2 ", @@ -109,9 +102,9 @@ pub async fn fetch_user_marketing_consent( } /// Fetches all user marketing consents with detailed user information for a specific course language group, if they haven't been synced to Mailchimp or if there have been updates since the last sync. -pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_groups_id( +pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_group_id( conn: &mut PgConnection, - course_language_groups_id: Uuid, + course_language_group_id: Uuid, ) -> sqlx::Result> { let result = sqlx::query_as!( UserMarketingConsentWithDetails, @@ -119,7 +112,7 @@ pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_group SELECT umc.id, umc.course_id, - umc.course_language_groups_id, + umc.course_language_group_id, umc.user_id, umc.user_mailchimp_id, umc.consent, @@ -138,11 +131,11 @@ pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_group JOIN courses AS c ON c.id = umc.course_id LEFT JOIN course_module_completions AS cmc ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id - WHERE umc.course_language_groups_id = $1 + WHERE umc.course_language_group_id = $1 AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < umc.updated_at) ", - course_language_groups_id + course_language_group_id ) .fetch_all(conn) .await?; @@ -153,7 +146,7 @@ pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_group /// Fetches all user details that have been updated after their marketing consent was last synced to Mailchimp pub async fn fetch_all_unsynced_updated_emails( conn: &mut PgConnection, - course_language_groups_id: Uuid, + course_language_group_id: Uuid, ) -> sqlx::Result> { let result = sqlx::query_as!( UserMarketingConsentWithDetails, @@ -161,7 +154,7 @@ pub async fn fetch_all_unsynced_updated_emails( SELECT umc.id, umc.course_id, - umc.course_language_groups_id, + umc.course_language_group_id, umc.user_id, umc.user_mailchimp_id, umc.consent, @@ -180,10 +173,10 @@ pub async fn fetch_all_unsynced_updated_emails( JOIN courses AS c ON c.id = umc.course_id LEFT JOIN course_module_completions AS cmc ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id - WHERE umc.course_language_groups_id = $1 + WHERE umc.course_language_group_id = $1 AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < u.updated_at) ", - course_language_groups_id + course_language_group_id ) .fetch_all(conn) .await?; @@ -216,85 +209,85 @@ pub async fn update_user_mailchimp_id_at_to_all_synced_users( pool: &mut PgConnection, user_contact_pairs: Vec<(String, String)>, ) -> ModelResult<()> { - let user_ids: Vec = user_contact_pairs - .iter() - .map(|(user_id, _)| user_id.clone()) - .collect(); - let user_mailchimp_ids: Vec = user_contact_pairs - .iter() - .map(|(_, mailchimp_id)| mailchimp_id.clone()) - .collect(); + let (user_ids_raw, user_mailchimp_ids): (Vec<_>, Vec<_>) = + user_contact_pairs.into_iter().unzip(); - let query = r#" - UPDATE user_marketing_consents - SET user_mailchimp_id = updated_data.user_mailchimp_id - FROM ( - SELECT UNNEST($1::Uuid[]) AS user_id, UNNEST($2::text[]) AS user_mailchimp_id - ) AS updated_data - WHERE user_marketing_consents.user_id = updated_data.user_id - "#; - - sqlx::query(query) - .bind(&user_ids) - .bind(&user_mailchimp_ids) - .execute(pool) - .await?; + // Parse user_ids into Uuid + let user_ids: Vec = user_ids_raw + .into_iter() + .filter_map(|user_id| Uuid::parse_str(&user_id).ok()) + .collect(); + sqlx::query!( + " +UPDATE user_marketing_consents +SET user_mailchimp_id = updated_data.user_mailchimp_id +FROM ( + SELECT UNNEST($1::uuid[]) AS user_id, UNNEST($2::text[]) AS user_mailchimp_id +) AS updated_data +WHERE user_marketing_consents.user_id = updated_data.user_id +", + &user_ids, + &user_mailchimp_ids + ) + .execute(pool) + .await?; Ok(()) } /// Updates user consents in bulk using Mailchimp data. -pub async fn update_bulk_user_consent( +pub async fn update_unsubscribed_users_from_mailchimp_in_bulk( conn: &mut PgConnection, mailchimp_data: Vec<(String, bool, String, String)>, ) -> anyhow::Result<()> { - let user_ids: Vec = mailchimp_data - .iter() - .filter_map(|(user_id, _, _, _)| Uuid::parse_str(user_id).ok()) - .collect(); + let (user_ids_raw, consents, timestamps_raw, course_language_group_ids_raw): ( + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + ) = multiunzip(mailchimp_data); - let consents: Vec = mailchimp_data - .iter() - .map(|(_, consent, _, _)| *consent) + let user_ids: Vec = user_ids_raw + .into_iter() + .filter_map(|user_id| Uuid::parse_str(&user_id).ok()) .collect(); - let timestamps: Vec> = mailchimp_data - .iter() - .filter_map(|(_, _, ts, _)| { - DateTime::parse_from_rfc3339(ts) + let timestamps: Vec> = timestamps_raw + .into_iter() + .filter_map(|ts| { + DateTime::parse_from_rfc3339(&ts) .ok() - .map(|dt| dt.with_timezone(&Utc)) // Convert to Utc + .map(|dt| dt.with_timezone(&Utc)) }) .collect(); - let course_language_groups_ids: Vec = mailchimp_data - .iter() - .filter_map(|(_, _, _, lang_id)| Uuid::parse_str(lang_id).ok()) + let course_language_group_ids: Vec = course_language_group_ids_raw + .into_iter() + .filter_map(|lang_id| Uuid::parse_str(&lang_id).ok()) .collect(); - let query = r#" - UPDATE user_marketing_consents - SET consent = updated_data.consent, - synced_to_mailchimp_at = updated_data.last_updated - FROM ( - SELECT UNNEST($1::Uuid[]) AS user_id, - UNNEST($2::bool[]) AS consent, - UNNEST($3::timestamptz[]) AS last_updated, - UNNEST($4::Uuid[]) AS course_language_groups_id - ) AS updated_data - WHERE user_marketing_consents.user_id = updated_data.user_id - AND user_marketing_consents.synced_to_mailchimp_at < updated_data.last_updated - AND user_marketing_consents.course_language_groups_id = updated_data.course_language_groups_id - "#; - - // Execute the query - sqlx::query(query) - .bind(&user_ids) - .bind(&consents) - .bind(×tamps) - .bind(&course_language_groups_ids) - .execute(conn) - .await?; + sqlx::query!( + " + UPDATE user_marketing_consents + SET consent = updated_data.consent, + synced_to_mailchimp_at = updated_data.last_updated + FROM ( + SELECT UNNEST($1::Uuid[]) AS user_id, + UNNEST($2::bool[]) AS consent, + UNNEST($3::timestamptz[]) AS last_updated, + UNNEST($4::Uuid[]) AS course_language_group_id + ) AS updated_data + WHERE user_marketing_consents.user_id = updated_data.user_id + AND user_marketing_consents.synced_to_mailchimp_at < updated_data.last_updated + AND user_marketing_consents.course_language_group_id = updated_data.course_language_group_id + ", + &user_ids, + &consents, + ×tamps, + &course_language_group_ids + ) + .execute(conn) + .await?; Ok(()) } @@ -307,7 +300,7 @@ pub async fn fetch_all_marketing_mailing_list_access_tokens( SELECT id, course_id, - course_language_groups_id, + course_language_group_id, server_prefix, access_token, mailchimp_mailing_list_id, diff --git a/services/headless-lms/server/src/controllers/course_material/courses.rs b/services/headless-lms/server/src/controllers/course_material/courses.rs index 3d2243e49c14..0f02dc29f34c 100644 --- a/services/headless-lms/server/src/controllers/course_material/courses.rs +++ b/services/headless-lms/server/src/controllers/course_material/courses.rs @@ -908,10 +908,10 @@ pub struct UserMarketingConsentPayload { pub course_language_groups_id: Uuid, pub consent: bool, } + /** POST `/api/v0/course-material/courses/:course_id/user-marketing-consent` - Adds or updates user's marketing consent for a specific course. */ - #[instrument(skip(pool, payload))] async fn update_marketing_consent( payload: web::Json, @@ -937,9 +937,8 @@ async fn update_marketing_consent( } /** -GET `/api/v0/course-material/courses/:course_id/-fetch-user-marketing-consent` +GET `/api/v0/course-material/courses/:course_id/fetch-user-marketing-consent` */ - #[instrument(skip(pool))] async fn fetch_user_marketing_consent( pool: web::Data, diff --git a/services/headless-lms/server/src/programs/mailchimp_syncer.rs b/services/headless-lms/server/src/programs/mailchimp_syncer.rs index 30330b5e8510..7e045db9c6f8 100644 --- a/services/headless-lms/server/src/programs/mailchimp_syncer.rs +++ b/services/headless-lms/server/src/programs/mailchimp_syncer.rs @@ -15,17 +15,59 @@ struct MailchimpField { field_name: String, } -const REQUIRED_FIELDS: &[&str] = &[ - "FNAME", - "LNAME", - "MARKETING", - "LOCALE", - "GRADUATED", - "COURSEID", - "LANGGRPID", - "USERID", +#[derive(Debug)] +struct FieldSchema { + tag: &'static str, + name: &'static str, + default_value: &'static str, +} + +const REQUIRED_FIELDS: &[FieldSchema] = &[ + FieldSchema { + tag: "FNAME", + name: "First Name", + default_value: "", + }, + FieldSchema { + tag: "LNAME", + name: "Last Name", + default_value: "", + }, + FieldSchema { + tag: "MARKETING", + name: "Accepts Marketing", + default_value: "disallowed", + }, + FieldSchema { + tag: "LOCALE", + name: "Locale", + default_value: "en", + }, + FieldSchema { + tag: "GRADUATED", + name: "Graduated", + default_value: "", + }, + FieldSchema { + tag: "COURSEID", + name: "Course ID", + default_value: "", + }, + FieldSchema { + tag: "USERID", + name: "User ID", + default_value: "", + }, + FieldSchema { + tag: "LANGGRPID", + name: "Course language Group ID", + default_value: "", + }, ]; +/// These fields are excluded from removing all fields that are not in the schema +const FIELDS_EXCLUDED_FROM_REMOVING: &[&str] = &["PHONE", "PACE", "COUNTRY", "MMERGE9", "RESEARCH"]; + const SYNC_INTERVAL_SECS: u64 = 10; const PRINT_STILL_RUNNING_MESSAGE_TICKS_THRESHOLD: u32 = 60; @@ -121,9 +163,13 @@ async fn ensure_mailchimp_schema( let existing_fields = fetch_current_mailchimp_fields(list_id, server_prefix, access_token).await?; - // Remove extra fields not in REQUIRED_FIELDS + // Remove extra fields not in REQUIRED_FIELDS or FIELDS_EXCLUDED_FROM_REMOVING for field in existing_fields.iter() { - if !REQUIRED_FIELDS.contains(&field.field_name.as_str()) { + if !REQUIRED_FIELDS + .iter() + .any(|r| r.tag == field.field_name.as_str()) + && !FIELDS_EXCLUDED_FROM_REMOVING.contains(&field.field_name.as_str()) + { if let Err(e) = remove_field_from_mailchimp(list_id, &field.field_id, server_prefix, access_token) .await @@ -135,23 +181,31 @@ async fn ensure_mailchimp_schema( } } + info!("Existing fields: {:?}", existing_fields); + // Add any required fields that are missing - for &required_field in REQUIRED_FIELDS.iter() { + for required_field in REQUIRED_FIELDS.iter() { if !existing_fields .iter() - .any(|f| f.field_name == required_field) + .any(|f| f.field_name == required_field.tag) { if let Err(e) = add_field_to_mailchimp(list_id, required_field, server_prefix, access_token).await { - warn!("Failed to add required field '{}': {}", required_field, e); + warn!( + "Failed to add required field '{}': {}", + required_field.name, e + ); } else { - info!("Successfully added required field '{}'", required_field); + info!( + "Successfully added required field '{}'", + required_field.name + ); } } else { info!( "Field '{}' already exists, skipping addition.", - required_field + required_field.name ); } } @@ -178,6 +232,7 @@ async fn fetch_current_mailchimp_fields( if response.status().is_success() { let json = response.json::().await?; + let fields: Vec = json["merge_fields"] .as_array() .unwrap_or(&vec![]) @@ -208,7 +263,7 @@ async fn fetch_current_mailchimp_fields( /// Adds a new merge field to the Mailchimp list. async fn add_field_to_mailchimp( list_id: &str, - field_name: &str, + field_schema: &FieldSchema, server_prefix: &str, access_token: &str, ) -> anyhow::Result<()> { @@ -218,9 +273,10 @@ async fn add_field_to_mailchimp( ); let body = json!({ - "tag": field_name, - "name": field_name, - "type": "text" + "tag": field_schema.tag, + "name": field_schema.name, + "type": "text", + "default_value": field_schema.default_value, }); let response = REQWEST_CLIENT @@ -293,7 +349,7 @@ async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyho // Iterate through tokens and fetch and send user details to Mailchimp for token in access_tokens { // Fetch all users from Mailchimp and sync possible changes locally - let mailchimp_data = fetch_mailchimp_data_in_chunks( + let mailchimp_data = fetch_unsubscribed_users_from_mailchimp_in_chunks( &token.mailchimp_mailing_list_id, &token.server_prefix, &token.access_token, @@ -306,19 +362,20 @@ async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyho token.mailchimp_mailing_list_id ); - process_mailchimp_data(conn, mailchimp_data).await?; + process_unsubscribed_users_from_mailchimp(conn, mailchimp_data).await?; // Fetch unsynced emails and update them in Mailchimp let users_with_unsynced_emails = headless_lms_models::marketing_consents::fetch_all_unsynced_updated_emails( conn, - token.course_language_groups_id, + token.course_language_group_id, ) .await?; println!( - "Prosessing unsynced emails for list: {}", - token.mailchimp_mailing_list_id + "Found {} unsynced user email(s) for course language group: {}", + users_with_unsynced_emails.len(), + token.course_language_group_id ); if !users_with_unsynced_emails.is_empty() { @@ -336,16 +393,16 @@ async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyho // Fetch unsynced user consents and update them in Mailchimp let unsynced_users_details = - headless_lms_models::marketing_consents::fetch_all_unsynced_user_marketing_consents_by_course_language_groups_id( + headless_lms_models::marketing_consents::fetch_all_unsynced_user_marketing_consents_by_course_language_group_id( conn, - token.course_language_groups_id, + token.course_language_group_id, ) .await?; println!( "Found {} unsynced user consent(s) for course language group: {}", unsynced_users_details.len(), - token.course_language_groups_id + token.course_language_group_id ); if !unsynced_users_details.is_empty() { @@ -397,7 +454,7 @@ pub async fn send_users_to_mailchimp( "GRADUATED": if user.completed_course.unwrap_or(false) { "passed" } else { "not passed" }, "USERID": user.user_id, "COURSEID": user.course_id, - "LANGGRPID": user.course_language_groups_id, + "LANGGRPID": user.course_language_group_id, }, }); users_data_in_json.push(user_details); @@ -508,11 +565,19 @@ async fn update_emails_in_mailchimp( continue; } } + + if !failed_user_ids.is_empty() { + eprintln!("Failed to update the following users:"); + for user_id in &failed_user_ids { + error!("User ID: {}", user_id); + } + } + Ok(successfully_synced_user_ids) } /// Fetches data from Mailchimp in chunks. -async fn fetch_mailchimp_data_in_chunks( +async fn fetch_unsubscribed_users_from_mailchimp_in_chunks( list_id: &str, server_prefix: &str, access_token: &str, @@ -523,11 +588,11 @@ async fn fetch_mailchimp_data_in_chunks( loop { let url = format!( - "https://{}.api.mailchimp.com/3.0/lists/{}/members?offset={}&count={}&fields=members.merge_fields,members.status,members.last_changed?merge_fields[MARKETING]=disallowed", + "https://{}.api.mailchimp.com/3.0/lists/{}/members?offset={}&count={}&fields=members.merge_fields,members.status,members.last_changed&status=unsubscribed", server_prefix, list_id, offset, chunk_size ); - let response = reqwest::Client::new() + let response = REQWEST_CLIENT .get(&url) .bearer_auth(access_token) .send() @@ -549,18 +614,18 @@ async fn fetch_mailchimp_data_in_chunks( member["merge_fields"].as_object(), ) { // Ensure both USERID and LANGGRPID are present and valid - if let (Some(user_id), Some(language_groups_id)) = ( + if let (Some(user_id), Some(language_group_id)) = ( merge_fields.get("USERID").and_then(|v| v.as_str()), merge_fields.get("LANGGRPID").and_then(|v| v.as_str()), ) { // Avoid adding data if any field is missing or empty - if !user_id.is_empty() && !language_groups_id.is_empty() { + if !user_id.is_empty() && !language_group_id.is_empty() { let is_subscribed = status == "subscribed"; all_data.push(( user_id.to_string(), is_subscribed, last_changed.to_string(), - language_groups_id.to_string(), + language_group_id.to_string(), )); } } @@ -581,7 +646,7 @@ async fn fetch_mailchimp_data_in_chunks( const BATCH_SIZE: usize = 1000; -async fn process_mailchimp_data( +async fn process_unsubscribed_users_from_mailchimp( conn: &mut PgConnection, mailchimp_data: Vec<(String, bool, String, String)>, ) -> anyhow::Result<()> { @@ -594,12 +659,13 @@ async fn process_mailchimp_data( } // Attempt to process the current chunk - if let Err(e) = - headless_lms_models::marketing_consents::update_bulk_user_consent(conn, chunk.to_vec()) - .await + if let Err(e) = headless_lms_models::marketing_consents::update_unsubscribed_users_from_mailchimp_in_bulk( + conn, + chunk.to_vec(), + ) + .await { - // Log the error with chunk-specific context - eprintln!( + error!( "Error while processing chunk {}/{}: ", (total_records + BATCH_SIZE - 1) / BATCH_SIZE, e diff --git a/services/headless-lms/server/tests/study_registry_test.rs b/services/headless-lms/server/tests/study_registry_test.rs index 410c5d3980f8..84a4bb810358 100644 --- a/services/headless-lms/server/tests/study_registry_test.rs +++ b/services/headless-lms/server/tests/study_registry_test.rs @@ -164,6 +164,7 @@ async fn insert_data( copy_user_permissions: false, is_joinable_by_code_only: false, join_code: None, + ask_marketing_consent: false, }, user_1, models_requests::make_spec_fetcher( From 0305ed448f98cd22682caae74032883af1dbf0fa Mon Sep 17 00:00:00 2001 From: Maija Y Date: Wed, 27 Nov 2024 18:34:12 +0200 Subject: [PATCH 07/16] Added email subscription status to UserMarketingConsent --- ...0241023104801_add-marketing-consent.up.sql | 4 ++- ...5be217790808cb8751ae50d121321f0be9856.json | 13 ++++--- ...50aba48b2bb79374b55e9c0bcf42c6141b80f.json | 12 ------- ...3d10f9cadc90a2b82a8cd590f722a4a296a2.json} | 28 +++++++++------ ...892d0e9407051f27b20ea136276c634a4240b.json | 12 +++++++ ...794883766ed0ae390953ece769d7ab658dd0.json} | 28 +++++++++------ .../models/src/marketing_consents.rs | 34 +++++++++++-------- .../server/src/programs/mailchimp_syncer.rs | 19 ++++++----- 8 files changed, 88 insertions(+), 62 deletions(-) delete mode 100644 services/headless-lms/models/.sqlx/query-1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f.json rename services/headless-lms/models/.sqlx/{query-a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea.json => query-2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2.json} (67%) create mode 100644 services/headless-lms/models/.sqlx/query-5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b.json rename services/headless-lms/models/.sqlx/{query-89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d.json => query-c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0.json} (67%) diff --git a/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql index ccfa3165009c..52b0c106ae64 100644 --- a/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql +++ b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql @@ -10,6 +10,7 @@ CREATE TABLE user_marketing_consents ( user_id UUID NOT NULL REFERENCES users(id), user_mailchimp_id VARCHAR(255), consent BOOLEAN NOT NULL, + email_subscription_in_mailchimp VARCHAR(255), created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), deleted_at TIMESTAMP WITH TIME ZONE, @@ -26,7 +27,8 @@ COMMENT ON COLUMN user_marketing_consents.course_id IS 'Course that the user has COMMENT ON COLUMN user_marketing_consents.course_language_group_id IS 'The course language group id that the mailing list is related to'; COMMENT ON COLUMN user_marketing_consents.user_id IS 'User who has the access to the course.'; COMMENT ON COLUMN user_marketing_consents.user_mailchimp_id IS 'Unique id for the user, provided by Mailchimp'; -COMMENT ON COLUMN user_marketing_consents.consent IS 'Wheter the user has given a marketing consent for a specific course.'; +COMMENT ON COLUMN user_marketing_consents.consent IS 'Whether the user has given a marketing consent for a specific course.'; +COMMENT ON COLUMN user_marketing_consents.email_subscription_in_mailchimp IS 'Tells the users email subscription status in Mailchimp'; COMMENT ON COLUMN user_marketing_consents.created_at IS 'Timestamp of when the record was created.'; COMMENT ON COLUMN user_marketing_consents.updated_at IS 'Timestamp when the record was last updated. The field is updated automatically by the set_timestamp trigger.'; COMMENT ON COLUMN user_marketing_consents.deleted_at IS 'Timestamp when the record was deleted. If null, the record is not deleted.'; diff --git a/services/headless-lms/models/.sqlx/query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json b/services/headless-lms/models/.sqlx/query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json index 72099e082491..8c49e3cf316e 100644 --- a/services/headless-lms/models/.sqlx/query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json +++ b/services/headless-lms/models/.sqlx/query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json @@ -35,21 +35,26 @@ }, { "ordinal": 6, + "name": "email_subscription_in_mailchimp", + "type_info": "Varchar" + }, + { + "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "deleted_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "synced_to_mailchimp_at", "type_info": "Timestamptz" } @@ -57,7 +62,7 @@ "parameters": { "Left": ["Uuid", "Uuid"] }, - "nullable": [false, false, false, false, true, false, false, false, true, true] + "nullable": [false, false, false, false, true, false, true, false, false, true, true] }, "hash": "0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856" } diff --git a/services/headless-lms/models/.sqlx/query-1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f.json b/services/headless-lms/models/.sqlx/query-1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f.json deleted file mode 100644 index 76f24a2a71ef..000000000000 --- a/services/headless-lms/models/.sqlx/query-1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE user_marketing_consents\n SET consent = updated_data.consent,\n synced_to_mailchimp_at = updated_data.last_updated\n FROM (\n SELECT UNNEST($1::Uuid[]) AS user_id,\n UNNEST($2::bool[]) AS consent,\n UNNEST($3::timestamptz[]) AS last_updated,\n UNNEST($4::Uuid[]) AS course_language_group_id\n ) AS updated_data\n WHERE user_marketing_consents.user_id = updated_data.user_id\n AND user_marketing_consents.synced_to_mailchimp_at < updated_data.last_updated\n AND user_marketing_consents.course_language_group_id = updated_data.course_language_group_id\n ", - "describe": { - "columns": [], - "parameters": { - "Left": ["UuidArray", "BoolArray", "TimestamptzArray", "UuidArray"] - }, - "nullable": [] - }, - "hash": "1fc6b778efbecae84f6ee2a0d8550aba48b2bb79374b55e9c0bcf42c6141b80f" -} diff --git a/services/headless-lms/models/.sqlx/query-a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea.json b/services/headless-lms/models/.sqlx/query-2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2.json similarity index 67% rename from services/headless-lms/models/.sqlx/query-a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea.json rename to services/headless-lms/models/.sqlx/query-2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2.json index 08fbd68db59c..69dacfe6379a 100644 --- a/services/headless-lms/models/.sqlx/query-a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea.json +++ b/services/headless-lms/models/.sqlx/query-2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_group_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_group_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL\n OR umc.synced_to_mailchimp_at < umc.updated_at)\n ", + "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_group_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.email_subscription_in_mailchimp,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_group_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL\n OR umc.synced_to_mailchimp_at < umc.updated_at)\n ", "describe": { "columns": [ { @@ -35,51 +35,56 @@ }, { "ordinal": 6, + "name": "email_subscription_in_mailchimp", + "type_info": "Varchar" + }, + { + "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "deleted_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "synced_to_mailchimp_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 11, "name": "first_name", "type_info": "Varchar" }, { - "ordinal": 11, + "ordinal": 12, "name": "last_name", "type_info": "Varchar" }, { - "ordinal": 12, + "ordinal": 13, "name": "email", "type_info": "Varchar" }, { - "ordinal": 13, + "ordinal": 14, "name": "course_name", "type_info": "Varchar" }, { - "ordinal": 14, + "ordinal": 15, "name": "locale", "type_info": "Varchar" }, { - "ordinal": 15, + "ordinal": 16, "name": "completed_course", "type_info": "Bool" } @@ -94,6 +99,7 @@ false, true, false, + true, false, false, true, @@ -106,5 +112,5 @@ null ] }, - "hash": "a71bda04bfa871d51d778bb9c25e36f8709a86d2e33ec9f57b4a0e3a111c05ea" + "hash": "2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2" } diff --git a/services/headless-lms/models/.sqlx/query-5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b.json b/services/headless-lms/models/.sqlx/query-5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b.json new file mode 100644 index 000000000000..dc178411a3d3 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE user_marketing_consents\n SET consent = false,\n email_subscription_in_mailchimp = updated_data.email_subscription_in_mailchimp,\n synced_to_mailchimp_at = updated_data.last_updated\n FROM (\n SELECT UNNEST($1::Uuid[]) AS user_id,\n UNNEST($2::timestamptz[]) AS last_updated,\n UNNEST($3::Uuid[]) AS course_language_group_id,\n UNNEST($4::text[]) AS email_subscription_in_mailchimp\n\n ) AS updated_data\n WHERE user_marketing_consents.user_id = updated_data.user_id\n AND user_marketing_consents.synced_to_mailchimp_at < updated_data.last_updated\n AND user_marketing_consents.course_language_group_id = updated_data.course_language_group_id\n ", + "describe": { + "columns": [], + "parameters": { + "Left": ["UuidArray", "TimestamptzArray", "UuidArray", "TextArray"] + }, + "nullable": [] + }, + "hash": "5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b" +} diff --git a/services/headless-lms/models/.sqlx/query-89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d.json b/services/headless-lms/models/.sqlx/query-c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0.json similarity index 67% rename from services/headless-lms/models/.sqlx/query-89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d.json rename to services/headless-lms/models/.sqlx/query-c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0.json index 13feeaaedab3..efb67f08f7de 100644 --- a/services/headless-lms/models/.sqlx/query-89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d.json +++ b/services/headless-lms/models/.sqlx/query-c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_group_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_group_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < u.updated_at)\n ", + "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_group_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.email_subscription_in_mailchimp,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_group_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < u.updated_at)\n ", "describe": { "columns": [ { @@ -35,51 +35,56 @@ }, { "ordinal": 6, + "name": "email_subscription_in_mailchimp", + "type_info": "Varchar" + }, + { + "ordinal": 7, "name": "created_at", "type_info": "Timestamptz" }, { - "ordinal": 7, + "ordinal": 8, "name": "updated_at", "type_info": "Timestamptz" }, { - "ordinal": 8, + "ordinal": 9, "name": "deleted_at", "type_info": "Timestamptz" }, { - "ordinal": 9, + "ordinal": 10, "name": "synced_to_mailchimp_at", "type_info": "Timestamptz" }, { - "ordinal": 10, + "ordinal": 11, "name": "first_name", "type_info": "Varchar" }, { - "ordinal": 11, + "ordinal": 12, "name": "last_name", "type_info": "Varchar" }, { - "ordinal": 12, + "ordinal": 13, "name": "email", "type_info": "Varchar" }, { - "ordinal": 13, + "ordinal": 14, "name": "course_name", "type_info": "Varchar" }, { - "ordinal": 14, + "ordinal": 15, "name": "locale", "type_info": "Varchar" }, { - "ordinal": 15, + "ordinal": 16, "name": "completed_course", "type_info": "Bool" } @@ -94,6 +99,7 @@ false, true, false, + true, false, false, true, @@ -106,5 +112,5 @@ null ] }, - "hash": "89b8ca0887cdf25ad383e29ff5c75f1575e6f8557a1612d9560cb7deed06269d" + "hash": "c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0" } diff --git a/services/headless-lms/models/src/marketing_consents.rs b/services/headless-lms/models/src/marketing_consents.rs index d9149b31d19d..f7fd04f99c68 100644 --- a/services/headless-lms/models/src/marketing_consents.rs +++ b/services/headless-lms/models/src/marketing_consents.rs @@ -11,6 +11,7 @@ pub struct UserMarketingConsent { pub user_id: Uuid, pub user_mailchimp_id: Option, pub consent: bool, + pub email_subscription_in_mailchimp: Option, pub created_at: DateTime, pub updated_at: DateTime, pub deleted_at: Option>, @@ -26,6 +27,7 @@ pub struct UserMarketingConsentWithDetails { pub user_id: Uuid, pub user_mailchimp_id: Option, pub consent: bool, + pub email_subscription_in_mailchimp: Option, pub created_at: DateTime, pub updated_at: DateTime, pub deleted_at: Option>, @@ -116,6 +118,7 @@ pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_group umc.user_id, umc.user_mailchimp_id, umc.consent, + umc.email_subscription_in_mailchimp, umc.created_at, umc.updated_at, umc.deleted_at, @@ -158,6 +161,7 @@ pub async fn fetch_all_unsynced_updated_emails( umc.user_id, umc.user_mailchimp_id, umc.consent, + umc.email_subscription_in_mailchimp, umc.created_at, umc.updated_at, umc.deleted_at, @@ -235,17 +239,17 @@ WHERE user_marketing_consents.user_id = updated_data.user_id Ok(()) } -/// Updates user consents in bulk using Mailchimp data. +/// Updates user consents to false in bulk using Mailchimp data. pub async fn update_unsubscribed_users_from_mailchimp_in_bulk( conn: &mut PgConnection, - mailchimp_data: Vec<(String, bool, String, String)>, + mailchimp_data: Vec<(String, String, String, String)>, ) -> anyhow::Result<()> { - let (user_ids_raw, consents, timestamps_raw, course_language_group_ids_raw): ( - Vec<_>, - Vec<_>, - Vec<_>, - Vec<_>, - ) = multiunzip(mailchimp_data); + let ( + user_ids_raw, + timestamps_raw, + course_language_group_ids_raw, + email_subscriptions_in_mailchimp, + ): (Vec<_>, Vec<_>, Vec<_>, Vec<_>) = multiunzip(mailchimp_data); let user_ids: Vec = user_ids_raw .into_iter() @@ -269,22 +273,24 @@ pub async fn update_unsubscribed_users_from_mailchimp_in_bulk( sqlx::query!( " UPDATE user_marketing_consents - SET consent = updated_data.consent, + SET consent = false, + email_subscription_in_mailchimp = updated_data.email_subscription_in_mailchimp, synced_to_mailchimp_at = updated_data.last_updated FROM ( SELECT UNNEST($1::Uuid[]) AS user_id, - UNNEST($2::bool[]) AS consent, - UNNEST($3::timestamptz[]) AS last_updated, - UNNEST($4::Uuid[]) AS course_language_group_id + UNNEST($2::timestamptz[]) AS last_updated, + UNNEST($3::Uuid[]) AS course_language_group_id, + UNNEST($4::text[]) AS email_subscription_in_mailchimp + ) AS updated_data WHERE user_marketing_consents.user_id = updated_data.user_id AND user_marketing_consents.synced_to_mailchimp_at < updated_data.last_updated AND user_marketing_consents.course_language_group_id = updated_data.course_language_group_id ", &user_ids, - &consents, ×tamps, - &course_language_group_ids + &course_language_group_ids, + &email_subscriptions_in_mailchimp ) .execute(conn) .await?; diff --git a/services/headless-lms/server/src/programs/mailchimp_syncer.rs b/services/headless-lms/server/src/programs/mailchimp_syncer.rs index 7e045db9c6f8..256df8219ac2 100644 --- a/services/headless-lms/server/src/programs/mailchimp_syncer.rs +++ b/services/headless-lms/server/src/programs/mailchimp_syncer.rs @@ -181,8 +181,6 @@ async fn ensure_mailchimp_schema( } } - info!("Existing fields: {:?}", existing_fields); - // Add any required fields that are missing for required_field in REQUIRED_FIELDS.iter() { if !existing_fields @@ -445,7 +443,11 @@ pub async fn send_users_to_mailchimp( for user in &users_details { let user_details = json!({ "email_address": user.email, - "status": "subscribed", + "status": if user.consent { + "subscribed".to_string() + } else { + user.email_subscription_in_mailchimp.clone().unwrap_or_else(|| "subscribed".to_string()) + }, "merge_fields": { "FNAME": user.first_name, "LNAME": user.last_name, @@ -582,19 +584,19 @@ async fn fetch_unsubscribed_users_from_mailchimp_in_chunks( server_prefix: &str, access_token: &str, chunk_size: usize, -) -> anyhow::Result> { +) -> anyhow::Result> { let mut all_data = Vec::new(); let mut offset = 0; loop { let url = format!( - "https://{}.api.mailchimp.com/3.0/lists/{}/members?offset={}&count={}&fields=members.merge_fields,members.status,members.last_changed&status=unsubscribed", + "https://{}.api.mailchimp.com/3.0/lists/{}/members?offset={}&count={}&fields=members.merge_fields,members.status,members.last_changed&status=unsubscribed,non-subscribed", server_prefix, list_id, offset, chunk_size ); let response = REQWEST_CLIENT .get(&url) - .bearer_auth(access_token) + .header("Authorization", format!("apikey {}", access_token)) .send() .await? .json::() @@ -620,12 +622,11 @@ async fn fetch_unsubscribed_users_from_mailchimp_in_chunks( ) { // Avoid adding data if any field is missing or empty if !user_id.is_empty() && !language_group_id.is_empty() { - let is_subscribed = status == "subscribed"; all_data.push(( user_id.to_string(), - is_subscribed, last_changed.to_string(), language_group_id.to_string(), + status.to_string(), )); } } @@ -648,7 +649,7 @@ const BATCH_SIZE: usize = 1000; async fn process_unsubscribed_users_from_mailchimp( conn: &mut PgConnection, - mailchimp_data: Vec<(String, bool, String, String)>, + mailchimp_data: Vec<(String, String, String, String)>, ) -> anyhow::Result<()> { // Log the total size of the Mailchimp data let total_records = mailchimp_data.len(); From 6b6aa6b731b2d31434256e16f8343c4cad0999b3 Mon Sep 17 00:00:00 2001 From: Maija Y Date: Wed, 27 Nov 2024 19:06:37 +0200 Subject: [PATCH 08/16] Little changes --- .../src/components/forms/SelectMarketingConsentForm.tsx | 6 +++--- .../src/components/modals/CourseSettingsModal.tsx | 4 ++-- services/course-material/src/services/backend.ts | 4 ++-- .../manage/courses/id/index/UpdateCourseForm.tsx | 3 +-- .../packages/common/src/locales/en/main-frontend.json | 1 + .../packages/common/src/locales/fi/main-frontend.json | 1 + 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx b/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx index 2dd9ebe0d9a7..c4f30c16120e 100644 --- a/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx +++ b/services/course-material/src/components/forms/SelectMarketingConsentForm.tsx @@ -6,12 +6,12 @@ import CheckBox from "@/shared-module/common/components/InputFields/CheckBox" import useToastMutation from "@/shared-module/common/hooks/useToastMutation" import { assertNotNullOrUndefined } from "@/shared-module/common/utils/nullability" -interface selectMarketingConstentProps { +interface SelectMarketingConsentFormProps { courseId: string courseLanguageGroupsId: string } -const SelectMarketingConstentForm: React.FC = ({ +const SelectMarketingConsentForm: React.FC = ({ courseId, courseLanguageGroupsId, }) => { @@ -57,4 +57,4 @@ const SelectMarketingConstentForm: React.FC = ({ ) } -export default SelectMarketingConstentForm +export default SelectMarketingConsentForm diff --git a/services/course-material/src/components/modals/CourseSettingsModal.tsx b/services/course-material/src/components/modals/CourseSettingsModal.tsx index 37580c57d6b5..264ee7111e42 100644 --- a/services/course-material/src/components/modals/CourseSettingsModal.tsx +++ b/services/course-material/src/components/modals/CourseSettingsModal.tsx @@ -12,7 +12,7 @@ import { } from "../../services/backend" import SelectCourseLanguage from "../SelectCourseLanguage" import SelectCourseInstanceForm from "../forms/SelectCourseInstanceForm" -import SelectMarketingConstentForm from "../forms/SelectMarketingConsentForm" +import SelectMarketingConsentForm from "../forms/SelectMarketingConsentForm" import { getLanguageName, @@ -202,7 +202,7 @@ const CourseSettingsModal: React.FC {getCourse.data?.ask_marketing_consent && ( - diff --git a/services/course-material/src/services/backend.ts b/services/course-material/src/services/backend.ts index 174f5be18971..7cb8093a72d3 100644 --- a/services/course-material/src/services/backend.ts +++ b/services/course-material/src/services/backend.ts @@ -752,7 +752,7 @@ export const updateMarketingConsent = async ( consent: boolean, ): Promise => { const res = await courseMaterialClient.post( - `courses/${courseId}/user-marketing-consent`, + `/courses/${courseId}/user-marketing-consent`, { course_language_groups_id: courseLanguageGroupsId, consent, @@ -767,6 +767,6 @@ export const updateMarketingConsent = async ( export const fetchUserMarketingConsent = async ( courseId: string, ): Promise => { - const res = await courseMaterialClient.get(`courses/${courseId}/fetch-user-marketing-consent`) + const res = await courseMaterialClient.get(`/courses/${courseId}/fetch-user-marketing-consent`) return validateResponse(res, isUserMarketingConsent) } diff --git a/services/main-frontend/src/components/page-specific/manage/courses/id/index/UpdateCourseForm.tsx b/services/main-frontend/src/components/page-specific/manage/courses/id/index/UpdateCourseForm.tsx index b7f0476383c0..ddbd7474899a 100644 --- a/services/main-frontend/src/components/page-specific/manage/courses/id/index/UpdateCourseForm.tsx +++ b/services/main-frontend/src/components/page-specific/manage/courses/id/index/UpdateCourseForm.tsx @@ -141,8 +141,7 @@ const UpdateCourseForm: React.FC> { setAskMarketingConsent(!askMarketingConsent) }} diff --git a/shared-module/packages/common/src/locales/en/main-frontend.json b/shared-module/packages/common/src/locales/en/main-frontend.json index 59129f106362..e102b4dadc78 100644 --- a/shared-module/packages/common/src/locales/en/main-frontend.json +++ b/shared-module/packages/common/src/locales/en/main-frontend.json @@ -298,6 +298,7 @@ "label-action": "Action", "label-actions": "Actions", "label-add-user": "Add user", + "label-ask-for-marketing-consent": "Ask marketing consent", "label-attempted-exercises": "Attempted exercises", "label-attempted-exercises-required": "Attempted exercises required", "label-background-svg": "Background SVG", diff --git a/shared-module/packages/common/src/locales/fi/main-frontend.json b/shared-module/packages/common/src/locales/fi/main-frontend.json index 1dd7fd4f86ca..8384c8f2f164 100644 --- a/shared-module/packages/common/src/locales/fi/main-frontend.json +++ b/shared-module/packages/common/src/locales/fi/main-frontend.json @@ -302,6 +302,7 @@ "label-action": "Toiminta", "label-actions": "Toiminnot", "label-add-user": "Lisää käyttäjä", + "label-ask-for-marketing-consent": "Kysy markkinointisuostumusta", "label-attempted-exercises": "Yritetyt tehtävät", "label-attempted-exercises-required": "Yritettyjä tehtäviä vaaditaan", "label-background-svg": "Taustan SVG", From 4d9a4a95bd2f18bda2d9c0f47de7eed556862d96 Mon Sep 17 00:00:00 2001 From: Maija Y Date: Wed, 27 Nov 2024 22:04:51 +0200 Subject: [PATCH 09/16] Changed println to info --- .../server/src/programs/mailchimp_syncer.rs | 10 +++++----- shared-module/packages/common/src/locales/en/cms.json | 6 +++--- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/services/headless-lms/server/src/programs/mailchimp_syncer.rs b/services/headless-lms/server/src/programs/mailchimp_syncer.rs index 256df8219ac2..38de045adfca 100644 --- a/services/headless-lms/server/src/programs/mailchimp_syncer.rs +++ b/services/headless-lms/server/src/programs/mailchimp_syncer.rs @@ -355,7 +355,7 @@ async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyho ) .await?; - println!( + info!( "Processing Mailchimp data for list: {}", token.mailchimp_mailing_list_id ); @@ -370,7 +370,7 @@ async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyho ) .await?; - println!( + info!( "Found {} unsynced user email(s) for course language group: {}", users_with_unsynced_emails.len(), token.course_language_group_id @@ -397,7 +397,7 @@ async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyho ) .await?; - println!( + info!( "Found {} unsynced user consent(s) for course language group: {}", unsynced_users_details.len(), token.course_language_group_id @@ -419,7 +419,7 @@ async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyho &successfully_synced_user_ids, ) .await?; - println!( + info!( "Successfully updated synced status for {} users.", successfully_synced_user_ids.len() ); @@ -569,7 +569,7 @@ async fn update_emails_in_mailchimp( } if !failed_user_ids.is_empty() { - eprintln!("Failed to update the following users:"); + info!("Failed to update the following users:"); for user_id in &failed_user_ids { error!("User ID: {}", user_id); } diff --git a/shared-module/packages/common/src/locales/en/cms.json b/shared-module/packages/common/src/locales/en/cms.json index ca9fafd22477..1230895b286c 100644 --- a/shared-module/packages/common/src/locales/en/cms.json +++ b/shared-module/packages/common/src/locales/en/cms.json @@ -110,6 +110,7 @@ "peer-reviews-to-receive": "Peer reviews to receive", "peer-reviews-to-receive-and-give-error-message": "Peer reviews to give must be greater than peer reviews to receive", "please-select-exercise-type": "Please select an exercise type:", + "primary-color": "Primary color", "remove": "Remove", "research-form-checkbox-description": "This block is used to add a question to the research form.", "reset": "Reset", @@ -123,7 +124,6 @@ "select-repository-exercise": "Select repository exercise", "selected-exercise-type": "Selected exercise type: {{exerciseType}}", "separator-color": "Separator color", - "primary-color": "Primary color", "serialize-to-html": "Serialize to HTML", "slide-title": "Slide {{ number }}", "start": "Start", @@ -131,6 +131,7 @@ "table-box-description": "This is a custom table block with colored background", "table-width-customizer": "Table width customizer", "task": "Task", + "terminology": "Terminology", "title-additional-review-instructions": "Additional review instructions", "title-assignment": "Assignment", "title-outdated-blocks-migrated": "Outdated blocks migrated", @@ -145,6 +146,5 @@ "use-default-text-for-label": "Use default text for label", "warning-points-are-all-or-nothing-disabled": "Warning: It is recommended to enable “points are all or nothing”. The peer reviews given by students vary greatly in quality, which may lead to some students receiving unfair points from the exercise. Enabling this option reduces randomness in the received points, making the peer review process fairer for the students.", "welcome-message-for-course": "Welcome message for course...", - "terminology": "Terminology", "width-of-table": "Width of table" -} \ No newline at end of file +} From a4083dbee88f3cc3753cc02f59cdf89bb89213ed Mon Sep 17 00:00:00 2001 From: Maija Y Date: Thu, 28 Nov 2024 09:57:32 +0200 Subject: [PATCH 10/16] Moved div --- .../components/modals/CourseSettingsModal.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/services/course-material/src/components/modals/CourseSettingsModal.tsx b/services/course-material/src/components/modals/CourseSettingsModal.tsx index 264ee7111e42..feb080e2a66b 100644 --- a/services/course-material/src/components/modals/CourseSettingsModal.tsx +++ b/services/course-material/src/components/modals/CourseSettingsModal.tsx @@ -196,18 +196,18 @@ const CourseSettingsModal: React.FC )} -
- {getCourse.data?.ask_marketing_consent && ( + {getCourse.data?.ask_marketing_consent && ( +
- )} -
+
+ )} {languageChanged && (
Date: Wed, 4 Dec 2024 20:40:57 +0200 Subject: [PATCH 11/16] new checkbox and research consent for mailchimp syncer --- .../forms/SelectCourseInstanceForm.tsx | 50 +++++++- .../forms/SelectMarketingConsentForm.tsx | 52 ++++---- .../components/modals/CourseSettingsModal.tsx | 37 +++--- .../course-material/src/services/backend.ts | 6 +- ...63d10f9cadc90a2b82a8cd590f722a4a296a2.json | 116 ------------------ ...6c9e73cacecfb2b845f0d35895295e47e4d9a.json | 18 --- ...892d0e9407051f27b20ea136276c634a4240b.json | 12 -- ...1794883766ed0ae390953ece769d7ab658dd0.json | 116 ------------------ .../models/src/marketing_consents.rs | 67 +++++----- .../controllers/course_material/courses.rs | 12 +- .../server/src/programs/mailchimp_syncer.rs | 95 ++++++++------ .../server/src/ts_binding_generator.rs | 2 +- .../packages/common/src/bindings.guard.ts | 8 +- shared-module/packages/common/src/bindings.ts | 4 + .../src/locales/en/course-material.json | 2 + .../src/locales/uk/course-material.json | 2 + 16 files changed, 223 insertions(+), 376 deletions(-) delete mode 100644 services/headless-lms/models/.sqlx/query-2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2.json delete mode 100644 services/headless-lms/models/.sqlx/query-398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a.json delete mode 100644 services/headless-lms/models/.sqlx/query-5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b.json delete mode 100644 services/headless-lms/models/.sqlx/query-c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0.json diff --git a/services/course-material/src/components/forms/SelectCourseInstanceForm.tsx b/services/course-material/src/components/forms/SelectCourseInstanceForm.tsx index 3c1828728458..ab2bf218f767 100644 --- a/services/course-material/src/components/forms/SelectCourseInstanceForm.tsx +++ b/services/course-material/src/components/forms/SelectCourseInstanceForm.tsx @@ -4,7 +4,13 @@ import { UseMutationResult, useQuery } from "@tanstack/react-query" import React, { useEffect, useState } from "react" import { useTranslation } from "react-i18next" -import { fetchBackgroundQuestionsAndAnswers } from "../../services/backend" +import { + fetchBackgroundQuestionsAndAnswers, + fetchCourseById, + updateMarketingConsent, +} from "../../services/backend" + +import SelectMarketingConsentForm from "./SelectMarketingConsentForm" import { CourseInstance, NewCourseBackgroundQuestionAnswer } from "@/shared-module/common/bindings" import Button from "@/shared-module/common/components/Button" @@ -40,11 +46,18 @@ interface SelectCourseInstanceFormProps { > initialSelectedInstanceId?: string dialogLanguage: string + selectedLangCourseId: string } const SelectCourseInstanceForm: React.FC< React.PropsWithChildren -> = ({ courseInstances, submitMutation, initialSelectedInstanceId, dialogLanguage }) => { +> = ({ + courseInstances, + submitMutation, + initialSelectedInstanceId, + dialogLanguage, + selectedLangCourseId, +}) => { const { t } = useTranslation("course-material", { lng: dialogLanguage }) const [selectedInstanceId, setSelectedInstanceId] = useState( figureOutInitialValue(courseInstances, initialSelectedInstanceId), @@ -52,12 +65,22 @@ const SelectCourseInstanceForm: React.FC< const [additionalQuestionAnswers, setAdditionalQuestionAnswers] = useState< NewCourseBackgroundQuestionAnswer[] >([]) + + const [isMarketingConsentChecked, setIsMarketingConsentChecked] = useState(false) + const [isEmailSubscriptionConsentChecked, setIsEmailSubscriptionConsentChecked] = useState(false) + const additionalQuestionsQuery = useQuery({ queryKey: ["additional-questions", selectedInstanceId], queryFn: () => fetchBackgroundQuestionsAndAnswers(assertNotNullOrUndefined(selectedInstanceId)), enabled: selectedInstanceId !== undefined, }) + const getCourse = useQuery({ + queryKey: ["courses", selectedLangCourseId], + queryFn: () => fetchCourseById(selectedLangCourseId as NonNullable), + enabled: selectedLangCourseId !== null, + }) + useEffect(() => { if (!additionalQuestionsQuery.data) { return @@ -100,6 +123,14 @@ const SelectCourseInstanceForm: React.FC< backgroundQuestionAnswers: additionalQuestionAnswers, }) } + if (getCourse.isSuccess) { + await updateMarketingConsent( + getCourse.data.id, + getCourse.data.course_language_group_id, + isEmailSubscriptionConsentChecked, + isMarketingConsentChecked, + ) + } } const additionalQuestions = additionalQuestionsQuery.data?.background_questions @@ -191,12 +222,25 @@ const SelectCourseInstanceForm: React.FC< {additionalQuestionsQuery.error && ( )} + {getCourse.data?.ask_marketing_consent && ( +
+ +
+ )}
- {getCourse.data?.ask_marketing_consent && ( -
- -
- )} + {languageChanged && (
=> export const updateMarketingConsent = async ( courseId: string, courseLanguageGroupsId: string, - consent: boolean, + emailSubscription: boolean, + marketingConsent: boolean, ): Promise => { const res = await courseMaterialClient.post( `/courses/${courseId}/user-marketing-consent`, { course_language_groups_id: courseLanguageGroupsId, - consent, + email_subscription: emailSubscription, + marketing_consent: marketingConsent, }, { responseType: "json", diff --git a/services/headless-lms/models/.sqlx/query-2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2.json b/services/headless-lms/models/.sqlx/query-2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2.json deleted file mode 100644 index 69dacfe6379a..000000000000 --- a/services/headless-lms/models/.sqlx/query-2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_group_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.email_subscription_in_mailchimp,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_group_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL\n OR umc.synced_to_mailchimp_at < umc.updated_at)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "course_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "course_language_group_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "user_mailchimp_id", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "consent", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "email_subscription_in_mailchimp", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "deleted_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "synced_to_mailchimp_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 11, - "name": "first_name", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "last_name", - "type_info": "Varchar" - }, - { - "ordinal": 13, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 14, - "name": "course_name", - "type_info": "Varchar" - }, - { - "ordinal": 15, - "name": "locale", - "type_info": "Varchar" - }, - { - "ordinal": 16, - "name": "completed_course", - "type_info": "Bool" - } - ], - "parameters": { - "Left": ["Uuid"] - }, - "nullable": [ - false, - false, - false, - false, - true, - false, - true, - false, - false, - true, - true, - true, - true, - false, - false, - false, - null - ] - }, - "hash": "2835b0aa8afe26fc0aede0c931563d10f9cadc90a2b82a8cd590f722a4a296a2" -} diff --git a/services/headless-lms/models/.sqlx/query-398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a.json b/services/headless-lms/models/.sqlx/query-398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a.json deleted file mode 100644 index e8e03908dba1..000000000000 --- a/services/headless-lms/models/.sqlx/query-398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n INSERT INTO user_marketing_consents (user_id, course_id, course_language_group_id, consent)\n VALUES ($1, $2, $3, $4)\n ON CONFLICT (user_id, course_language_group_id)\n DO UPDATE\n SET consent = $4\n RETURNING id\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - } - ], - "parameters": { - "Left": ["Uuid", "Uuid", "Uuid", "Bool"] - }, - "nullable": [false] - }, - "hash": "398c47b513e0dfaebd258b876a46c9e73cacecfb2b845f0d35895295e47e4d9a" -} diff --git a/services/headless-lms/models/.sqlx/query-5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b.json b/services/headless-lms/models/.sqlx/query-5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b.json deleted file mode 100644 index dc178411a3d3..000000000000 --- a/services/headless-lms/models/.sqlx/query-5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n UPDATE user_marketing_consents\n SET consent = false,\n email_subscription_in_mailchimp = updated_data.email_subscription_in_mailchimp,\n synced_to_mailchimp_at = updated_data.last_updated\n FROM (\n SELECT UNNEST($1::Uuid[]) AS user_id,\n UNNEST($2::timestamptz[]) AS last_updated,\n UNNEST($3::Uuid[]) AS course_language_group_id,\n UNNEST($4::text[]) AS email_subscription_in_mailchimp\n\n ) AS updated_data\n WHERE user_marketing_consents.user_id = updated_data.user_id\n AND user_marketing_consents.synced_to_mailchimp_at < updated_data.last_updated\n AND user_marketing_consents.course_language_group_id = updated_data.course_language_group_id\n ", - "describe": { - "columns": [], - "parameters": { - "Left": ["UuidArray", "TimestamptzArray", "UuidArray", "TextArray"] - }, - "nullable": [] - }, - "hash": "5d95679ce5dfd7c1eafc3fb8d38892d0e9407051f27b20ea136276c634a4240b" -} diff --git a/services/headless-lms/models/.sqlx/query-c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0.json b/services/headless-lms/models/.sqlx/query-c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0.json deleted file mode 100644 index efb67f08f7de..000000000000 --- a/services/headless-lms/models/.sqlx/query-c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0.json +++ /dev/null @@ -1,116 +0,0 @@ -{ - "db_name": "PostgreSQL", - "query": "\n SELECT\n umc.id,\n umc.course_id,\n umc.course_language_group_id,\n umc.user_id,\n umc.user_mailchimp_id,\n umc.consent,\n umc.email_subscription_in_mailchimp,\n umc.created_at,\n umc.updated_at,\n umc.deleted_at,\n umc.synced_to_mailchimp_at,\n u.first_name AS first_name,\n u.last_name AS last_name,\n u.email AS email,\n c.name AS course_name,\n c.language_code AS locale,\n CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n JOIN courses AS c ON c.id = umc.course_id\n LEFT JOIN course_module_completions AS cmc\n ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id\n WHERE umc.course_language_group_id = $1\n AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < u.updated_at)\n ", - "describe": { - "columns": [ - { - "ordinal": 0, - "name": "id", - "type_info": "Uuid" - }, - { - "ordinal": 1, - "name": "course_id", - "type_info": "Uuid" - }, - { - "ordinal": 2, - "name": "course_language_group_id", - "type_info": "Uuid" - }, - { - "ordinal": 3, - "name": "user_id", - "type_info": "Uuid" - }, - { - "ordinal": 4, - "name": "user_mailchimp_id", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "consent", - "type_info": "Bool" - }, - { - "ordinal": 6, - "name": "email_subscription_in_mailchimp", - "type_info": "Varchar" - }, - { - "ordinal": 7, - "name": "created_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 8, - "name": "updated_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 9, - "name": "deleted_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 10, - "name": "synced_to_mailchimp_at", - "type_info": "Timestamptz" - }, - { - "ordinal": 11, - "name": "first_name", - "type_info": "Varchar" - }, - { - "ordinal": 12, - "name": "last_name", - "type_info": "Varchar" - }, - { - "ordinal": 13, - "name": "email", - "type_info": "Varchar" - }, - { - "ordinal": 14, - "name": "course_name", - "type_info": "Varchar" - }, - { - "ordinal": 15, - "name": "locale", - "type_info": "Varchar" - }, - { - "ordinal": 16, - "name": "completed_course", - "type_info": "Bool" - } - ], - "parameters": { - "Left": ["Uuid"] - }, - "nullable": [ - false, - false, - false, - false, - true, - false, - true, - false, - false, - true, - true, - true, - true, - false, - false, - false, - null - ] - }, - "hash": "c876ab77b567d1e1e76589198411794883766ed0ae390953ece769d7ab658dd0" -} diff --git a/services/headless-lms/models/src/marketing_consents.rs b/services/headless-lms/models/src/marketing_consents.rs index f7fd04f99c68..952c0dc309d2 100644 --- a/services/headless-lms/models/src/marketing_consents.rs +++ b/services/headless-lms/models/src/marketing_consents.rs @@ -38,11 +38,20 @@ pub struct UserMarketingConsentWithDetails { pub course_name: String, pub locale: Option, pub completed_course: Option, + pub research_consent: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] #[cfg_attr(feature = "ts_rs", derive(TS))] +pub struct UserEmailSubscription { + pub user_id: Uuid, + pub email: String, + pub email_subscription_in_mailchimp: Option, + pub user_mailchimp_id: Option, +} +#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[cfg_attr(feature = "ts_rs", derive(TS))] pub struct MarketingMailingListAccessToken { pub id: Uuid, pub course_id: Uuid, @@ -60,21 +69,25 @@ pub async fn upsert_marketing_consent( course_id: Uuid, course_language_group_id: Uuid, user_id: &Uuid, - consent: bool, + email_subscription: &str, + marketing_consent: bool, ) -> sqlx::Result { let result = sqlx::query!( r#" - INSERT INTO user_marketing_consents (user_id, course_id, course_language_group_id, consent) - VALUES ($1, $2, $3, $4) + INSERT INTO user_marketing_consents (user_id, course_id, course_language_group_id, consent, email_subscription_in_mailchimp) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (user_id, course_language_group_id) DO UPDATE - SET consent = $4 + SET + consent = $4, + email_subscription_in_mailchimp = $5 RETURNING id "#, user_id, course_id, course_language_group_id, - consent + marketing_consent, + email_subscription ) .fetch_one(conn) .await?; @@ -128,15 +141,24 @@ pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_group u.email AS email, c.name AS course_name, c.language_code AS locale, - CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course + CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course, + COALESCE(csfa.research_consent, urc.research_consent) AS research_consent FROM user_marketing_consents AS umc JOIN user_details AS u ON u.user_id = umc.user_id JOIN courses AS c ON c.id = umc.course_id LEFT JOIN course_module_completions AS cmc ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id + LEFT JOIN course_specific_consent_form_answers AS csfa + ON csfa.course_id = umc.course_id AND csfa.user_id = umc.user_id + LEFT JOIN user_research_consents AS urc + ON urc.user_id = umc.user_id WHERE umc.course_language_group_id = $1 - AND (umc.synced_to_mailchimp_at IS NULL - OR umc.synced_to_mailchimp_at < umc.updated_at) + AND ( + umc.synced_to_mailchimp_at IS NULL + OR umc.synced_to_mailchimp_at < umc.updated_at + OR csfa.updated_at > umc.synced_to_mailchimp_at + OR urc.updated_at > umc.synced_to_mailchimp_at +) ", course_language_group_id ) @@ -146,39 +168,23 @@ pub async fn fetch_all_unsynced_user_marketing_consents_by_course_language_group Ok(result) } -/// Fetches all user details that have been updated after their marketing consent was last synced to Mailchimp +/// Fetches email, email subscription status and user ID for users whose details have been updated after their marketing consent was last synced to Mailchimp pub async fn fetch_all_unsynced_updated_emails( conn: &mut PgConnection, course_language_group_id: Uuid, -) -> sqlx::Result> { +) -> sqlx::Result> { let result = sqlx::query_as!( - UserMarketingConsentWithDetails, + UserEmailSubscription, " SELECT - umc.id, - umc.course_id, - umc.course_language_group_id, umc.user_id, - umc.user_mailchimp_id, - umc.consent, - umc.email_subscription_in_mailchimp, - umc.created_at, - umc.updated_at, - umc.deleted_at, - umc.synced_to_mailchimp_at, - u.first_name AS first_name, - u.last_name AS last_name, u.email AS email, - c.name AS course_name, - c.language_code AS locale, - CASE WHEN cmc.passed IS NOT NULL THEN cmc.passed ELSE NULL END AS completed_course + umc.email_subscription_in_mailchimp, + umc.user_mailchimp_id FROM user_marketing_consents AS umc JOIN user_details AS u ON u.user_id = umc.user_id - JOIN courses AS c ON c.id = umc.course_id - LEFT JOIN course_module_completions AS cmc - ON cmc.user_id = umc.user_id AND cmc.course_id = umc.course_id WHERE umc.course_language_group_id = $1 - AND (umc.synced_to_mailchimp_at IS NULL OR umc.synced_to_mailchimp_at < u.updated_at) + AND umc.synced_to_mailchimp_at < u.updated_at ", course_language_group_id ) @@ -284,6 +290,7 @@ pub async fn update_unsubscribed_users_from_mailchimp_in_bulk( ) AS updated_data WHERE user_marketing_consents.user_id = updated_data.user_id + AND user_marketing_consents.consent = true AND user_marketing_consents.synced_to_mailchimp_at < updated_data.last_updated AND user_marketing_consents.course_language_group_id = updated_data.course_language_group_id ", diff --git a/services/headless-lms/server/src/controllers/course_material/courses.rs b/services/headless-lms/server/src/controllers/course_material/courses.rs index 0f02dc29f34c..caae7fabb55a 100644 --- a/services/headless-lms/server/src/controllers/course_material/courses.rs +++ b/services/headless-lms/server/src/controllers/course_material/courses.rs @@ -906,7 +906,8 @@ async fn get_research_form_answers_with_user_id( #[cfg_attr(feature = "ts_rs", derive(TS))] pub struct UserMarketingConsentPayload { pub course_language_groups_id: Uuid, - pub consent: bool, + pub email_subscription: bool, + pub marketing_consent: bool, } /** @@ -924,12 +925,19 @@ async fn update_marketing_consent( let token = authorize_access_to_course_material(&mut conn, user_id, *course_id).await?; + let email_subscription = if payload.email_subscription { + "subscribed" + } else { + "unsubscribed" + }; + let result = models::marketing_consents::upsert_marketing_consent( &mut conn, *course_id, payload.course_language_groups_id, &user.id, - payload.consent, + email_subscription, + payload.marketing_consent, ) .await?; diff --git a/services/headless-lms/server/src/programs/mailchimp_syncer.rs b/services/headless-lms/server/src/programs/mailchimp_syncer.rs index 38de045adfca..15c37ba93f7f 100644 --- a/services/headless-lms/server/src/programs/mailchimp_syncer.rs +++ b/services/headless-lms/server/src/programs/mailchimp_syncer.rs @@ -1,6 +1,7 @@ use crate::setup_tracing; use dotenv::dotenv; use headless_lms_models::marketing_consents::MarketingMailingListAccessToken; +use headless_lms_models::marketing_consents::UserEmailSubscription; use headless_lms_models::marketing_consents::UserMarketingConsentWithDetails; use headless_lms_utils::http::REQWEST_CLIENT; use serde::Deserialize; @@ -53,20 +54,25 @@ const REQUIRED_FIELDS: &[FieldSchema] = &[ name: "Course ID", default_value: "", }, + FieldSchema { + tag: "LANGGRPID", + name: "Course language Group ID", + default_value: "", + }, FieldSchema { tag: "USERID", name: "User ID", default_value: "", }, FieldSchema { - tag: "LANGGRPID", - name: "Course language Group ID", - default_value: "", + tag: "RESEARCH", + name: "Research consent", + default_value: "false", }, ]; /// These fields are excluded from removing all fields that are not in the schema -const FIELDS_EXCLUDED_FROM_REMOVING: &[&str] = &["PHONE", "PACE", "COUNTRY", "MMERGE9", "RESEARCH"]; +const FIELDS_EXCLUDED_FROM_REMOVING: &[&str] = &["PHONE", "PACE", "COUNTRY", "MMERGE9"]; const SYNC_INTERVAL_SECS: u64 = 10; const PRINT_STILL_RUNNING_MESSAGE_TICKS_THRESHOLD: u32 = 60; @@ -414,15 +420,26 @@ async fn sync_contacts(conn: &mut PgConnection, _config: &SyncerConfig) -> anyho // If there are any successfully synced users, update the database to mark them as synced if !successfully_synced_user_ids.is_empty() { - headless_lms_models::marketing_consents::update_synced_to_mailchimp_at_to_all_synced_users( - conn, - &successfully_synced_user_ids, - ) - .await?; - info!( - "Successfully updated synced status for {} users.", - successfully_synced_user_ids.len() - ); + match headless_lms_models::marketing_consents::update_synced_to_mailchimp_at_to_all_synced_users( + conn, + &successfully_synced_user_ids, + ) + .await + { + Ok(_) => { + info!( + "Successfully updated synced status for {} users.", + successfully_synced_user_ids.len() + ); + } + Err(e) => { + error!( + "Failed to update synced status for {} users: {:?}", + successfully_synced_user_ids.len(), + e + ); + } + } } Ok(()) @@ -441,26 +458,28 @@ pub async fn send_users_to_mailchimp( // Prepare each user's data for Mailchimp for user in &users_details { - let user_details = json!({ - "email_address": user.email, - "status": if user.consent { - "subscribed".to_string() - } else { - user.email_subscription_in_mailchimp.clone().unwrap_or_else(|| "subscribed".to_string()) - }, - "merge_fields": { - "FNAME": user.first_name, - "LNAME": user.last_name, - "MARKETING": if user.consent { "allowed" } else { "disallowed" }, - "LOCALE": user.locale, - "GRADUATED": if user.completed_course.unwrap_or(false) { "passed" } else { "not passed" }, - "USERID": user.user_id, - "COURSEID": user.course_id, - "LANGGRPID": user.course_language_group_id, - }, - }); - users_data_in_json.push(user_details); - user_ids.push(user.id); + // Check user has given permission to send data to mailchimp + if let Some(ref subscription) = user.email_subscription_in_mailchimp { + if subscription == "subscribed" { + let user_details = json!({ + "email_address": user.email, + "status": user.email_subscription_in_mailchimp, + "merge_fields": { + "FNAME": user.first_name, + "LNAME": user.last_name, + "MARKETING": if user.consent { "allowed" } else { "disallowed" }, + "LOCALE": user.locale, + "GRADUATED": if user.completed_course.unwrap_or(false) { "passed" } else { "not passed" }, + "USERID": user.user_id, + "COURSEID": user.course_id, + "LANGGRPID": user.course_language_group_id, + "RESEARCH" : if user.research_consent.unwrap_or(false) { "allowed" } else { "disallowed" }, + }, + }); + users_data_in_json.push(user_details); + user_ids.push(user.id); + } + } } let batch_request = json!({ @@ -529,7 +548,7 @@ pub async fn send_users_to_mailchimp( /// Updates the email addresses of multiple users in a Mailchimp mailing list. async fn update_emails_in_mailchimp( - users: Vec, + users: Vec, list_id: &str, server_prefix: &str, access_token: &str, @@ -539,6 +558,12 @@ async fn update_emails_in_mailchimp( for user in users { if let Some(ref user_mailchimp_id) = user.user_mailchimp_id { + if let Some(ref status) = user.email_subscription_in_mailchimp { + if status != "subscribed" { + continue; // Skip this user if they are not subscribed because Mailchimp only updates emails that are subscribed + } + } + let url = format!( "https://{}.api.mailchimp.com/3.0/lists/{}/members/{}", server_prefix, list_id, user_mailchimp_id @@ -547,7 +572,7 @@ async fn update_emails_in_mailchimp( // Prepare the body for the PUT request let body = serde_json::json!({ "email_address": &user.email, - "status": "subscribed", + "status": &user.email_subscription_in_mailchimp, }); // Update the email diff --git a/services/headless-lms/server/src/ts_binding_generator.rs b/services/headless-lms/server/src/ts_binding_generator.rs index e004f1a0462d..77bf3eb9ad50 100644 --- a/services/headless-lms/server/src/ts_binding_generator.rs +++ b/services/headless-lms/server/src/ts_binding_generator.rs @@ -170,7 +170,7 @@ fn models(target: &mut File) { library::progressing::UserModuleCompletionStatus, library::progressing::UserWithModuleCompletions, - marketing_consent::UserMarketingConsent, + marketing_consents::UserMarketingConsent, material_references::MaterialReference, material_references::NewMaterialReference, diff --git a/shared-module/packages/common/src/bindings.guard.ts b/shared-module/packages/common/src/bindings.guard.ts index 419d81ba9f92..eb9a8f2bc68d 100644 --- a/shared-module/packages/common/src/bindings.guard.ts +++ b/shared-module/packages/common/src/bindings.guard.ts @@ -2202,11 +2202,17 @@ export function isUserMarketingConsent(obj: unknown): obj is UserMarketingConsen ((typedObj !== null && typeof typedObj === "object") || typeof typedObj === "function") && typeof typedObj["id"] === "string" && typeof typedObj["course_id"] === "string" && + typeof typedObj["course_language_group_id"] === "string" && typeof typedObj["user_id"] === "string" && + (typedObj["user_mailchimp_id"] === null || typeof typedObj["user_mailchimp_id"] === "string") && typeof typedObj["consent"] === "boolean" && + (typedObj["email_subscription_in_mailchimp"] === null || + typeof typedObj["email_subscription_in_mailchimp"] === "string") && typeof typedObj["created_at"] === "string" && typeof typedObj["updated_at"] === "string" && - (typedObj["deleted_at"] === null || typeof typedObj["deleted_at"] === "string") + (typedObj["deleted_at"] === null || typeof typedObj["deleted_at"] === "string") && + (typedObj["synced_to_mailchimp_at"] === null || + typeof typedObj["synced_to_mailchimp_at"] === "string") ) } diff --git a/shared-module/packages/common/src/bindings.ts b/shared-module/packages/common/src/bindings.ts index 060ccd271a4a..fd4786759a40 100644 --- a/shared-module/packages/common/src/bindings.ts +++ b/shared-module/packages/common/src/bindings.ts @@ -1156,11 +1156,15 @@ export interface UserWithModuleCompletions { export interface UserMarketingConsent { id: string course_id: string + course_language_group_id: string user_id: string + user_mailchimp_id: string | null consent: boolean + email_subscription_in_mailchimp: string | null created_at: string updated_at: string deleted_at: string | null + synced_to_mailchimp_at: string | null } export interface MaterialReference { diff --git a/shared-module/packages/common/src/locales/en/course-material.json b/shared-module/packages/common/src/locales/en/course-material.json index e80672c4f7cd..31d87f878d38 100644 --- a/shared-module/packages/common/src/locales/en/course-material.json +++ b/shared-module/packages/common/src/locales/en/course-material.json @@ -123,6 +123,8 @@ "map-disclaimer": "*On the map, you'll find the breakdown of students per country. Other students will only see the total student count for your country.", "map-instruction": "To begin, please your country of residence. Once you've made your selection, a map will display the countries where fellow students are living.", "map-tooltip-students-in-country": "{{country}} - {{count}} students", + "marketing-consent-checkbox-text": "I am ok with receiving updates about upcoming language versions and information regarding new courses. I agree to share my contact information in order to receive tailored messages on third party platforms. Read more in our Privacy Policy.", + "marketing-consent-privacy-policy-checkbox-text": "I accept the Privacy Policy and Terms of Service.", "max-points": "Max points", "max-score-n-marks": "Max score: <2>{{marks}} marks", "message-already-on-different-language-version": "You're already on a different language version of this course. Before answering any exercises, please return to <1>{{name}} or change your active language in the course settings.", diff --git a/shared-module/packages/common/src/locales/uk/course-material.json b/shared-module/packages/common/src/locales/uk/course-material.json index 954b4eb2521f..d7d86d69e692 100644 --- a/shared-module/packages/common/src/locales/uk/course-material.json +++ b/shared-module/packages/common/src/locales/uk/course-material.json @@ -125,6 +125,8 @@ "map-disclaimer": "*На карті ви знайдете розподіл студентів по країнах. Інші студенти бачитимуть лише загальну кількість студентів у вашій країні.", "map-instruction": "Для початку вкажіть свою країну проживання. Після того, як ви зробите свій вибір, на карті з’являться країни, де проживають однокурсники.", "map-tooltip-students-in-country": "{{country}} - {{count}} студентів", + "marketing-consent-checkbox-text": "Я згоден отримувати оновлення про майбутні мовні версії та інформацію про нові курси. Я згоден надати свою контактну інформацію для отримання персоналізованих повідомлень на сторонніх платформах. Дізнайтеся більше в нашій Політиці конфіденційності.", + "marketing-consent-privacy-policy-checkbox-text": "Я приймаю Політику конфіденційності та Умови надання послуг.", "max-points": "Максимальна кількість балів", "max-score-n-marks": "Максимальний бал: <2>{{marks}} бали", "message-already-on-different-language-version": "Ви вже використовуєте іншу мовну версію цього курсу. Перш ніж відповідати на вправи, будь ласка, поверніться до <1>{{name}} або змініть активну мову в налаштуваннях курсу.", From b7b41aded26feed41b065674d8ce1cc956d09a3d Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Mon, 9 Dec 2024 14:53:39 +0200 Subject: [PATCH 12/16] Eslint fixes --- .../ContentRenderer/core/formatting/TableBlock.tsx | 6 +++--- .../ContentRenderer/moocfi/ExerciseBlock/ExerciseTask.tsx | 2 +- services/main-frontend/src/pages/manage/regradings/[id].tsx | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/services/course-material/src/components/ContentRenderer/core/formatting/TableBlock.tsx b/services/course-material/src/components/ContentRenderer/core/formatting/TableBlock.tsx index a68f60ca48b2..5f8345eabcd7 100644 --- a/services/course-material/src/components/ContentRenderer/core/formatting/TableBlock.tsx +++ b/services/course-material/src/components/ContentRenderer/core/formatting/TableBlock.tsx @@ -83,7 +83,7 @@ const TableBlock: React.FC< rowSpan={stringToNumberOrPlaceholder(cell.rowspan, undefined)} dangerouslySetInnerHTML={{ __html: parseText( - cell.content !== "" ? cell.content ?? "" : "", + cell.content !== "" ? (cell.content ?? "") : "", terms, ).parsedText, }} @@ -105,7 +105,7 @@ const TableBlock: React.FC< rowSpan={stringToNumberOrPlaceholder(cell.rowspan, undefined)} dangerouslySetInnerHTML={{ __html: parseText( - cell.content !== "" ? cell.content ?? "" : "", + cell.content !== "" ? (cell.content ?? "") : "", terms, ).parsedText, }} @@ -127,7 +127,7 @@ const TableBlock: React.FC< rowSpan={stringToNumberOrPlaceholder(cell.rowspan, undefined)} dangerouslySetInnerHTML={{ __html: parseText( - cell.content !== "" ? cell.content ?? "" : "", + cell.content !== "" ? (cell.content ?? "") : "", terms, ).parsedText, }} diff --git a/services/course-material/src/components/ContentRenderer/moocfi/ExerciseBlock/ExerciseTask.tsx b/services/course-material/src/components/ContentRenderer/moocfi/ExerciseBlock/ExerciseTask.tsx index 80fe64478af7..6251c2bd3176 100644 --- a/services/course-material/src/components/ContentRenderer/moocfi/ExerciseBlock/ExerciseTask.tsx +++ b/services/course-material/src/components/ContentRenderer/moocfi/ExerciseBlock/ExerciseTask.tsx @@ -51,7 +51,7 @@ const ExerciseTask: React.FC> = ({ const feedbackText = postThisStateToIFrame.view_type === "view-submission" - ? postThisStateToIFrame.data.grading?.feedback_text ?? null + ? (postThisStateToIFrame.data.grading?.feedback_text ?? null) : null const cannotAnswerButNoSubmission = !canPostSubmission && !exerciseTask.previous_submission && signedIn diff --git a/services/main-frontend/src/pages/manage/regradings/[id].tsx b/services/main-frontend/src/pages/manage/regradings/[id].tsx index 4644c8c0d507..90cf59ee3635 100644 --- a/services/main-frontend/src/pages/manage/regradings/[id].tsx +++ b/services/main-frontend/src/pages/manage/regradings/[id].tsx @@ -148,7 +148,7 @@ const ViewRegradingPage: React.FC> = () => { {si.grading_before_regrading.score_given ?? "null"} {si.grading_after_regrading - ? si.grading_after_regrading.score_given ?? "null" + ? (si.grading_after_regrading.score_given ?? "null") : "null"} From 6d0f6cedcaa14f5f05a475c656e7fb0e43551e87 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 11 Dec 2024 10:24:25 +0200 Subject: [PATCH 13/16] Add kubernetes configs --- .../base/headless-lms/mailchimp-syncer.yml | 54 +++++++++++++++++++ kubernetes/base/kustomization.yaml | 1 + 2 files changed, 55 insertions(+) create mode 100644 kubernetes/base/headless-lms/mailchimp-syncer.yml diff --git a/kubernetes/base/headless-lms/mailchimp-syncer.yml b/kubernetes/base/headless-lms/mailchimp-syncer.yml new file mode 100644 index 000000000000..58f729a72798 --- /dev/null +++ b/kubernetes/base/headless-lms/mailchimp-syncer.yml @@ -0,0 +1,54 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: mailchimp-syncer + labels: + app: mailchimp-syncer + deploymentType: with-init-container + needs-db: "true" +spec: + replicas: 1 + selector: + matchLabels: + app: mailchimp-syncer + template: + metadata: + annotations: + linkerd.io/inject: enabled + labels: + app: mailchimp-syncer + spec: + containers: + - name: mailchimp-syncer + image: headless-lms + command: ["bin/run", "mailchimp-syncer"] + resources: + requests: + memory: 100Mi + cpu: 20m + limits: + memory: 300Mi + cpu: 200m + envFrom: + - secretRef: + name: headless-lms-secrets + initContainers: + - name: headless-lms-wait-for-db + image: headless-lms + command: + - bash + - "-c" + - | + echo Waiting for postgres to be available + timeout 120 ./wait-for-db.sh + ./wait-for-db-migrations.sh + resources: + requests: + memory: 100Mi + cpu: 20m + limits: + memory: 300Mi + cpu: 200m + envFrom: + - secretRef: + name: headless-lms-secrets diff --git a/kubernetes/base/kustomization.yaml b/kubernetes/base/kustomization.yaml index 5e5dcd809426..9bb5435f8481 100644 --- a/kubernetes/base/kustomization.yaml +++ b/kubernetes/base/kustomization.yaml @@ -18,3 +18,4 @@ resources: - headless-lms/peer-review-updater.yml - headless-lms/sync-tmc-users.yml - headless-lms/chatbot-syncer.yml + - headless-lms/mailchimp-syncer.yml From f71eac00840967d11d0ce5b4292693bd3ef081aa Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 11 Dec 2024 10:30:39 +0200 Subject: [PATCH 14/16] Add configuration to control removal of unsupported Mailchimp fields --- .../server/src/programs/mailchimp_syncer.rs | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/services/headless-lms/server/src/programs/mailchimp_syncer.rs b/services/headless-lms/server/src/programs/mailchimp_syncer.rs index 15c37ba93f7f..d863308c2d71 100644 --- a/services/headless-lms/server/src/programs/mailchimp_syncer.rs +++ b/services/headless-lms/server/src/programs/mailchimp_syncer.rs @@ -73,6 +73,7 @@ const REQUIRED_FIELDS: &[FieldSchema] = &[ /// These fields are excluded from removing all fields that are not in the schema const FIELDS_EXCLUDED_FROM_REMOVING: &[&str] = &["PHONE", "PACE", "COUNTRY", "MMERGE9"]; +const REMOVE_UNSUPPORTED_FIELDS: bool = false; const SYNC_INTERVAL_SECS: u64 = 10; const PRINT_STILL_RUNNING_MESSAGE_TICKS_THRESHOLD: u32 = 60; @@ -169,20 +170,26 @@ async fn ensure_mailchimp_schema( let existing_fields = fetch_current_mailchimp_fields(list_id, server_prefix, access_token).await?; - // Remove extra fields not in REQUIRED_FIELDS or FIELDS_EXCLUDED_FROM_REMOVING - for field in existing_fields.iter() { - if !REQUIRED_FIELDS - .iter() - .any(|r| r.tag == field.field_name.as_str()) - && !FIELDS_EXCLUDED_FROM_REMOVING.contains(&field.field_name.as_str()) - { - if let Err(e) = - remove_field_from_mailchimp(list_id, &field.field_id, server_prefix, access_token) - .await + if REMOVE_UNSUPPORTED_FIELDS { + // Remove extra fields not in REQUIRED_FIELDS or FIELDS_EXCLUDED_FROM_REMOVING + for field in existing_fields.iter() { + if !REQUIRED_FIELDS + .iter() + .any(|r| r.tag == field.field_name.as_str()) + && !FIELDS_EXCLUDED_FROM_REMOVING.contains(&field.field_name.as_str()) { - warn!("Could not remove field '{}': {}", field.field_name, e); - } else { - info!("Removed field '{}'", field.field_name); + if let Err(e) = remove_field_from_mailchimp( + list_id, + &field.field_id, + server_prefix, + access_token, + ) + .await + { + warn!("Could not remove field '{}': {}", field.field_name, e); + } else { + info!("Removed field '{}'", field.field_name); + } } } } From e93b0ca14c28300b3318569ab20f324859b203a4 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 11 Dec 2024 11:24:45 +0200 Subject: [PATCH 15/16] Add wait timeout for course language change test --- system-tests/src/tests/change-course-language.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/system-tests/src/tests/change-course-language.spec.ts b/system-tests/src/tests/change-course-language.spec.ts index 3c30b31b2710..181e7adb05bf 100644 --- a/system-tests/src/tests/change-course-language.spec.ts +++ b/system-tests/src/tests/change-course-language.spec.ts @@ -57,6 +57,8 @@ test("Changing course language works", async ({ page, headless }, testInfo) => { await page.getByText("Choose your preferred language").first().waitFor() await page.getByText("Default").first().click() + // eslint-disable-next-line playwright/no-wait-for-timeout + await page.waitForTimeout(200) await page.getByRole("button", { name: "Continue" }).click() await page.getByRole("heading", { name: "Course overview" }).waitFor() From 1bd385883e8a2998678c3019374b7f820fdd4dc4 Mon Sep 17 00:00:00 2001 From: Henrik Nygren Date: Wed, 11 Dec 2024 11:43:14 +0200 Subject: [PATCH 16/16] Refactor course language change test to improve button interaction handling --- system-tests/src/tests/change-course-language.spec.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/system-tests/src/tests/change-course-language.spec.ts b/system-tests/src/tests/change-course-language.spec.ts index 181e7adb05bf..cb353934720c 100644 --- a/system-tests/src/tests/change-course-language.spec.ts +++ b/system-tests/src/tests/change-course-language.spec.ts @@ -59,7 +59,14 @@ test("Changing course language works", async ({ page, headless }, testInfo) => { await page.getByText("Default").first().click() // eslint-disable-next-line playwright/no-wait-for-timeout await page.waitForTimeout(200) - await page.getByRole("button", { name: "Continue" }).click() + await page.getByTestId("select-course-instance-continue-button").click() + try { + await page.getByTestId("select-course-instance-continue-button").waitFor({ state: "hidden" }) + } catch (_e) { + await page.getByTestId("select-course-instance-continue-button").click() + await page.getByTestId("select-course-instance-continue-button").waitFor({ state: "hidden" }) + } + await page.getByRole("heading", { name: "Course overview" }).waitFor() await expect(page).toHaveURL(