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 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 && ( +
+ +
+ )}
+ {languageChanged && (
=> return validateResponse(response, isString) } +export const updateMarketingConsent = async ( + courseId: string, + courseLanguageGroupsId: string, + emailSubscription: boolean, + marketingConsent: boolean, +): Promise => { + const res = await courseMaterialClient.post( + `/courses/${courseId}/user-marketing-consent`, + { + course_language_groups_id: courseLanguageGroupsId, + email_subscription: emailSubscription, + marketing_consent: marketingConsent, + }, + { + 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) +} + export const fetchPartnersBlock = async (courseId: string): Promise => { const response = await courseMaterialClient.get(`/courses/${courseId}/partners-block`) return validateResponse(response, isPartnersBlock) 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 new file mode 100644 index 000000000000..677379c8bf5a --- /dev/null +++ b/services/headless-lms/migrations/20241023104801_add-marketing-consent.down.sql @@ -0,0 +1,3 @@ +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 new file mode 100644 index 000000000000..52b0c106ae64 --- /dev/null +++ b/services/headless-lms/migrations/20241023104801_add-marketing-consent.up.sql @@ -0,0 +1,62 @@ +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_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, + 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, + synced_to_mailchimp_at TIMESTAMP WITH TIME ZONE, + CONSTRAINT course_language_group_specific_marketing_user_uniqueness UNIQUE NULLS NOT DISTINCT(user_id, course_language_group_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.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 '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.'; +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.'; + + +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_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, + 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_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'; +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-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json b/services/headless-lms/models/.sqlx/query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json new file mode 100644 index 000000000000..8c49e3cf316e --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856.json @@ -0,0 +1,68 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT *\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": "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" + } + ], + "parameters": { + "Left": ["Uuid", "Uuid"] + }, + "nullable": [false, false, false, false, true, false, true, false, false, true, true] + }, + "hash": "0a881c84d211755c24c03586b0d5be217790808cb8751ae50d121321f0be9856" +} diff --git a/services/headless-lms/models/.sqlx/query-3036b60a162754931a2128c92caf88b9e98e50393c53dc95ac919e7065eb4a65.json b/services/headless-lms/models/.sqlx/query-3036b60a162754931a2128c92caf88b9e98e50393c53dc95ac919e7065eb4a65.json new file mode 100644 index 000000000000..d98a5b8f775b --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-3036b60a162754931a2128c92caf88b9e98e50393c53dc95ac919e7065eb4a65.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_marketing_consents (user_id, course_id, course_language_group_id, consent, email_subscription_in_mailchimp)\n VALUES ($1, $2, $3, $4, $5)\n ON CONFLICT (user_id, course_language_group_id)\n DO UPDATE\n SET\n consent = $4,\n email_subscription_in_mailchimp = $5\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + } + ], + "parameters": { + "Left": ["Uuid", "Uuid", "Uuid", "Bool", "Varchar"] + }, + "nullable": [false] + }, + "hash": "3036b60a162754931a2128c92caf88b9e98e50393c53dc95ac919e7065eb4a65" +} 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-6f9c9d6f61094b88673acaa9d820bc13fd0bbc0f7d52c0e3714ba77e0c81280e.json b/services/headless-lms/models/.sqlx/query-6f9c9d6f61094b88673acaa9d820bc13fd0bbc0f7d52c0e3714ba77e0c81280e.json new file mode 100644 index 000000000000..eede92b41fca --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-6f9c9d6f61094b88673acaa9d820bc13fd0bbc0f7d52c0e3714ba77e0c81280e.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n umc.user_id,\n u.email AS email,\n umc.email_subscription_in_mailchimp,\n umc.user_mailchimp_id\n FROM user_marketing_consents AS umc\n JOIN user_details AS u ON u.user_id = umc.user_id\n WHERE umc.course_language_group_id = $1\n AND umc.synced_to_mailchimp_at < u.updated_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "email_subscription_in_mailchimp", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "user_mailchimp_id", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [false, false, true, true] + }, + "hash": "6f9c9d6f61094b88673acaa9d820bc13fd0bbc0f7d52c0e3714ba77e0c81280e" +} 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-8b8d471ed21eb709b5332c5bb4ab3460a7f3089988d959d05fba690f17d9d456.json b/services/headless-lms/models/.sqlx/query-8b8d471ed21eb709b5332c5bb4ab3460a7f3089988d959d05fba690f17d9d456.json new file mode 100644 index 000000000000..813e67388e30 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-8b8d471ed21eb709b5332c5bb4ab3460a7f3089988d959d05fba690f17d9d456.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "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": [ + { + "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": "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": "8b8d471ed21eb709b5332c5bb4ab3460a7f3089988d959d05fba690f17d9d456" +} 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-a6659b3a5772ec77fc86b1a7e6abf2f884ee622559d13dfa0d47261cc9f8d4a2.json b/services/headless-lms/models/.sqlx/query-a6659b3a5772ec77fc86b1a7e6abf2f884ee622559d13dfa0d47261cc9f8d4a2.json new file mode 100644 index 000000000000..b297e6c6c716 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-a6659b3a5772ec77fc86b1a7e6abf2f884ee622559d13dfa0d47261cc9f8d4a2.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "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": { + "Left": ["UuidArray"] + }, + "nullable": [] + }, + "hash": "a6659b3a5772ec77fc86b1a7e6abf2f884ee622559d13dfa0d47261cc9f8d4a2" +} 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-bbec51d086e46c8b3e4e547fc88ad8ed5ddd43db737ec99e48391668646d49f9.json b/services/headless-lms/models/.sqlx/query-bbec51d086e46c8b3e4e547fc88ad8ed5ddd43db737ec99e48391668646d49f9.json new file mode 100644 index 000000000000..c44cc5a953e0 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-bbec51d086e46c8b3e4e547fc88ad8ed5ddd43db737ec99e48391668646d49f9.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.consent = true\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": "bbec51d086e46c8b3e4e547fc88ad8ed5ddd43db737ec99e48391668646d49f9" +} 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-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/.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-f52994d7e2e279a1a5f31d4486f6911035d1d91ddcb9d346df8aa848c0b29ec7.json b/services/headless-lms/models/.sqlx/query-f52994d7e2e279a1a5f31d4486f6911035d1d91ddcb9d346df8aa848c0b29ec7.json new file mode 100644 index 000000000000..c16644ad9843 --- /dev/null +++ b/services/headless-lms/models/.sqlx/query-f52994d7e2e279a1a5f31d4486f6911035d1d91ddcb9d346df8aa848c0b29ec7.json @@ -0,0 +1,122 @@ +{ + "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 COALESCE(csfa.research_consent, urc.research_consent) AS research_consent\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 LEFT JOIN course_specific_consent_form_answers AS csfa\n ON csfa.course_id = umc.course_id AND csfa.user_id = umc.user_id\n LEFT JOIN user_research_consents AS urc\n ON urc.user_id = umc.user_id\n WHERE umc.course_language_group_id = $1\n AND (\n umc.synced_to_mailchimp_at IS NULL\n OR umc.synced_to_mailchimp_at < umc.updated_at\n OR csfa.updated_at > umc.synced_to_mailchimp_at\n OR urc.updated_at > umc.synced_to_mailchimp_at\n)\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" + }, + { + "ordinal": 17, + "name": "research_consent", + "type_info": "Bool" + } + ], + "parameters": { + "Left": ["Uuid"] + }, + "nullable": [ + false, + false, + false, + false, + true, + false, + true, + false, + false, + true, + true, + true, + true, + false, + false, + false, + null, + null + ] + }, + "hash": "f52994d7e2e279a1a5f31d4486f6911035d1d91ddcb9d346df8aa848c0b29ec7" +} 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 d4461c6a729c..d82dc0926a30 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_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/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_consents.rs b/services/headless-lms/models/src/marketing_consents.rs new file mode 100644 index 000000000000..952c0dc309d2 --- /dev/null +++ b/services/headless-lms/models/src/marketing_consents.rs @@ -0,0 +1,330 @@ +use itertools::multiunzip; + +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_group_id: Uuid, + 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>, + 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_group_id: Uuid, + 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>, + pub synced_to_mailchimp_at: Option>, + pub first_name: Option, + pub last_name: Option, + pub email: String, + 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, + pub course_language_group_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_group_id: Uuid, + user_id: &Uuid, + 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, email_subscription_in_mailchimp) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_id, course_language_group_id) + DO UPDATE + SET + consent = $4, + email_subscription_in_mailchimp = $5 + RETURNING id + "#, + user_id, + course_id, + course_language_group_id, + marketing_consent, + email_subscription + ) + .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 * + 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_group_id( + conn: &mut PgConnection, + course_language_group_id: Uuid, +) -> sqlx::Result> { + let result = sqlx::query_as!( + UserMarketingConsentWithDetails, + " + 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, + 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 + OR csfa.updated_at > umc.synced_to_mailchimp_at + OR urc.updated_at > umc.synced_to_mailchimp_at +) + ", + course_language_group_id + ) + .fetch_all(conn) + .await?; + + Ok(result) +} + +/// 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> { + let result = sqlx::query_as!( + UserEmailSubscription, + " + SELECT + umc.user_id, + u.email AS email, + 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 + WHERE umc.course_language_group_id = $1 + AND umc.synced_to_mailchimp_at < u.updated_at + ", + course_language_group_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 user_id IN ( + SELECT UNNEST($1::uuid []) + ) +", + &ids + ) + .execute(conn) + .await?; + 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_raw, user_mailchimp_ids): (Vec<_>, Vec<_>) = + user_contact_pairs.into_iter().unzip(); + + // 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 to false in bulk using Mailchimp data. +pub async fn update_unsubscribed_users_from_mailchimp_in_bulk( + conn: &mut PgConnection, + mailchimp_data: Vec<(String, String, String, String)>, +) -> anyhow::Result<()> { + 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() + .filter_map(|user_id| Uuid::parse_str(&user_id).ok()) + .collect(); + + let timestamps: Vec> = timestamps_raw + .into_iter() + .filter_map(|ts| { + DateTime::parse_from_rfc3339(&ts) + .ok() + .map(|dt| dt.with_timezone(&Utc)) + }) + .collect(); + + let course_language_group_ids: Vec = course_language_group_ids_raw + .into_iter() + .filter_map(|lang_id| Uuid::parse_str(&lang_id).ok()) + .collect(); + + sqlx::query!( + " + UPDATE user_marketing_consents + 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::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.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 + ", + &user_ids, + ×tamps, + &course_language_group_ids, + &email_subscriptions_in_mailchimp + ) + .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_group_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/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 6755b81bc4a9..20986c2b8b1a 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_consents::UserMarketingConsent; use headless_lms_models::{partner_block::PartnersBlock, privacy_link::PrivacyLink}; use headless_lms_utils::ip_to_country::IpToCountryMapper; use isbot::Bots; @@ -902,6 +903,70 @@ 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 course_language_groups_id: Uuid, + pub email_subscription: bool, + pub marketing_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 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, + email_subscription, + payload.marketing_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_consents::fetch_user_marketing_consent(&mut conn, *course_id, &user.id) + .await + .ok(); + + token.authorized_ok(web::Json(result)) +} + /** GET /courses/:course_id/partners_blocks - Gets a partners block related to a course */ @@ -1023,5 +1088,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/mailchimp_syncer.rs b/services/headless-lms/server/src/programs/mailchimp_syncer.rs new file mode 100644 index 000000000000..d863308c2d71 --- /dev/null +++ b/services/headless-lms/server/src/programs/mailchimp_syncer.rs @@ -0,0 +1,710 @@ +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; +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, +} + +#[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: "LANGGRPID", + name: "Course language Group ID", + default_value: "", + }, + FieldSchema { + tag: "USERID", + name: "User ID", + default_value: "", + }, + FieldSchema { + 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"]; +const REMOVE_UNSUPPORTED_FIELDS: bool = false; + +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?; + + 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()) + { + 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.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.name, e + ); + } else { + info!( + "Successfully added required field '{}'", + required_field.name + ); + } + } else { + info!( + "Field '{}' already exists, skipping addition.", + required_field.name + ); + } + } + + 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.")) + } +} + +/// Adds a new merge field to the Mailchimp list. +async fn add_field_to_mailchimp( + list_id: &str, + field_schema: &FieldSchema, + 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_schema.tag, + "name": field_schema.name, + "type": "text", + "default_value": field_schema.default_value, + }); + + 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?; + + 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_unsubscribed_users_from_mailchimp_in_chunks( + &token.mailchimp_mailing_list_id, + &token.server_prefix, + &token.access_token, + 1000, + ) + .await?; + + info!( + "Processing Mailchimp data for list: {}", + token.mailchimp_mailing_list_id + ); + + 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_group_id, + ) + .await?; + + info!( + "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() { + 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_group_id( + conn, + token.course_language_group_id, + ) + .await?; + + info!( + "Found {} unsynced user consent(s) for course language group: {}", + unsynced_users_details.len(), + token.course_language_group_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() { + 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(()) +} + +/// 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 { + // 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!({ + "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 { + 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 + ); + + // Prepare the body for the PUT request + let body = serde_json::json!({ + "email_address": &user.email, + "status": &user.email_subscription_in_mailchimp, + }); + + // 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); + } + } else { + continue; + } + } + + if !failed_user_ids.is_empty() { + info!("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_unsubscribed_users_from_mailchimp_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&status=unsubscribed,non-subscribed", + server_prefix, list_id, offset, chunk_size + ); + + let response = REQWEST_CLIENT + .get(&url) + .header("Authorization", format!("apikey {}", 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_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_group_id.is_empty() { + all_data.push(( + user_id.to_string(), + last_changed.to_string(), + language_group_id.to_string(), + status.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; + } + + Ok(all_data) +} + +const BATCH_SIZE: usize = 1000; + +async fn process_unsubscribed_users_from_mailchimp( + conn: &mut PgConnection, + mailchimp_data: Vec<(String, String, 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_unsubscribed_users_from_mailchimp_in_bulk( + conn, + chunk.to_vec(), + ) + .await + { + error!( + "Error while processing chunk {}/{}: ", + (total_records + BATCH_SIZE - 1) / BATCH_SIZE, + e + ); + } + } + + Ok(()) +} 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; 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 db5f63f04e10..a9c2632cd2d0 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_consents::UserMarketingConsent, + material_references::MaterialReference, material_references::NewMaterialReference, 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( 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..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 @@ -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,15 @@ const UpdateCourseForm: React.FC> checked={joinableByCodeOnlyStatus} /> + + { + setAskMarketingConsent(!askMarketingConsent) + }} + checked={askMarketingConsent} + /> +