@fluid-app/fluid-cli-localization 0.1.5 → 0.1.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +65 -13
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +506 -2
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
package/dist/index.mjs
CHANGED
|
@@ -628,6 +628,433 @@ function complianceView(countryCode, atlas) {
|
|
|
628
628
|
};
|
|
629
629
|
}
|
|
630
630
|
//#endregion
|
|
631
|
+
//#region src/countries/forms.ts
|
|
632
|
+
const FORMS_PATH = "/api/forms";
|
|
633
|
+
const PER_PAGE$2 = 100;
|
|
634
|
+
const MAX_PAGES$3 = 200;
|
|
635
|
+
/** The title of the enrollment form the atlas seeds for a country, the same
|
|
636
|
+
* title the admin gives it. */
|
|
637
|
+
const enrollmentFormTitle = (countryName) => `Enrollment - ${countryName}`;
|
|
638
|
+
/** The member types an atlas field can be required for. */
|
|
639
|
+
const AUDIENCES = [
|
|
640
|
+
"rep",
|
|
641
|
+
"preferred",
|
|
642
|
+
"customer"
|
|
643
|
+
];
|
|
644
|
+
/**
|
|
645
|
+
* Each atlas `component` (a form-builder component name) and the element it
|
|
646
|
+
* is stored as. Identity fields are `input` elements named by `puck_type`.
|
|
647
|
+
*/
|
|
648
|
+
const COMPONENT_MAPPINGS = {
|
|
649
|
+
LegalName: {
|
|
650
|
+
element_type: "input",
|
|
651
|
+
puck_type: "legal_name",
|
|
652
|
+
text: true
|
|
653
|
+
},
|
|
654
|
+
FullName: {
|
|
655
|
+
element_type: "input",
|
|
656
|
+
puck_type: "full_name",
|
|
657
|
+
text: true
|
|
658
|
+
},
|
|
659
|
+
Email: {
|
|
660
|
+
element_type: "input",
|
|
661
|
+
puck_type: "email",
|
|
662
|
+
text: true
|
|
663
|
+
},
|
|
664
|
+
PhoneNumber: {
|
|
665
|
+
element_type: "input",
|
|
666
|
+
puck_type: "phone_number",
|
|
667
|
+
text: true
|
|
668
|
+
},
|
|
669
|
+
DateOfBirth: {
|
|
670
|
+
element_type: "input",
|
|
671
|
+
puck_type: "date_of_birth",
|
|
672
|
+
sensitive: true
|
|
673
|
+
},
|
|
674
|
+
ShortTextEntry: {
|
|
675
|
+
element_type: "short_text_entry",
|
|
676
|
+
puck_type: "short_text_entry",
|
|
677
|
+
text: true
|
|
678
|
+
},
|
|
679
|
+
LongTextEntry: {
|
|
680
|
+
element_type: "long_text_entry",
|
|
681
|
+
puck_type: "long_text_entry",
|
|
682
|
+
text: true
|
|
683
|
+
},
|
|
684
|
+
NumberEntry: {
|
|
685
|
+
element_type: "number_entry",
|
|
686
|
+
puck_type: "number_entry",
|
|
687
|
+
text: true
|
|
688
|
+
},
|
|
689
|
+
YesNo: {
|
|
690
|
+
element_type: "yes_no_entry",
|
|
691
|
+
puck_type: "yes_no_entry"
|
|
692
|
+
},
|
|
693
|
+
MultipleChoice: {
|
|
694
|
+
element_type: "multiple_choice_entry",
|
|
695
|
+
puck_type: "multiple_choice_entry"
|
|
696
|
+
},
|
|
697
|
+
FileUpload: {
|
|
698
|
+
element_type: "file_upload",
|
|
699
|
+
puck_type: "file_upload"
|
|
700
|
+
},
|
|
701
|
+
Checkbox: {
|
|
702
|
+
element_type: "checkbox",
|
|
703
|
+
puck_type: "checkbox"
|
|
704
|
+
}
|
|
705
|
+
};
|
|
706
|
+
/** Components an enrollment form must not collect. The repository's review
|
|
707
|
+
* rules treat a form that collects SSNs as critical, so the admin never
|
|
708
|
+
* seeds one either. */
|
|
709
|
+
const EXCLUDED_COMPONENTS = new Set(["SocialSecurityNumber"]);
|
|
710
|
+
/** Identity components: a form with any one of them covers the atlas field,
|
|
711
|
+
* whatever its label. Other components match by label. */
|
|
712
|
+
const IDENTITY_PUCK_TYPES = new Set([
|
|
713
|
+
"legal_name",
|
|
714
|
+
"full_name",
|
|
715
|
+
"email",
|
|
716
|
+
"phone_number",
|
|
717
|
+
"date_of_birth",
|
|
718
|
+
"ssn"
|
|
719
|
+
]);
|
|
720
|
+
/** The element types an existing component may be stored as, older names
|
|
721
|
+
* included, each mapped to the `puck_type` this module compares. */
|
|
722
|
+
const STORED_TYPE_ALIASES = {
|
|
723
|
+
legal_name: "legal_name",
|
|
724
|
+
full_name: "full_name",
|
|
725
|
+
email: "email",
|
|
726
|
+
phone_number: "phone_number",
|
|
727
|
+
date_of_birth: "date_of_birth",
|
|
728
|
+
ssn: "ssn",
|
|
729
|
+
short_text_entry: "short_text_entry",
|
|
730
|
+
text: "short_text_entry",
|
|
731
|
+
long_text_entry: "long_text_entry",
|
|
732
|
+
number_entry: "number_entry",
|
|
733
|
+
number: "number_entry",
|
|
734
|
+
yes_no_entry: "yes_no_entry",
|
|
735
|
+
yes_no: "yes_no_entry",
|
|
736
|
+
multiple_choice_entry: "multiple_choice_entry",
|
|
737
|
+
multiple_choice: "multiple_choice_entry",
|
|
738
|
+
file_upload: "file_upload",
|
|
739
|
+
checkbox: "checkbox"
|
|
740
|
+
};
|
|
741
|
+
/**
|
|
742
|
+
* Labels of data an enrollment form must not collect: SSNs, national and tax
|
|
743
|
+
* ID numbers, bank and card details, and passwords. The repository's review
|
|
744
|
+
* rules treat a form that collects any of them as critical, and the atlas
|
|
745
|
+
* names them in the label (a `ShortTextEntry` labelled "CPF" or "IBAN"), not
|
|
746
|
+
* in the component, so the label is checked too, on every component that
|
|
747
|
+
* takes a value: a respondent types what the label asks for, whatever the
|
|
748
|
+
* element type. A false match only leaves a field to a person.
|
|
749
|
+
*/
|
|
750
|
+
const RESTRICTED_DATA_LABEL = /\b(SSN|EIN|ITIN|CPF|CNPJ|CURP|RFC|RUC|RUT|NIT|NIF|NIE|NIP|TIN|TPIN|PAN|INN|OIB|CUIT|CUIL|NPWP|IRD|UTR|KRA|IBAN|SWIFT|BIC|CVV|CVC|VAT|GST|GSTIN|ABN|TRN|NTN)\b|tax|fiscal|steuer|social security|national (id|identity|insurance)|identity (number|card|document)|government id|passport|personnummer|id number|bank|routing|sort code|account number|e-?wallet|card number|credit card|debit card|password|passcode/i;
|
|
751
|
+
/** Components whose answer is yes or no, so they collect no number
|
|
752
|
+
* whatever their label says: a yes/no about tax registration is fine. Every
|
|
753
|
+
* other component takes a value, and its label decides. */
|
|
754
|
+
const BOOLEAN_COMPONENTS = new Set(["YesNo", "Checkbox"]);
|
|
755
|
+
/** Whether an atlas field asks for data an enrollment form must not
|
|
756
|
+
* collect, by component or by label. */
|
|
757
|
+
function collectsRestrictedData(field) {
|
|
758
|
+
return EXCLUDED_COMPONENTS.has(field.component) || !BOOLEAN_COMPONENTS.has(field.component) && RESTRICTED_DATA_LABEL.test(field.label ?? "");
|
|
759
|
+
}
|
|
760
|
+
/** The choices of a MultipleChoice field: strings or `{ label }`. */
|
|
761
|
+
function choicesOf(field) {
|
|
762
|
+
const options = field["options"];
|
|
763
|
+
if (!Array.isArray(options)) return [];
|
|
764
|
+
return options.flatMap((choice) => {
|
|
765
|
+
const label = typeof choice === "string" ? choice : choice?.label;
|
|
766
|
+
return typeof label === "string" && label.trim() ? [label.trim()] : [];
|
|
767
|
+
});
|
|
768
|
+
}
|
|
769
|
+
/** Whether every member type gets the field. A Post Purchase form is chosen
|
|
770
|
+
* by country only, so a field for some member types would reach everyone. */
|
|
771
|
+
function forEveryone(field) {
|
|
772
|
+
const audiences = field.requiredFor ?? [];
|
|
773
|
+
return audiences.length === 0 || AUDIENCES.every((a) => audiences.includes(a));
|
|
774
|
+
}
|
|
775
|
+
/** Each atlas enrollment field, in atlas order, with the element it maps to
|
|
776
|
+
* and whether the command may create it. */
|
|
777
|
+
function mapAtlasFields(fields) {
|
|
778
|
+
return fields.map((field, index) => ({
|
|
779
|
+
field,
|
|
780
|
+
index
|
|
781
|
+
})).filter(({ field }) => Boolean(field.label?.trim())).sort((a, b) => {
|
|
782
|
+
const oa = a.field.order ?? Number.POSITIVE_INFINITY;
|
|
783
|
+
const ob = b.field.order ?? Number.POSITIVE_INFINITY;
|
|
784
|
+
return oa === ob ? a.index - b.index : oa - ob;
|
|
785
|
+
}).map(({ field, index }) => {
|
|
786
|
+
const mapping = COMPONENT_MAPPINGS[field.component];
|
|
787
|
+
const options = field.component === "MultipleChoice" ? choicesOf(field) : [];
|
|
788
|
+
const excluded = collectsRestrictedData(field) ? "notCollected" : !forEveryone(field) ? "someMemberTypes" : !mapping || field.component === "MultipleChoice" && options.length === 0 ? "needsFormBuilder" : null;
|
|
789
|
+
return {
|
|
790
|
+
id: field.id ?? `field-${index}`,
|
|
791
|
+
label: field.label.trim(),
|
|
792
|
+
component: field.component,
|
|
793
|
+
required: field.required === true,
|
|
794
|
+
order: field.order ?? index,
|
|
795
|
+
requiredFor: field.requiredFor ?? [],
|
|
796
|
+
elementType: mapping?.element_type ?? null,
|
|
797
|
+
puckType: mapping?.puck_type ?? null,
|
|
798
|
+
sensitive: mapping?.sensitive === true,
|
|
799
|
+
excluded,
|
|
800
|
+
options
|
|
801
|
+
};
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
const EXCLUSION_REASONS = {
|
|
805
|
+
notCollected: "Enrollment forms must not collect SSNs, national or tax ID numbers, bank or card details, or passwords. Collect it outside Fluid's forms, e.g. through your tax or payout provider.",
|
|
806
|
+
someMemberTypes: "The atlas asks only some member types for it, and a Post Purchase form reaches everyone who enrolls in the country. Collect it where you can limit it to those member types.",
|
|
807
|
+
needsFormBuilder: "The atlas doesn't give enough to build it (a multiple choice with no choices, or a component the form builder doesn't have). Add it in the form builder."
|
|
808
|
+
};
|
|
809
|
+
/** The component for an atlas field, as the form builder would save it. */
|
|
810
|
+
function componentFor(field) {
|
|
811
|
+
const mapping = COMPONENT_MAPPINGS[field.component];
|
|
812
|
+
if (!mapping || field.excluded) throw new LocalizationError(`Atlas field ${field.id} (${field.component}) isn't one this command creates.`);
|
|
813
|
+
return {
|
|
814
|
+
element_type: mapping.element_type,
|
|
815
|
+
puck_type: mapping.puck_type,
|
|
816
|
+
label: field.label,
|
|
817
|
+
placeholder: "",
|
|
818
|
+
field_data_value: "",
|
|
819
|
+
required: field.required,
|
|
820
|
+
...mapping.text ? { case_sensitive: false } : {},
|
|
821
|
+
sensitive_info: field.sensitive,
|
|
822
|
+
auto_translate: true,
|
|
823
|
+
translations: [],
|
|
824
|
+
show_label: true,
|
|
825
|
+
is_form_field: true,
|
|
826
|
+
...field.component === "MultipleChoice" ? { multiple_choice_options: field.options.map((label, index) => ({
|
|
827
|
+
label,
|
|
828
|
+
id: String(index)
|
|
829
|
+
})) } : {},
|
|
830
|
+
...field.component === "YesNo" ? { yes_no_options: [{
|
|
831
|
+
label: "Yes",
|
|
832
|
+
value: "yes"
|
|
833
|
+
}, {
|
|
834
|
+
label: "No",
|
|
835
|
+
value: "no"
|
|
836
|
+
}] } : {}
|
|
837
|
+
};
|
|
838
|
+
}
|
|
839
|
+
const normalizeLabel = (label) => typeof label === "string" ? label.trim().replace(/\s+/g, " ").toLowerCase() : "";
|
|
840
|
+
/** The `puck_type` an existing component compares as, older types included. */
|
|
841
|
+
function storedType(component) {
|
|
842
|
+
return STORED_TYPE_ALIASES[(typeof component.puck_type === "string" && component.puck_type ? component.puck_type : component.element_type ?? "").toLowerCase()] ?? null;
|
|
843
|
+
}
|
|
844
|
+
/** Whether a component already collects an atlas field: the same identity
|
|
845
|
+
* field, or the same kind of field with the same label. */
|
|
846
|
+
function componentCovers(component, field) {
|
|
847
|
+
if (!field.puckType) return false;
|
|
848
|
+
const type = storedType(component);
|
|
849
|
+
if (type !== field.puckType) return false;
|
|
850
|
+
if (IDENTITY_PUCK_TYPES.has(type)) return true;
|
|
851
|
+
return normalizeLabel(component.label) === normalizeLabel(field.label);
|
|
852
|
+
}
|
|
853
|
+
/** A form's components, parsed. Ids are the stored elements' ids. */
|
|
854
|
+
function componentsOf(form) {
|
|
855
|
+
const raw = form.form_components;
|
|
856
|
+
if (raw == null || raw === "") return [];
|
|
857
|
+
let parsed;
|
|
858
|
+
try {
|
|
859
|
+
parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
860
|
+
} catch {
|
|
861
|
+
throw new LocalizationError(`Form ${form.id} ("${form.title}") has components that aren't valid JSON, so it can't be read safely. Fix it in the form builder.`);
|
|
862
|
+
}
|
|
863
|
+
return Array.isArray(parsed) ? parsed.filter((c) => c && typeof c === "object") : [];
|
|
864
|
+
}
|
|
865
|
+
/** GET /api/forms, every page. */
|
|
866
|
+
async function listForms(client) {
|
|
867
|
+
const forms = [];
|
|
868
|
+
for (let page = 1; page <= MAX_PAGES$3; page += 1) {
|
|
869
|
+
const body = await client.get(FORMS_PATH, {
|
|
870
|
+
page,
|
|
871
|
+
per_page: PER_PAGE$2
|
|
872
|
+
});
|
|
873
|
+
const batch = body.data ?? [];
|
|
874
|
+
forms.push(...batch);
|
|
875
|
+
const next = body.pagination?.next_page;
|
|
876
|
+
const total = body.pagination?.total_pages ?? 1;
|
|
877
|
+
if (batch.length === 0 || next == null && page >= total) return forms;
|
|
878
|
+
}
|
|
879
|
+
throw new LocalizationError(`The company has more than ${MAX_PAGES$3 * PER_PAGE$2} forms, more than one run reads.`);
|
|
880
|
+
}
|
|
881
|
+
/** GET /api/forms/:id, read again just before it's changed. */
|
|
882
|
+
async function getForm(client, id) {
|
|
883
|
+
const body = await client.get(`${FORMS_PATH}/${id}`);
|
|
884
|
+
const form = "form" in body && body.form ? body.form : body;
|
|
885
|
+
if (typeof form?.id !== "number") throw new LocalizationError(`Form ${id} couldn't be read.`);
|
|
886
|
+
return form;
|
|
887
|
+
}
|
|
888
|
+
const isPostPurchase = (form) => form.form_type === "post_purchase";
|
|
889
|
+
const isActive = (form) => form.status === "active";
|
|
890
|
+
const countryIdsOf = (form) => (form.countries ?? []).map((c) => c.id);
|
|
891
|
+
/**
|
|
892
|
+
* The form --create writes to: the form named with --form, else the
|
|
893
|
+
* country's atlas form (titled `Enrollment - <Country>`, Post Purchase,
|
|
894
|
+
* scoped to this country alone), active before draft. A form shared with
|
|
895
|
+
* other countries is never a target: a field one country's atlas asks for
|
|
896
|
+
* doesn't belong on another country's enrollment.
|
|
897
|
+
*/
|
|
898
|
+
function targetForm(forms, country, formId) {
|
|
899
|
+
const own = (form) => {
|
|
900
|
+
const ids = countryIdsOf(form);
|
|
901
|
+
return ids.length === 1 && ids[0] === country.id;
|
|
902
|
+
};
|
|
903
|
+
if (formId !== void 0) {
|
|
904
|
+
const form = forms.find((f) => f.id === formId);
|
|
905
|
+
if (!form) throw new LocalizationError(`The company has no form ${formId}.`);
|
|
906
|
+
if (!isPostPurchase(form)) throw new LocalizationError(`Form ${formId} ("${form.title}") is a ${form.form_type ?? "untyped"} form, not Post Purchase, so enrollment checkout doesn't show it.`);
|
|
907
|
+
if (!own(form)) throw new LocalizationError(`Form ${formId} ("${form.title}") isn't scoped to ${country.name} alone, so this command won't add country fields to it. Pick a form for ${country.name} only, or leave out --form to use or create "${enrollmentFormTitle(country.name)}".`);
|
|
908
|
+
return form;
|
|
909
|
+
}
|
|
910
|
+
const title = enrollmentFormTitle(country.name);
|
|
911
|
+
const candidates = forms.filter((f) => isPostPurchase(f) && own(f) && f.title === title);
|
|
912
|
+
return candidates.find(isActive) ?? [...candidates].sort((a, b) => (b.updated_at ?? "").localeCompare(a.updated_at ?? ""))[0] ?? null;
|
|
913
|
+
}
|
|
914
|
+
/** What the forms say about a country's atlas enrollment fields, and what
|
|
915
|
+
* --create would send. */
|
|
916
|
+
function formsReport(input) {
|
|
917
|
+
const { countryCode, country, atlas, options = {} } = input;
|
|
918
|
+
const fields = mapAtlasFields(atlas.enrollmentFormFields ?? []);
|
|
919
|
+
const forCountry = input.forms.filter((f) => isPostPurchase(f) && countryIdsOf(f).includes(country.id));
|
|
920
|
+
const target = targetForm(input.forms, country, options.formId);
|
|
921
|
+
const parsed = new Map(forCountry.map((f) => [f.id, componentsOf(f)]));
|
|
922
|
+
if (target && !parsed.has(target.id)) parsed.set(target.id, componentsOf(target));
|
|
923
|
+
const covers = (formId, field) => (parsed.get(formId) ?? []).some((c) => componentCovers(c, field));
|
|
924
|
+
const active = forCountry.filter(isActive);
|
|
925
|
+
const rows = fields.map((field) => {
|
|
926
|
+
const collectedBy = active.filter((f) => covers(f.id, field)).map((f) => f.id);
|
|
927
|
+
const { options: _options, excluded, ...rest } = field;
|
|
928
|
+
const state = excluded ? "byHand" : collectedBy.length > 0 ? "collected" : target && covers(target.id, field) ? "inDraft" : "missing";
|
|
929
|
+
return {
|
|
930
|
+
...rest,
|
|
931
|
+
state,
|
|
932
|
+
collectedBy,
|
|
933
|
+
...excluded ? { reason: EXCLUSION_REASONS[excluded] } : {}
|
|
934
|
+
};
|
|
935
|
+
});
|
|
936
|
+
const missing = rows.filter((r) => r.state === "missing");
|
|
937
|
+
const toAdd = fields.filter((f) => missing.some((m) => m.id === f.id && !(options.skipSensitive && f.sensitive)));
|
|
938
|
+
const skippedSensitive = options.skipSensitive ? missing.filter((m) => m.sensitive).map((m) => m.id) : [];
|
|
939
|
+
const title = target?.title ?? enrollmentFormTitle(country.name);
|
|
940
|
+
const wantsActivate = options.activate === true && (target ? !isActive(target) : true);
|
|
941
|
+
const action = toAdd.length > 0 ? target ? "update" : "create" : target && wantsActivate ? "update" : "none";
|
|
942
|
+
const activate = action !== "none" && wantsActivate;
|
|
943
|
+
const plan = {
|
|
944
|
+
action,
|
|
945
|
+
formId: target?.id ?? null,
|
|
946
|
+
title,
|
|
947
|
+
status: (target ? isActive(target) : false) || options.activate ? "active" : "draft",
|
|
948
|
+
add: toAdd.map((f) => ({
|
|
949
|
+
id: f.id,
|
|
950
|
+
label: f.label,
|
|
951
|
+
elementType: f.elementType,
|
|
952
|
+
puckType: f.puckType,
|
|
953
|
+
required: f.required,
|
|
954
|
+
sensitive: f.sensitive
|
|
955
|
+
})),
|
|
956
|
+
skippedSensitive,
|
|
957
|
+
activate
|
|
958
|
+
};
|
|
959
|
+
const report = {
|
|
960
|
+
country: countryCode,
|
|
961
|
+
countryId: country.id,
|
|
962
|
+
countryName: country.name,
|
|
963
|
+
done: missing.length === 0 && rows.every((r) => r.state !== "inDraft"),
|
|
964
|
+
forms: forCountry.map((f) => ({
|
|
965
|
+
id: f.id,
|
|
966
|
+
title: f.title,
|
|
967
|
+
status: f.status ?? null,
|
|
968
|
+
countries: (f.countries ?? []).map((c) => c.iso ?? String(c.id)),
|
|
969
|
+
shared: countryIdsOf(f).length > 1,
|
|
970
|
+
atlasFields: fields.filter((field) => covers(f.id, field)).map((x) => x.id),
|
|
971
|
+
components: parsed.get(f.id)?.length ?? 0
|
|
972
|
+
})),
|
|
973
|
+
target: target ? {
|
|
974
|
+
id: target.id,
|
|
975
|
+
title: target.title,
|
|
976
|
+
status: target.status ?? null
|
|
977
|
+
} : null,
|
|
978
|
+
fields: rows,
|
|
979
|
+
missing: missing.map((m) => m.id),
|
|
980
|
+
plan
|
|
981
|
+
};
|
|
982
|
+
if (fields.length === 0) {
|
|
983
|
+
report.note = `The atlas lists no enrollment fields for ${countryCode}, so there is nothing to add.`;
|
|
984
|
+
return report;
|
|
985
|
+
}
|
|
986
|
+
if (report.forms.some((f) => f.shared)) report.note = `Forms shared with other countries are read, not changed. A field they lack goes on ${countryCode}'s own form, which enrollment shows beside them.`;
|
|
987
|
+
const draftOnly = rows.filter((r) => r.state === "inDraft");
|
|
988
|
+
if (draftOnly.length > 0 && target && !isActive(target)) report.draft = `"${target.title}" (${target.id}) is a draft, so enrollment doesn't ask for ${draftOnly.map((r) => r.id).join(", ")} yet. Review it in the form builder and publish it, or rerun with --create --activate --yes.`;
|
|
989
|
+
if (action !== "none") report.next = `fluid countries forms ${countryCode} --create${options.formId !== void 0 ? ` --form ${options.formId}` : ""}${options.activate ? " --activate" : ""}${options.skipSensitive ? " --skip-sensitive" : ""} --yes`;
|
|
990
|
+
return report;
|
|
991
|
+
}
|
|
992
|
+
/** The message the forms API answers when the caller can't change which
|
|
993
|
+
* fields are sensitive. */
|
|
994
|
+
const SENSITIVE_DENIED = /permission to change which fields are marked sensitive/i;
|
|
995
|
+
/** Turn the forms API's sensitive-permission 422 into an actionable error. */
|
|
996
|
+
function explainSensitiveDenial(error, fields) {
|
|
997
|
+
if (statusOf(error) === 422 && SENSITIVE_DENIED.test(errorDetail(error).message)) throw Object.assign(new LocalizationError(`Your token can't create fields marked sensitive (${fields.join(", ")}): that needs the forms manage-sensitive permission. Nothing was saved. Run it with a token whose role has that permission, or rerun with --skip-sensitive to create the rest and leave those fields to someone who has it.`), { status: 422 });
|
|
998
|
+
throw error;
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Apply a plan. A new form is created with the fields. An existing form is
|
|
1002
|
+
* read again first and every one of its components is sent back as it is,
|
|
1003
|
+
* ids included, with the new ones after them, so nothing on it is
|
|
1004
|
+
* deactivated and no field's sensitivity changes; fields an admin added since
|
|
1005
|
+
* the preview aren't added twice.
|
|
1006
|
+
*/
|
|
1007
|
+
async function applyFormsPlan(client, report, fields) {
|
|
1008
|
+
const { plan } = report;
|
|
1009
|
+
const byId = new Map(fields.map((f) => [f.id, f]));
|
|
1010
|
+
const toAdd = plan.add.map((a) => byId.get(a.id));
|
|
1011
|
+
const sensitive = toAdd.filter((f) => f.sensitive).map((f) => f.id);
|
|
1012
|
+
const result = (action, form, added, activated) => ({
|
|
1013
|
+
action,
|
|
1014
|
+
form: form ? {
|
|
1015
|
+
id: form.id,
|
|
1016
|
+
title: form.title,
|
|
1017
|
+
status: form.status ?? null
|
|
1018
|
+
} : null,
|
|
1019
|
+
added,
|
|
1020
|
+
activated,
|
|
1021
|
+
skippedSensitive: plan.skippedSensitive
|
|
1022
|
+
});
|
|
1023
|
+
if (plan.action === "none") return result("none", null, [], false);
|
|
1024
|
+
if (plan.action === "create") {
|
|
1025
|
+
let body;
|
|
1026
|
+
try {
|
|
1027
|
+
body = await client.post(FORMS_PATH, { form: {
|
|
1028
|
+
title: plan.title,
|
|
1029
|
+
form_type: "post_purchase",
|
|
1030
|
+
status: plan.status,
|
|
1031
|
+
form_redirect: "",
|
|
1032
|
+
country_ids: [report.countryId],
|
|
1033
|
+
form_components: JSON.stringify(toAdd.map(componentFor))
|
|
1034
|
+
} });
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
explainSensitiveDenial(error, sensitive);
|
|
1037
|
+
}
|
|
1038
|
+
return result("created", body.form ?? null, toAdd.map((f) => f.id), plan.status === "active");
|
|
1039
|
+
}
|
|
1040
|
+
const current = await getForm(client, plan.formId);
|
|
1041
|
+
const existing = componentsOf(current);
|
|
1042
|
+
const stillMissing = toAdd.filter((f) => !existing.some((c) => componentCovers(c, f)));
|
|
1043
|
+
if (stillMissing.length === 0 && !plan.activate) return result("none", current, [], false);
|
|
1044
|
+
const unidentified = existing.filter((c) => c.id == null || c.id === "");
|
|
1045
|
+
if (stillMissing.length > 0 && unidentified.length > 0) throw new LocalizationError(`Form ${current.id} ("${current.title}") has ${unidentified.length} component(s) without an element id, so sending its components back could deactivate them. Open it in the form builder and save it once, then rerun.`);
|
|
1046
|
+
let body;
|
|
1047
|
+
try {
|
|
1048
|
+
body = await client.put(`${FORMS_PATH}/${current.id}`, { form: {
|
|
1049
|
+
...stillMissing.length > 0 ? { form_components: JSON.stringify([...existing, ...stillMissing.map(componentFor)]) } : {},
|
|
1050
|
+
...plan.activate ? { status: "active" } : {}
|
|
1051
|
+
} });
|
|
1052
|
+
} catch (error) {
|
|
1053
|
+
explainSensitiveDenial(error, stillMissing.filter((f) => f.sensitive).map((f) => f.id));
|
|
1054
|
+
}
|
|
1055
|
+
return result("updated", body.form ?? current, stillMissing.map((f) => f.id), plan.activate);
|
|
1056
|
+
}
|
|
1057
|
+
//#endregion
|
|
631
1058
|
//#region src/countries/prices.ts
|
|
632
1059
|
/** The money fields a conversion can write, as the variants API names them. */
|
|
633
1060
|
const PRICE_FIELDS = [
|
|
@@ -1222,9 +1649,20 @@ function statusReport(input) {
|
|
|
1222
1649
|
ok: input.prices.onSale > 0 || input.prices.bundles.length > 0,
|
|
1223
1650
|
detail: input.prices.onSale > 0 ? `${input.prices.onSale} variants are on sale in ${countryCode}.` : input.prices.bundles.length > 0 ? `No single products are on sale in ${countryCode}; check the bundles under byHand.bundles.` : `Nothing is on sale in ${countryCode} yet. Turn on the products you'll sell there, or run prices with --activate.`
|
|
1224
1651
|
}] : [];
|
|
1652
|
+
const enrollment = input.enrollment && !("unavailable" in input.enrollment) ? input.enrollment : null;
|
|
1653
|
+
const enrollmentChecks = enrollment ? [{
|
|
1654
|
+
id: "enrollment_form",
|
|
1655
|
+
ok: enrollment.done,
|
|
1656
|
+
detail: enrollment.done ? enrollment.fields.length === 0 ? `The atlas lists no enrollment fields for ${countryCode}.` : `Enrollment in ${countryCode} asks for every atlas field a Post Purchase form can collect.` : enrollment.missing.length > 0 ? `Enrollment in ${countryCode} doesn't ask for ${enrollment.missing.join(", ")}. Run \`fluid countries forms ${countryCode}\`.` : enrollment.draft ?? `The enrollment form for ${countryCode} isn't published.`
|
|
1657
|
+
}] : input.enrollment && "unavailable" in input.enrollment ? [{
|
|
1658
|
+
id: "enrollment_form",
|
|
1659
|
+
ok: false,
|
|
1660
|
+
detail: `Couldn't read the company's forms (${input.enrollment.unavailable}), so the enrollment fields weren't checked.`
|
|
1661
|
+
}] : [];
|
|
1225
1662
|
const checks = [
|
|
1226
1663
|
...settingsChecks(atlas, cc),
|
|
1227
1664
|
...priceChecks,
|
|
1665
|
+
...enrollmentChecks,
|
|
1228
1666
|
...languages.map((l) => ({
|
|
1229
1667
|
id: `language:${l.iso}`,
|
|
1230
1668
|
ok: l.enabled,
|
|
@@ -1249,6 +1687,10 @@ function statusReport(input) {
|
|
|
1249
1687
|
report: `fluid translations missing ${iso}`,
|
|
1250
1688
|
translate: `fluid translations auto ${iso} --write --yes`
|
|
1251
1689
|
})),
|
|
1690
|
+
enrollmentForm: {
|
|
1691
|
+
report: `fluid countries forms ${countryCode}`,
|
|
1692
|
+
create: `fluid countries forms ${countryCode} --create --yes`
|
|
1693
|
+
},
|
|
1252
1694
|
prices: {
|
|
1253
1695
|
preview: `fluid countries prices ${countryCode}`,
|
|
1254
1696
|
convert: `fluid countries prices ${countryCode} --rate <rate> --write --yes`
|
|
@@ -1263,7 +1705,13 @@ function statusReport(input) {
|
|
|
1263
1705
|
name: m.name,
|
|
1264
1706
|
recommended: m.enabled
|
|
1265
1707
|
})),
|
|
1266
|
-
enrollmentFields:
|
|
1708
|
+
enrollmentFields: enrollment ? enrollment.fields.filter((f) => f.state === "byHand").map((f) => ({
|
|
1709
|
+
id: f.id,
|
|
1710
|
+
label: f.label,
|
|
1711
|
+
component: f.component,
|
|
1712
|
+
required: f.required,
|
|
1713
|
+
...f.reason ? { reason: f.reason } : {}
|
|
1714
|
+
})) : [...atlas.enrollmentFormFields ?? []].sort((a, b) => a.order - b.order).map((f) => ({
|
|
1267
1715
|
id: f.id,
|
|
1268
1716
|
label: f.label,
|
|
1269
1717
|
component: f.component,
|
|
@@ -3324,6 +3772,20 @@ async function withTranslationGaps(api, atlas, states) {
|
|
|
3324
3772
|
async function countryPriceCoverage(api, iso) {
|
|
3325
3773
|
return priceCoverage(await listProductsWithVariants(api, ["active"]), iso);
|
|
3326
3774
|
}
|
|
3775
|
+
/** The country's enrollment form report, or the reason it couldn't be
|
|
3776
|
+
* read (a token without forms access), so status still runs. */
|
|
3777
|
+
async function countryEnrollment(api, countryCode, atlas) {
|
|
3778
|
+
try {
|
|
3779
|
+
return formsReport({
|
|
3780
|
+
countryCode,
|
|
3781
|
+
country: await catalogCountry(api, countryCode),
|
|
3782
|
+
atlas,
|
|
3783
|
+
forms: await listForms(api)
|
|
3784
|
+
});
|
|
3785
|
+
} catch (error) {
|
|
3786
|
+
return { unavailable: failureOf(error).error };
|
|
3787
|
+
}
|
|
3788
|
+
}
|
|
3327
3789
|
function registerCountriesCommands(parent, ctx) {
|
|
3328
3790
|
parent.command("atlas <iso>").description("Show Fluid's Country Atlas for a market: modes, tax, legal, address, payments, languages, agreements").option("--agreement <localId>", "Print one atlas agreement's full text in every language instead").action((iso, opts) => run(async () => {
|
|
3329
3791
|
const result = await fetchAtlas(await client$1(ctx), iso);
|
|
@@ -3532,6 +3994,46 @@ function registerCountriesCommands(parent, ctx) {
|
|
|
3532
3994
|
});
|
|
3533
3995
|
if (failed.length > 0 || created.some((c) => c.failedTranslations.length > 0)) process.exitCode = 1;
|
|
3534
3996
|
}));
|
|
3997
|
+
parent.command("forms <iso>").description("Compare the atlas enrollment fields with the country's Post Purchase forms; --create sets up its enrollment form, as a draft unless --activate (changes live config)").option("--create", "Create the country's enrollment form, or add the missing atlas fields to it", false).option("--form <id>", "With --create, add to this Post Purchase form for the country instead").option("--activate", "With --create, publish the form so enrollment asks for its fields now", false).option("--skip-sensitive", "With --create, leave out fields stored as sensitive, which need the forms manage-sensitive permission", false).option("--yes", "Confirm without a prompt", false).action((iso, opts) => run(async () => {
|
|
3998
|
+
if (!opts.create && (opts.form || opts.activate || opts.skipSensitive)) throw new LocalizationError("--form, --activate and --skip-sensitive go with --create.");
|
|
3999
|
+
const api = await client$1(ctx);
|
|
4000
|
+
const { countryCode, atlas } = await requireAtlas(api, iso);
|
|
4001
|
+
const country = await catalogCountry(api, countryCode);
|
|
4002
|
+
const options = {
|
|
4003
|
+
...opts.form ? { formId: positiveInt(opts.form, "--form") } : {},
|
|
4004
|
+
activate: opts.activate,
|
|
4005
|
+
skipSensitive: opts.skipSensitive
|
|
4006
|
+
};
|
|
4007
|
+
const report = formsReport({
|
|
4008
|
+
countryCode,
|
|
4009
|
+
country,
|
|
4010
|
+
atlas,
|
|
4011
|
+
forms: await listForms(api),
|
|
4012
|
+
options
|
|
4013
|
+
});
|
|
4014
|
+
if (!opts.create || report.plan.action === "none") {
|
|
4015
|
+
ctx.output({
|
|
4016
|
+
...report,
|
|
4017
|
+
written: false
|
|
4018
|
+
});
|
|
4019
|
+
return;
|
|
4020
|
+
}
|
|
4021
|
+
const { plan } = report;
|
|
4022
|
+
await confirmChange(`${plan.action === "create" ? `Create "${plan.title}" as a ${plan.status} Post Purchase form for ${countryCode}` : `Change form ${plan.formId} ("${plan.title}")`} ${[...plan.add.length > 0 ? [`with ${plan.add.length} field(s): ${plan.add.map((f) => `"${f.label}"${f.sensitive ? " (sensitive)" : ""}`).join(", ")}`] : [], ...plan.action === "update" && plan.activate ? ["and publish it, so enrollment asks for its fields"] : []].join(" ")}?`, opts.yes);
|
|
4023
|
+
const result = await applyFormsPlan(api, report, mapAtlasFields(atlas.enrollmentFormFields ?? []));
|
|
4024
|
+
const after = formsReport({
|
|
4025
|
+
countryCode,
|
|
4026
|
+
country,
|
|
4027
|
+
atlas,
|
|
4028
|
+
forms: await listForms(api),
|
|
4029
|
+
...options.formId !== void 0 ? { options: { formId: options.formId } } : {}
|
|
4030
|
+
});
|
|
4031
|
+
ctx.output({
|
|
4032
|
+
...after,
|
|
4033
|
+
written: result.action !== "none",
|
|
4034
|
+
...result
|
|
4035
|
+
});
|
|
4036
|
+
}));
|
|
3535
4037
|
parent.command("prices <iso>").description("Convert every variant's price from another country into this one's currency at an exchange rate. A dry run unless --write (changes live prices)").option("--from-country <iso>", "The country whose prices are converted", "US").option("--rate <rate>", "Units of the new currency per unit of the source currency, e.g. 0.92; --write needs it").option("--fields <list>", `Price fields to convert, comma-separated, from ${PRICE_FIELDS.join(", ")}`, DEFAULT_PRICE_FIELDS.join(",")).option("--status <status>", "Product status to include (repeatable)", collect, []).option("--overwrite", "Also replace prices the country already has", false).option("--activate", "Put each converted variant on sale in the country", false).option("--sample <n>", "How many before→after examples to print", "10").option("--write", "Save the converted prices; without it nothing is written", false).option("--yes", "Confirm --write without a prompt", false).action((iso, opts) => run(async () => {
|
|
3536
4038
|
const api = await client$1(ctx);
|
|
3537
4039
|
const targetIso = countryCodeOf(iso);
|
|
@@ -3643,13 +4145,15 @@ function registerCountriesCommands(parent, ctx) {
|
|
|
3643
4145
|
const { countryCode, atlas } = await requireAtlas(api, iso);
|
|
3644
4146
|
const cc = await findCompanyCountry(api, countryCode);
|
|
3645
4147
|
const [languages, agreements] = cc ? await Promise.all([listLanguages(api), listAgreements(api)]) : [[], []];
|
|
4148
|
+
const enrollment = cc ? await countryEnrollment(api, countryCode, atlas) : void 0;
|
|
3646
4149
|
ctx.output(statusReport({
|
|
3647
4150
|
countryCode,
|
|
3648
4151
|
atlas,
|
|
3649
4152
|
companyCountry: cc,
|
|
3650
4153
|
languages: languageStates(atlas, languages),
|
|
3651
4154
|
agreements: await withTranslationGaps(api, atlas, agreementStates(atlas, agreements, cc?.id ?? null)),
|
|
3652
|
-
...cc ? { prices: await countryPriceCoverage(api, countryCode) } : {}
|
|
4155
|
+
...cc ? { prices: await countryPriceCoverage(api, countryCode) } : {},
|
|
4156
|
+
...enrollment ? { enrollment } : {}
|
|
3653
4157
|
}));
|
|
3654
4158
|
}));
|
|
3655
4159
|
}
|