Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

chore: add analytics tracking to capture PlanningConstraints & PropertyInformation "overrides" #3510

Merged
merged 6 commits into from
Aug 14, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api.planx.uk/modules/gis/service/classifiedRoads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,8 +126,9 @@
fn: PASSPORT_FN,
value: true,
text: `is on a Classified Road`,
data: features.map((feature: any) => ({

Check warning on line 129 in api.planx.uk/modules/gis/service/classifiedRoads.ts

View workflow job for this annotation

GitHub Actions / Run API Tests

Unexpected any. Specify a different type
name: `${feature.properties["RoadName1"]} - ${feature.properties["RoadClassification"]}`,
entity: feature.properties["GmlID"], // match Planning Data "entity" identifier for convenience when reporting inaccurate constraints
jessicamcinchak marked this conversation as resolved.
Show resolved Hide resolved
properties: feature.properties,
})),
category: "General policy",
Expand All @@ -147,7 +148,7 @@
},
} as GISResponse);
}
} catch (error: any) {

Check warning on line 151 in api.planx.uk/modules/gis/service/classifiedRoads.ts

View workflow job for this annotation

GitHub Actions / Run API Tests

Unexpected any. Specify a different type
return next({
message: "Failed to fetch classified roads: " + error?.message,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,11 @@ const ALLOW_LIST = [
"application.declaration.connection",
"drawBoundary.action",
"findProperty.action",
"_overrides",
"planningConstraints.action",
"property.constraints.planning",
"property.type",
"propertyInformation.action",
"proposal.projectType",
"usedFOIYNPP",
"user.role",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,13 +50,15 @@ function Component(props: Props) {
siteBoundary,
{ x, y, longitude, latitude, usrn },
hasPlanningData,
priorOverrides,
] = useStore((state) => [
state.currentCard?.id,
state.cachedBreadcrumbs,
state.teamSlug,
state.computePassport().data?.["property.boundary.site"],
(state.computePassport().data?.["_address"] as SiteAddress) || {},
state.teamIntegrations?.hasPlanningData,
state.computePassport().data?.["_overrides"],
]);

// PlanningConstraints must come after at least a FindProperty in the graph
Expand All @@ -66,7 +68,7 @@ function Component(props: Props) {
// still prepopulate any previously marked inaccurateConstraints
const initialInaccurateConstraints =
currentCardId &&
cachedBreadcrumbs?.[currentCardId]?.["data"]?.["_overrides"];
cachedBreadcrumbs?.[currentCardId]?.["data"]?.["_overrides"]?.[props.fn];
const [inaccurateConstraints, setInaccurateConstraints] =
useState<InaccurateConstraints>(initialInaccurateConstraints);

Expand Down Expand Up @@ -176,7 +178,13 @@ function Component(props: Props) {
if (data) _constraints.push(data as GISResponse["constraints"]);
}

const _overrides = inaccurateConstraints;
const hasInaccurateConstraints = inaccurateConstraints && Object.keys(inaccurateConstraints).length > 0;
const _overrides = hasInaccurateConstraints ? { ...priorOverrides, [props.fn]: inaccurateConstraints } : undefined;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An observation / pattern I didn't really notice last time round when inaccurateConstraints were added to this component.

This component has transitioned from being informational only, to now capturing data much like other components. This data is generally captured and validated using formik (unlike the useState() hook here for inaccurateConstraints).

This particular function is essentially handling the "back" behaviour usually managed by getPreviouslySubmittedData().

If / when we come back to handle using formik for the OverrideEntitiesModal instances, it likely makes sense that this state would live at this root component level to match the behaviour of other components as opposed to individual instances within each modal as I think I'd previously suggested.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for noting this here so we have record when coming back; will leave unresolved so stays expanded 👍


// `planningConstraints.action` is for analytics
const userAction = hasInaccurateConstraints
? "Reported at least one inaccurate planning constraint"
: "Accepted all planning constraints";

// `[props.fn]` & `_nots[props.fn]` are responsible for future service automations
const _nots: IntersectingConstraints = {};
Expand Down Expand Up @@ -206,6 +214,7 @@ function Component(props: Props) {
const passportData = {
_constraints,
_overrides,
"planningConstraints.action": userAction,
_nots: notsAfterOverrides,
...intersectingConstraintsAfterOverrides,
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,7 @@ export const mockConstraints: GISResponse["constraints"] = {
"data": [
{
"name": "Black Prince Road - Classified Unnumbered",
"entity": "Highways_RoadLink.83155",
"properties": {
"GmlID": "Highways_RoadLink.83155",
"OBJECTID": 83155,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ describe("handleOverrides with non-granular constraint categories", () => {
test("when all entities in a constraint category are inaccurate, the whole category no longer applies", () => {
const overrides = {
"road.classified": {
"entities": ['undefined'], // @todo roads don't have entity ids, record the property.OBJECTID via OS?
"entities": ["Highways_RoadLink.83155"],
"reason": "My house is on a corner, and this is not the road that my entry faces"
},
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,17 @@ function Component(props: PublicProps<PropertyInformation>) {
titleBoundary={passport.data?.["property.boundary.title"]}
blpuCodes={blpuCodes}
overrideAnswer={overrideAnswer}
handleSubmit={props.handleSubmit}
handleSubmit={() => {
// If the user changed their property type, they'll already have a previous PropertyInformation breadcrumb that set `_overrides`
const hasOverrodeAnswer = passport.data?.["_overrides"]?.["property.type"];
const passportData = {
"propertyInformation.action": hasOverrodeAnswer ? "Changed the property type" : "Accepted the property type",
};
jessicamcinchak marked this conversation as resolved.
Show resolved Hide resolved

props.handleSubmit?.({
data: passportData,
});
}}
/>
) : (
<Card>
Expand Down
1 change: 0 additions & 1 deletion editor.planx.uk/src/lib/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// add/edit/remove feature flags in array below
const AVAILABLE_FEATURE_FLAGS = [
"SEARCH",
"OVERRIDE_CONSTRAINTS",
"TREES",
"ADD_NEW_EDITOR",
] as const;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,22 @@ test("it clears the correct breadcrumb and navigates back to the right node", as
["residential.dwelling.house.semiDetached"],
);

// override answer
// override answer ("I'm in a flat not a house")
overrideAnswer("property.type");

// confirm we've cleared the provided passport variable from the first node that set it
// confirm we've cleared the provided passport variable from the first node that set it but added an audit variable `_overrides`
const afterOverrideBreadcrumb = getState().breadcrumbs;
const addressBreadcrumb: any =
afterOverrideBreadcrumb?.["FindPropertyNodeId"]?.data;
expect(Object.keys(addressBreadcrumb)).toHaveLength(3);
expect(Object.keys(addressBreadcrumb)).toHaveLength(4);
expect(addressBreadcrumb).not.toHaveProperty(["property.type"]);
expect(addressBreadcrumb).toHaveProperty(["_overrides"]);

const afterOverridePassport = getState().computePassport();
expect(afterOverridePassport.data).toBeDefined();
expect(afterOverridePassport.data).not.toHaveProperty(["property.type"]);

// confirm we've stored a copy of the original value in the first node that set it
const overrideData: any =
afterOverrideBreadcrumb?.["FindPropertyNodeId"]?.override;
expect(overrideData).toEqual({
expect(afterOverridePassport.data).toHaveProperty(["_overrides"])
expect(afterOverridePassport.data?.["_overrides"]).toEqual({
"property.type": ["residential.dwelling.house.semiDetached"],
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,11 @@ export const ALLOW_LIST = [
"application.declaration.connection",
"drawBoundary.action",
"findProperty.action",
"_overrides",
"planningConstraints.action",
"property.constraints.planning",
"property.type",
"propertyInformation.action",
jessicamcinchak marked this conversation as resolved.
Show resolved Hide resolved
"proposal.projectType",
"usedFOIYNPP",
"user.role",
Expand Down
8 changes: 4 additions & 4 deletions editor.planx.uk/src/pages/FlowEditor/lib/store/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -617,11 +617,11 @@ export const previewStore: StateCreator<

if (originalNodeId) {
// Omit existing passport value from breadcrumbs.data in whichever node originally set it, so it won't be auto-answered in future
// and keep a receipt of the original value in breadcrumbs.override
// and keep a receipt of the original value in breadcrumbs.data._overrides
record(originalNodeId, {
data: omit(breadcrumbs?.[originalNodeId]?.data, fn),
override: {
[fn]: breadcrumbs?.[originalNodeId]?.data?.[fn],
data: {
...omit(breadcrumbs?.[originalNodeId]?.data, fn),
_overrides: { [fn]: breadcrumbs?.[originalNodeId]?.data?.[fn] },
jessicamcinchak marked this conversation as resolved.
Show resolved Hide resolved
},
});
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
CREATE OR REPLACE VIEW "public"."analytics_summary" AS
SELECT a.id AS analytics_id,
al.id AS analytics_log_id,
f.slug AS service_slug,
t.slug AS team_slug,
a.type AS analytics_type,
al.created_at AS analytics_log_created_at,
a.created_at AS analytics_created_at,
((a.user_agent -> 'os'::text) ->> 'name'::text) AS operating_system,
((a.user_agent -> 'browser'::text) ->> 'name'::text) AS browser,
((a.user_agent -> 'platform'::text) ->> 'type'::text) AS platform,
a.referrer,
al.flow_direction,
(al.metadata ->> 'change'::text) AS change_metadata,
(al.metadata ->> 'back'::text) AS back_metadata,
(al.metadata ->> 'selectedUrls'::text) AS selected_urls,
(al.metadata ->> 'flag'::text) AS result_flag,
(al.metadata -> 'flagSet'::text) AS result_flagset,
((al.metadata -> 'displayText'::text) ->> 'heading'::text) AS result_heading,
((al.metadata -> 'displayText'::text) ->> 'description'::text) AS result_description,
(al.metadata -> 'helpTextUseful'::text) AS help_text_useful,
CASE
WHEN al.has_clicked_help THEN al.metadata
ELSE NULL::jsonb
END AS help_metadata,
al.user_exit AS is_user_exit,
al.node_type,
al.node_title,
al.has_clicked_help,
al.input_errors,
(date_part('epoch'::text, (al.next_log_created_at - al.created_at)))::numeric(10,1) AS time_spent_on_node_seconds,
a.ended_at AS analytics_ended_at,
((date_part('epoch'::text, (a.ended_at - a.created_at)) / (60)::double precision))::numeric(10,1) AS time_spent_on_analytics_session_minutes,
al.node_id,
al.allow_list_answers,
(al.allow_list_answers -> 'proposal.projectType'::text) AS proposal_project_type,
(al.allow_list_answers -> 'application.declaration.connection'::text) AS application_declaration_connection,
(al.allow_list_answers -> 'property.type'::text) AS property_type,
(al.allow_list_answers -> 'drawBoundary.action'::text) AS draw_boundary_action,
(al.allow_list_answers -> 'user.role'::text) AS user_role,
(al.allow_list_answers -> 'property.constraints.planning'::text) AS property_constraints_planning,
(al.allow_list_answers -> 'findProperty.action'::text) AS find_property_action,
(al.allow_list_answers -> 'usedFOIYNPP'::text) AS used_foiynpp
FROM (((analytics a
LEFT JOIN analytics_logs al ON ((a.id = al.analytics_id)))
LEFT JOIN flows f ON ((a.flow_id = f.id)))
LEFT JOIN teams t ON ((t.id = f.team_id)));

CREATE OR REPLACE VIEW "public"."submission_services_summary" AS
WITH resumes_per_session AS (
SELECT reconciliation_requests.session_id,
count(reconciliation_requests.id) AS number_times_resumed
FROM reconciliation_requests
GROUP BY reconciliation_requests.session_id
), bops_agg AS (
SELECT bops_applications.session_id,
json_agg(json_build_object('id', bops_applications.bops_id, 'submittedAt', bops_applications.created_at, 'destinationUrl', bops_applications.destination_url) ORDER BY bops_applications.created_at DESC) AS bops_applications
FROM bops_applications
GROUP BY bops_applications.session_id
), email_agg AS (
SELECT email_applications.session_id,
json_agg(json_build_object('id', email_applications.id, 'recipient', email_applications.recipient, 'submittedAt', email_applications.created_at) ORDER BY email_applications.created_at DESC) AS email_applications
FROM email_applications
GROUP BY email_applications.session_id
), uniform_agg AS (
SELECT uniform_applications.submission_reference,
json_agg(json_build_object('id', uniform_applications.idox_submission_id, 'submittedAt', uniform_applications.created_at) ORDER BY uniform_applications.created_at DESC) AS uniform_applications
FROM uniform_applications
GROUP BY uniform_applications.submission_reference
), payment_requests_agg AS (
SELECT payment_requests.session_id,
json_agg(json_build_object('id', payment_requests.id, 'createdAt', payment_requests.created_at, 'paidAt', payment_requests.paid_at, 'govpayPaymentId', payment_requests.govpay_payment_id) ORDER BY payment_requests.created_at DESC) AS payment_requests
FROM payment_requests
GROUP BY payment_requests.session_id
), payment_status_agg AS (
SELECT payment_status.session_id,
json_agg(json_build_object('govpayPaymentId', payment_status.payment_id, 'createdAt', payment_status.created_at, 'status', payment_status.status) ORDER BY payment_status.created_at DESC) AS payment_status
FROM payment_status
GROUP BY payment_status.session_id
), s3_agg AS (
SELECT s3_applications.session_id,
json_agg(json_build_object('id', s3_applications.id, 'submittedAt', s3_applications.created_at) ORDER BY s3_applications.created_at DESC) AS s3_applications
FROM s3_applications
GROUP BY s3_applications.session_id
)
SELECT (ls.id)::text AS session_id,
t.slug AS team_slug,
f.slug AS service_slug,
ls.created_at,
ls.submitted_at,
((ls.submitted_at)::date - (ls.created_at)::date) AS session_length_days,
ls.has_user_saved AS user_clicked_save,
rps.number_times_resumed,
ls.allow_list_answers,
(ls.allow_list_answers -> 'proposal.projectType'::text) AS proposal_project_type,
(ls.allow_list_answers -> 'application.declaration.connection'::text) AS application_declaration_connection,
(ls.allow_list_answers -> 'property.type'::text) AS property_type,
(ls.allow_list_answers -> 'drawBoundary.action'::text) AS draw_boundary_action,
(ls.allow_list_answers -> 'user.role'::text) AS user_role,
(ls.allow_list_answers -> 'property.constraints.planning'::text) AS property_constraints_planning,
CASE
WHEN (((pr.payment_requests)::jsonb IS NOT NULL) AND (jsonb_array_length((pr.payment_requests)::jsonb) > 0)) THEN true
ELSE false
END AS user_invited_to_pay,
pr.payment_requests,
ps.payment_status,
CASE
WHEN (((ba.bops_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ba.bops_applications)::jsonb) > 0)) THEN true
ELSE false
END AS sent_to_bops,
ba.bops_applications,
CASE
WHEN (((ua.uniform_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ua.uniform_applications)::jsonb) > 0)) THEN true
ELSE false
END AS sent_to_uniform,
ua.uniform_applications,
CASE
WHEN (((ea.email_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((ea.email_applications)::jsonb) > 0)) THEN true
ELSE false
END AS sent_to_email,
ea.email_applications,
(ls.allow_list_answers -> 'findProperty.action'::text) AS find_property_action,
CASE
WHEN (((sa.s3_applications)::jsonb IS NOT NULL) AND (jsonb_array_length((sa.s3_applications)::jsonb) > 0)) THEN true
ELSE false
END AS sent_to_s3_power_automate,
sa.s3_applications
FROM (((((((((lowcal_sessions ls
LEFT JOIN flows f ON ((f.id = ls.flow_id)))
LEFT JOIN teams t ON ((t.id = f.team_id)))
LEFT JOIN resumes_per_session rps ON ((rps.session_id = (ls.id)::text)))
LEFT JOIN payment_requests_agg pr ON ((pr.session_id = ls.id)))
LEFT JOIN payment_status_agg ps ON ((ps.session_id = ls.id)))
LEFT JOIN bops_agg ba ON ((ba.session_id = (ls.id)::text)))
LEFT JOIN uniform_agg ua ON ((ua.submission_reference = (ls.id)::text)))
LEFT JOIN email_agg ea ON ((ea.session_id = ls.id)))
LEFT JOIN s3_agg sa ON ((sa.session_id = (ls.id)::text)))
WHERE ((f.slug IS NOT NULL) AND (t.slug IS NOT NULL));
Loading
Loading