diff --git a/doc/how-to/how-to-grant-metabase-permissions.md b/doc/how-to/how-to-grant-metabase-permissions.md new file mode 100644 index 0000000000..1cce89ccfa --- /dev/null +++ b/doc/how-to/how-to-grant-metabase-permissions.md @@ -0,0 +1,54 @@ +# How to grant Metabase permissions + +## What is Metabase? +[Metabase](https://www.metabase.com/) is an open source BI service which we self-host as part of PlanX. It allows teams to view and self-serve analytics dashboards related to their flows, applications, and users. + +Metabase is set up and running on both Staging and Production environments, but only the Production instance (with Production data) has dashboards maintained and curated for teams. + +## Context +Metabase accesses our staging and production databases through the `metabase_read_only` role which has `SELECT` (read-only) access to a subset of tables and views. This ensures that sensitive user data cannot be inadvertently exposed via Metabase, and any new tables added to the PlanX database have to be explicitly exposed via this role. + +The permissions granted for the `metabase_read_only` role are applied via Hasura migrations. This ensures that we have a version-controlled history of this role, and it's access level is documented in code. This is not controlled via Pulumi (IaC) as this is not used for local development or test environments ("Pizzas"), which would necessitate a second method for these environments. + +## How is this role used? + +### Staging & Production +The `metabase_read_only` role is granted to the `metabase_user` database user, which is manually set up on both staging and production with the following SQL - + +```sql +CREATE USER metabase_user WITH PASSWORD `$PASSWORD`; +GRANT metabase_read_only TO metabase_user; +``` + +The password for Staging and Production databases can be found the OSL 1Password account. + +The username and password for Metabase are not controlled via IaC - they are manually entered via the Metabase "Admin" dashboard (`Admin Setting > Databases > "staging" | "production" > Username / Password fields`). + +Please note - this is separate to the role used to read/write Metabase internal application data (such as dashboard and queries). This role is setup in IaC [here](https://github.com/theopensystemslab/planx-new/blob/main/infrastructure/application/index.ts#L100). For more information, please see [the Metabase docs](https://www.metabase.com/docs/latest/installation-and-operation/configuring-application-database). + +### Locally & Pizzas +If you wish to run Metabase locally using the "analytics" Docker profile (`pnpm analytics` from project root), you will need to manually run the above SQL on your local database with a password of your choice. Alternatively, you can use the root DB username/password. + +The Metabase service does not run on Pizzas. + +## Metabase permissions +Metabase also operates it's own permissions model, which allows more fine-grained control over tables. This allows "Administrator" users access to all tables granted to the Postgres `metabase_read_only` role, but other users can only access summary views. + +## Process +The process for exposing a new table / view to Metabase is as follows - + +### Tables +Generally, we'd favour exposing views of data via Metabase. This means only certain columns can be exposed, and data can be formatted in a more user-friendly manner. + +If you need to expose a new table (e.g. public data) access can be granted via a Hasura migration, e.g. - + +```sql +GRANT SELECT ON public.flows TO metabase_read_only; +``` + +### Views +When adding a new view, you will need to grant the `metabase_read_only` role `SELECT` access the view. Access should be applied via Hasura migrations, e.g. - + +```sql +GRANT SELECT ON public.YOUR_NEW_VIEW TO metabase_read_only; +``` diff --git a/editor.planx.uk/src/@planx/components/Confirmation/Public.tsx b/editor.planx.uk/src/@planx/components/Confirmation/Public.tsx index 81923dc459..788c7c5818 100644 --- a/editor.planx.uk/src/@planx/components/Confirmation/Public.tsx +++ b/editor.planx.uk/src/@planx/components/Confirmation/Public.tsx @@ -1,9 +1,9 @@ import Check from "@mui/icons-material/Check"; import Box from "@mui/material/Box"; -import { styled } from "@mui/material/styles"; import Typography from "@mui/material/Typography"; import { QuestionAndResponses } from "@opensystemslab/planx-core/types"; import Card from "@planx/components/shared/Preview/Card"; +import { SummaryListTable } from "@planx/components/shared/Preview/SummaryList"; import { PublicProps } from "@planx/components/ui"; import { useStore } from "pages/FlowEditor/lib/store"; import React, { useEffect, useState } from "react"; @@ -14,20 +14,6 @@ import ReactMarkdownOrHtml from "ui/shared/ReactMarkdownOrHtml"; import type { Confirmation } from "./model"; -const Table = styled("table")(({ theme }) => ({ - width: "100%", - borderCollapse: "collapse", - "& tr": { - borderBottom: `1px solid ${theme.palette.grey[400]}`, - "&:last-of-type": { - border: "none", - }, - "& td": { - padding: theme.spacing(1.5, 1), - }, - }, -})); - export type Props = PublicProps; export default function ConfirmationComponent(props: Props) { @@ -70,18 +56,14 @@ export default function ConfirmationComponent(props: Props) { {props.details && ( - - - {Object.entries(props.details).map((item, i) => ( - - - - - ))} - -
{item[0]} - {item[1]} -
+ + {Object.entries(props.details).map((item) => ( + <> + {item[0]} + {item[1]} + + ))} + )} {} diff --git a/editor.planx.uk/src/@planx/components/PropertyInformation/Public.tsx b/editor.planx.uk/src/@planx/components/PropertyInformation/Public.tsx index 021906f9b5..1c86fb91a6 100644 --- a/editor.planx.uk/src/@planx/components/PropertyInformation/Public.tsx +++ b/editor.planx.uk/src/@planx/components/PropertyInformation/Public.tsx @@ -6,6 +6,7 @@ import Typography from "@mui/material/Typography"; import { visuallyHidden } from "@mui/utils"; import Card from "@planx/components/shared/Preview/Card"; import QuestionHeader from "@planx/components/shared/Preview/QuestionHeader"; +import { SummaryListTable } from "@planx/components/shared/Preview/SummaryList"; import type { PublicProps } from "@planx/components/ui"; import { Feature } from "@turf/helpers"; import { useFormik } from "formik"; @@ -15,7 +16,6 @@ import { useAnalyticsTracking } from "pages/FlowEditor/lib/analyticsProvider"; import { useStore } from "pages/FlowEditor/lib/store"; import { handleSubmit } from "pages/Preview/Node"; import React from "react"; -import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import type { SiteAddress } from "../FindProperty/model"; import { FETCH_BLPU_CODES } from "../FindProperty/Public"; @@ -201,41 +201,10 @@ interface PropertyDetail { interface PropertyDetailsProps { data: PropertyDetail[]; showPropertyTypeOverride?: boolean; + showChangeButton?: boolean; overrideAnswer: (fn: string) => void; } -// Borrows and tweaks grid style from Review page's `SummaryList` -const PropertyDetailsList = styled(Box)(({ theme }) => ({ - display: "grid", - gridTemplateColumns: "1fr 2fr 100px", - marginTop: theme.spacing(2), - marginBottom: theme.spacing(2), - "& > *": { - borderBottom: `1px solid ${theme.palette.border.main}`, - paddingBottom: theme.spacing(1.5), - paddingTop: theme.spacing(1.5), - verticalAlign: "top", - margin: 0, - }, - "& ul": { - listStylePosition: "inside", - padding: 0, - margin: 0, - }, - "& dt": { - // left column - fontWeight: FONT_WEIGHT_SEMI_BOLD, - }, - "& dd:nth-of-type(n)": { - // middle column - paddingLeft: "10px", - }, - "& dd:nth-of-type(2n)": { - // right column - textAlign: "right", - }, -})); - function PropertyDetails(props: PropertyDetailsProps) { const { data, showPropertyTypeOverride, overrideAnswer } = props; const filteredData = data.filter((d) => Boolean(d.detail)); @@ -248,7 +217,7 @@ function PropertyDetails(props: PropertyDetailsProps) { }; return ( - + {filteredData.map(({ heading, detail, fn }: PropertyDetail) => ( {heading} @@ -275,6 +244,6 @@ function PropertyDetails(props: PropertyDetailsProps) { ))} - + ); } diff --git a/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx b/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx index 4b60e55c4b..2f5338895f 100644 --- a/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx +++ b/editor.planx.uk/src/@planx/components/shared/Preview/SummaryList.tsx @@ -19,7 +19,7 @@ export default SummaryListsBySections; const FIND_PROPERTY_DT = "Property address"; const DRAW_BOUNDARY_DT = "Location plan"; -const Grid = styled("dl", { +export const SummaryListTable = styled("dl", { shouldForwardProp: (prop) => prop !== "showChangeButton", })<{ showChangeButton?: boolean }>(({ theme, showChangeButton }) => ({ display: "grid", @@ -49,7 +49,7 @@ const Grid = styled("dl", { }, "& dd:nth-of-type(2n)": { // right column - textAlign: "right", + textAlign: showChangeButton ? "right" : "left", }, })); @@ -235,7 +235,7 @@ function SummaryList(props: SummaryListProps) { return ( <> - + {props.summaryBreadcrumbs.map( ({ component: Component, nodeId, node, userData }, i) => ( @@ -247,7 +247,7 @@ function SummaryList(props: SummaryListProps) { passport={props.passport} /> {props.showChangeButton && ( -
+ handleChange(nodeId)} component="button" @@ -263,12 +263,12 @@ function SummaryList(props: SummaryListProps) { "this answer"} -
+ )}
), )} -
+ -
{props.node.data.text}
-
{getNodeText()}
+ {props.node.data.text} + {getNodeText()} ); @@ -323,26 +323,26 @@ function FindProperty(props: ComponentProps) { props.passport.data?._address; return ( <> -
{FIND_PROPERTY_DT}
-
+ {FIND_PROPERTY_DT} + {`${single_line_address.split(`, ${town}`)[0]}`}
{town}
{postcode} -
+ ); } else { const { x, y, title } = props.passport.data?._address; return ( <> -
{FIND_PROPERTY_DT}
-
+ {FIND_PROPERTY_DT} + {`${title}`}
{`${Math.round(x)} Easting (X), ${Math.round(y)} Northing (Y)`} -
+ ); } @@ -351,14 +351,14 @@ function FindProperty(props: ComponentProps) { function Checklist(props: ComponentProps) { return ( <> -
{props.node.data.text}
-
+ {props.node.data.text} +
    {getAnswers(props).map((nodeId, i: number) => (
  • {props.flow[nodeId].data.text}
  • ))}
-
+ ); } @@ -366,8 +366,8 @@ function Checklist(props: ComponentProps) { function TextInput(props: ComponentProps) { return ( <> -
{props.node.data.title}
-
{getAnswersByNode(props)}
+ {props.node.data.title} + {getAnswersByNode(props)} ); } @@ -375,8 +375,8 @@ function TextInput(props: ComponentProps) { function FileUpload(props: ComponentProps) { return ( <> -
{props.node.data.title}
-
+ {props.node.data.title} +
    {getAnswersByNode(props)?.map((file: any, i: number) => (
  • @@ -384,7 +384,7 @@ function FileUpload(props: ComponentProps) {
  • ))}
-
+ ); } @@ -392,8 +392,10 @@ function FileUpload(props: ComponentProps) { function DateInput(props: ComponentProps) { return ( <> -
{props.node.data.title}
-
{format(new Date(getAnswersByNode(props)), "d MMMM yyyy")}
+ {props.node.data.title} + + {format(new Date(getAnswersByNode(props)), "d MMMM yyyy")} + ); } @@ -413,8 +415,8 @@ function DrawBoundary(props: ComponentProps) { return ( <> -
{DRAW_BOUNDARY_DT}
-
+ {DRAW_BOUNDARY_DT} + {fileName && ( Your uploaded file: {fileName} @@ -443,7 +445,7 @@ function DrawBoundary(props: ComponentProps) { !geodata && props.node.data?.hideFileUpload && "Not provided"} -
+ ); } @@ -451,8 +453,10 @@ function DrawBoundary(props: ComponentProps) { function NumberInput(props: ComponentProps) { return ( <> -
{props.node.data.title}
-
{`${getAnswersByNode(props)} ${props.node.data.units ?? ""}`}
+ {props.node.data.title} + {`${getAnswersByNode(props)} ${ + props.node.data.units ?? "" + }`} ); } @@ -463,8 +467,8 @@ function AddressInput(props: ComponentProps) { return ( <> -
{props.node.data.title}
-
+ {props.node.data.title} + {line1}
{line2} @@ -480,7 +484,7 @@ function AddressInput(props: ComponentProps) { {country} ) : null} -
+ ); } @@ -492,8 +496,8 @@ function ContactInput(props: ComponentProps) { return ( <> -
{props.node.data.title}
-
+ {props.node.data.title} + {[title, firstName, lastName].filter(Boolean).join(" ").trim()}
{organisation ? ( @@ -505,7 +509,7 @@ function ContactInput(props: ComponentProps) { {phone}
{email} -
+ ); } @@ -520,8 +524,8 @@ function FileUploadAndLabel(props: ComponentProps) { return ( <> -
{props.node.data.title}
-
+ {props.node.data.title} +
    {uniqueFilenames.length ? uniqueFilenames.map((filename, index) => ( @@ -529,7 +533,7 @@ function FileUploadAndLabel(props: ComponentProps) { )) : "No files uploaded"}
-
+ ); } @@ -537,8 +541,8 @@ function FileUploadAndLabel(props: ComponentProps) { function Debug(props: ComponentProps) { return ( <> -
{JSON.stringify(props.node.data)}
-
{JSON.stringify(props.userData?.answers)}
+ {JSON.stringify(props.node.data)} + {JSON.stringify(props.userData?.answers)} ); } diff --git a/editor.planx.uk/src/lib/featureFlags.ts b/editor.planx.uk/src/lib/featureFlags.ts index 31cbfdf23c..1e270e08ee 100644 --- a/editor.planx.uk/src/lib/featureFlags.ts +++ b/editor.planx.uk/src/lib/featureFlags.ts @@ -1,5 +1,5 @@ // add/edit/remove feature flags in array below -const AVAILABLE_FEATURE_FLAGS = [] as const; +const AVAILABLE_FEATURE_FLAGS = ["SUBMISSION_VIEW"] as const; type FeatureFlag = (typeof AVAILABLE_FEATURE_FLAGS)[number]; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/DataManagerSettings.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/DataManagerSettings.tsx index a4aabe041f..04aa4f159c 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Settings/DataManagerSettings.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/DataManagerSettings.tsx @@ -5,19 +5,17 @@ import { FeaturePlaceholder } from "ui/editor/FeaturePlaceholder"; const DataManagerSettings: React.FC = () => { return ( - <> - - - Data Manager - - - Manage the data that your service uses and makes available via its API - - - - + + + Data Manager + + + Manage the data that your service uses and makes available via its API + + + - + ); }; export default DataManagerSettings; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceFlags.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceFlags.tsx index 5dc2aa5856..6c4c92ffc5 100644 --- a/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceFlags.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/ServiceFlags.tsx @@ -5,20 +5,18 @@ import { FeaturePlaceholder } from "ui/editor/FeaturePlaceholder"; const ServiceFlags: React.FC = () => { return ( - <> - - - Service flags - - - Manage the flag sets that this service uses. Flags at the top of a set - override flags below. - - - - + + + Service flags + + + Manage the flag sets that this service uses. Flags at the top of a set + override flags below. + + + - + ); }; export default ServiceFlags; diff --git a/editor.planx.uk/src/pages/FlowEditor/components/Settings/Submissions/index.tsx b/editor.planx.uk/src/pages/FlowEditor/components/Settings/Submissions/index.tsx new file mode 100644 index 0000000000..cf8cafe475 --- /dev/null +++ b/editor.planx.uk/src/pages/FlowEditor/components/Settings/Submissions/index.tsx @@ -0,0 +1,22 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import React from "react"; +import { FeaturePlaceholder } from "ui/editor/FeaturePlaceholder"; + +const Submissions: React.FC = () => { + return ( + + + Submissions + + + View data on the user submitted applications for this service. + + + + + + ); +}; + +export default Submissions; diff --git a/editor.planx.uk/src/pages/FlowEditor/lib/analyticsProvider.tsx b/editor.planx.uk/src/pages/FlowEditor/lib/analyticsProvider.tsx index ec8dc14ae7..da4a661b2f 100644 --- a/editor.planx.uk/src/pages/FlowEditor/lib/analyticsProvider.tsx +++ b/editor.planx.uk/src/pages/FlowEditor/lib/analyticsProvider.tsx @@ -462,7 +462,7 @@ export const AnalyticsProvider: React.FC<{ children: React.ReactNode }> = ({ ) { if (shouldSkipTracking()) return; - const allowListAnswers = getAllowListAnswers(breadcrumb); + const allowListAnswers = getAllowListAnswers(nodeId, breadcrumb); if (!allowListAnswers) return; await publicClient.mutate({ @@ -491,17 +491,50 @@ export const AnalyticsProvider: React.FC<{ children: React.ReactNode }> = ({ } function getAllowListAnswers( + nodeId: string, breadcrumb: Store.userData, - ): Record[] | undefined { + ): Record[] | undefined { + const answers = getAnswers(nodeId); + const data = getData(breadcrumb); + + const allowListAnswers = [...answers, ...data]; + if (!allowListAnswers.length) return; + + return allowListAnswers; + } + + /** + * Extract allowlist answers from user answers + * e.g. from Checklist or Question components + */ + function getAnswers(nodeId: string) { + const { data } = flow[nodeId]; + const nodeFn: string = data?.fn || data?.val; + if (!nodeFn || !ALLOW_LIST.includes(nodeFn)) return []; + + const answerIds = breadcrumbs[nodeId]?.answers; + if (!answerIds) return []; + + const answerValues = answerIds.map((answerId) => flow[answerId]?.data?.val); + + // Match data structure of `allow_list_answers` column + const answers = [ {[nodeFn]: answerValues } ]; + + return answers; + } + + /** + * Extract allowlist answers from breadcrumb data + * e.g. data set automatically by components such as DrawBoundary + */ + function getData(breadcrumb: Store.userData) { const dataSetByNode = breadcrumb.data; - if (!dataSetByNode) return; + if (!dataSetByNode) return []; const answerValues = Object.entries(dataSetByNode) .filter(([key, value]) => ALLOW_LIST.includes(key) && Boolean(value)) .map(([key, value]) => ({ [key]: value })); - if (!answerValues.length) return; - return answerValues; } diff --git a/editor.planx.uk/src/routes/flowSettings.tsx b/editor.planx.uk/src/routes/flowSettings.tsx index a6e154c55c..de66efe0da 100644 --- a/editor.planx.uk/src/routes/flowSettings.tsx +++ b/editor.planx.uk/src/routes/flowSettings.tsx @@ -1,4 +1,5 @@ import gql from "graphql-tag"; +import { hasFeatureFlag } from "lib/featureFlags"; import { publicClient } from "lib/graphql"; import { compose, @@ -12,6 +13,7 @@ import { import DataManagerSettings from "pages/FlowEditor/components/Settings/DataManagerSettings"; import ServiceFlags from "pages/FlowEditor/components/Settings/ServiceFlags"; import ServiceSettings from "pages/FlowEditor/components/Settings/ServiceSettings"; +import Submissions from "pages/FlowEditor/components/Settings/Submissions"; import { useStore } from "pages/FlowEditor/lib/store"; import React from "react"; @@ -19,6 +21,30 @@ import Settings from "../pages/FlowEditor/components/Settings"; import type { FlowSettings } from "../types"; import { makeTitle } from "./utils"; +const standardTabs = [ + { + name: "Service", + route: "service", + Component: ServiceSettings, + }, + { + name: "Service Flags", + route: "flags", + Component: ServiceFlags, + }, + { + name: "Data", + route: "data-manager", + Component: DataManagerSettings, + }, +]; + +const submissionsTab = { + name: "Submissions", + route: "submissions", + Component: Submissions, +}; + const flowSettingsRoutes = compose( withData((req) => ({ mountpath: req.mountpath, @@ -58,32 +84,18 @@ const flowSettingsRoutes = compose( const settings: FlowSettings = data.flows[0].settings; useStore.getState().setFlowSettings(settings); + function getTabs() { + const isUsingFeatureFlag = hasFeatureFlag("SUBMISSION_VIEW"); + return isUsingFeatureFlag + ? [...standardTabs, submissionsTab] + : standardTabs; + } + return { title: makeTitle( [req.params.team, req.params.flow, "Flow Settings"].join("/"), ), - view: ( - - ), + view: , }; }); }), diff --git a/hasura.planx.uk/metadata/tables.yaml b/hasura.planx.uk/metadata/tables.yaml index 574b108f7b..3b9c7d0f62 100644 --- a/hasura.planx.uk/metadata/tables.yaml +++ b/hasura.planx.uk/metadata/tables.yaml @@ -1358,6 +1358,54 @@ - locked_at: _is_null: true check: null +- table: + name: submission_services_summary + schema: public + select_permissions: + - role: platformAdmin + permission: + columns: + - number_times_resumed + - sent_to_bops + - sent_to_email + - sent_to_uniform + - user_clicked_save + - user_invited_to_pay + - session_length_days + - bops_applications + - email_applications + - payment_requests + - payment_status + - uniform_applications + - service_slug + - session_id + - team_slug + - created_at + - submitted_at + filter: {} + comment: "" + - role: teamEditor + permission: + columns: + - number_times_resumed + - sent_to_bops + - sent_to_email + - sent_to_uniform + - user_clicked_save + - user_invited_to_pay + - session_length_days + - bops_applications + - email_applications + - payment_requests + - payment_status + - uniform_applications + - service_slug + - session_id + - team_slug + - created_at + - submitted_at + filter: {} + comment: "" - table: name: team_integrations schema: public diff --git a/hasura.planx.uk/migrations/1709565833346_run_sql_migration/down.sql b/hasura.planx.uk/migrations/1709565833346_run_sql_migration/down.sql new file mode 100644 index 0000000000..2d6d0e9244 --- /dev/null +++ b/hasura.planx.uk/migrations/1709565833346_run_sql_migration/down.sql @@ -0,0 +1,14 @@ +-- Revoke select permissions from tables used by Metabase +REVOKE SELECT ON public.analytics FROM metabase_read_only; +REVOKE SELECT ON public.analytics_logs FROM metabase_read_only; + +-- Revoke select permissions from views used by Metabase +REVOKE SELECT ON public.analytics_summary FROM metabase_read_only; +REVOKE SELECT ON public.feedback_summary FROM metabase_read_only; +REVOKE SELECT ON public.submission_services_summary FROM metabase_read_only; + +-- Revoke usage on schema +REVOKE USAGE ON SCHEMA public FROM metabase_read_only; + +-- Drop the role +DROP ROLE IF EXISTS metabase_read_only; diff --git a/hasura.planx.uk/migrations/1709565833346_run_sql_migration/up.sql b/hasura.planx.uk/migrations/1709565833346_run_sql_migration/up.sql new file mode 100644 index 0000000000..ade696cb4b --- /dev/null +++ b/hasura.planx.uk/migrations/1709565833346_run_sql_migration/up.sql @@ -0,0 +1,14 @@ +-- Create the role +CREATE ROLE metabase_read_only; + +-- Grant usage on schema +GRANT USAGE ON SCHEMA public TO metabase_read_only; + +-- (Temp) Grant select permissions on tables used by Metabase in current SQL queries +GRANT SELECT ON public.analytics TO metabase_read_only; +GRANT SELECT ON public.analytics_logs TO metabase_read_only; + +-- Grant select permissions on views used by Metabase +GRANT SELECT ON public.analytics_summary TO metabase_read_only; +GRANT SELECT ON public.feedback_summary TO metabase_read_only; +GRANT SELECT ON public.submission_services_summary TO metabase_read_only; \ No newline at end of file diff --git a/hasura.planx.uk/migrations/1710258204814_alter_view_submission_services_summary_expand_application_data/down.sql b/hasura.planx.uk/migrations/1710258204814_alter_view_submission_services_summary_expand_application_data/down.sql new file mode 100644 index 0000000000..4f59e7acaa --- /dev/null +++ b/hasura.planx.uk/migrations/1710258204814_alter_view_submission_services_summary_expand_application_data/down.sql @@ -0,0 +1,50 @@ +drop view public.submission_services_summary; + +-- Previous instance of view from hasura.planx.uk/migrations/1700072112794_create_view_submission_services_summary/up.sql +CREATE OR REPLACE VIEW public.submission_services_summary AS +with resumes_per_session as ( + select + session_id, + count(id) as number_times_resumed + from reconciliation_requests + group by session_id +) +select + ls.id 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, + case + when pr.id is null + then false + else true + end as user_invited_to_pay, + case + when ba.bops_id is null + then false + else true + end as sent_to_bops, + case + when ua.idox_submission_id is null + then false + else true + end as sent_to_uniform, + case + when ea.id is null + then false + else true + end as sent_to_email +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 pr on pr.session_id = ls.id + left join bops_applications ba on ba.session_id = ls.id::text + left join uniform_applications ua on ua.submission_reference = ls.id::text + left join email_applications ea on ea.session_id = ls.id +where f.slug IS NOT NULL + and t.slug IS NOT NULL; \ No newline at end of file diff --git a/hasura.planx.uk/migrations/1710258204814_alter_view_submission_services_summary_expand_application_data/up.sql b/hasura.planx.uk/migrations/1710258204814_alter_view_submission_services_summary_expand_application_data/up.sql new file mode 100644 index 0000000000..28b44fde0c --- /dev/null +++ b/hasura.planx.uk/migrations/1710258204814_alter_view_submission_services_summary_expand_application_data/up.sql @@ -0,0 +1,115 @@ +drop view "public"."submission_services_summary"; + +create or replace view "public"."submission_services_summary" as + with resumes_per_session as ( + select + session_id, + count(id) as number_times_resumed + from reconciliation_requests + group by session_id +), bops_agg as ( + select + session_id, + json_agg( + json_build_object( + 'id', bops_id, + 'submitted_at', created_at, + 'destination_url', destination_url + ) order by created_at desc + ) as bops_applications + from bops_applications + group by session_id +), email_agg as ( + select + session_id, + json_agg( + json_build_object( + 'id', id, + 'recipient', recipient, + 'submitted_at', created_at + ) order by created_at desc + ) as email_applications + from email_applications + group by session_id +), uniform_agg as ( + select + submission_reference, + json_agg( + json_build_object( + 'id', idox_submission_id, + 'submitted_at', created_at + ) order by created_at desc + ) as uniform_applications + from uniform_applications + group by submission_reference +), payment_requests_agg as ( + select + session_id, + json_agg( + json_build_object( + 'id', id, + 'created_at', created_at, + 'paid_at', paid_at, + 'govpay_payment_id', govpay_payment_id + ) order by created_at desc + ) as payment_requests + from payment_requests + group by session_id +), payment_status_agg as ( + select + session_id, + json_agg( + json_build_object( + 'govpay_payment_id', payment_id, + 'created_at', created_at, + 'status', status + ) order by created_at desc + ) as payment_status + from payment_status + group by 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, + 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 +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 +where f.slug is not null + and t.slug is not null; diff --git a/infrastructure/application/Pulumi.staging.yaml b/infrastructure/application/Pulumi.staging.yaml index bff13762f9..ac8e78abb8 100644 --- a/infrastructure/application/Pulumi.staging.yaml +++ b/infrastructure/application/Pulumi.staging.yaml @@ -21,7 +21,7 @@ config: application:gov-uk-pay-token-camden: secure: AAABAA2gkhNBs2hOfIkhHiA50MF3X8xnaGvLVzWdg0OOTl9qOKgtCjS76/XBIpGsGEyFbtHwuOgWhPw1qgql7MBO+pTnfLzDc8WcbxQFMbIjKUAgqF4yUMu75jcOiJ9XadNq application:gov-uk-pay-token-gloucester: - secure: AAABANCvH7gf+3Bs9vZxg1QkkFUDnDnn0dn9n0UlDo3rJdF5ThFzqg4dc+mSxAyQ4OXZLPxvFmVmMY9QZNSZKGqXYSY2CJjed2GeGmQ5zHAOzSjkomxQqtpaRANAb3s7NQkn + secure: AAABAMzflVg0cd5sjaETPp/s+OhgAr9UC4p8B72HiLHLUmNoFlsdWmCi4Z9rX6fEx+R008e0JHTz6REYQXWegH80QYMhKsUonmMON8/QMiz3AbDfZnEOGovMuC7mFLGpRDqn application:gov-uk-pay-token-lambeth: secure: AAABAPy5USkd8/hwq6vFXP45BXsYFUltR6gj8PoiZkOLRPUd1wgQ3Yhgc1Cyn+lb5cZrXBoVPjuVhm/UvBN82DNzRTl2TxAakCQQIrBU5xil+m9UnbY82CNSMDuEaWwMpR3C application:gov-uk-pay-token-medway: diff --git a/infrastructure/application/index.ts b/infrastructure/application/index.ts index e203d5923b..6149575a7d 100644 --- a/infrastructure/application/index.ts +++ b/infrastructure/application/index.ts @@ -97,6 +97,10 @@ export = async () => { superuser: false, }); const metabasePgPassword = config.requireSecret("metabasePgPassword"); + + // Setup role and database for internal Metabase application data, such as dashboards and queries + // This is separate to the postgres/public one used to hold PlanX application data + // Docs: https://www.metabase.com/docs/latest/installation-and-operation/configuring-application-database const role = new postgres.Role( "metabase", {