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

Production deploy #2583

Merged
merged 9 commits into from
Dec 20, 2023
1 change: 1 addition & 0 deletions api.planx.uk/lib/hasura/metadata/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface ScheduledEvent {

export interface CombinedResponse {
bops?: ScheduledEventResponse;
bops_v2?: ScheduledEventResponse;
uniform?: ScheduledEventResponse;
email?: ScheduledEventResponse;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,17 @@ const createPaymentSendEvents = async (
comment: `bops_submission_${payload.sessionId}`,
});
combinedResponse[Destination.BOPS] = bopsEvent;

const isProduction = process.env.APP_ENVIRONMENT === "production";
if (!isProduction) {
const bopsV2Event = await createScheduledEvent({
webhook: `{{HASURA_PLANX_API_URL}}/bops-v2/${teamSlug}`,
schedule_at: new Date(now.getTime() + 30 * 1000),
payload: eventPayload,
comment: `bops_v2_submission_${payload.sessionId}`,
});
combinedResponse["bops_v2"] = bopsV2Event;
}
}

if (destinations.includes(Destination.Email)) {
Expand Down
112 changes: 110 additions & 2 deletions api.planx.uk/modules/send/bops/bops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import supertest from "supertest";
import { queryMock } from "../../../tests/graphqlQueryMock";
import app from "../../../server";
import { expectedPayload } from "../../../tests/mocks/bopsMocks";
import { expectedPlanningPermissionPayload } from "../../../tests/mocks/digitalPlanningDataMocks";

jest.mock("../../saveAndReturn/service/utils", () => ({
markSessionAsSubmitted: jest.fn(),
Expand All @@ -22,6 +23,10 @@ jest.mock("@opensystemslab/planx-core", () => {
exportData: expectedPayload,
redactedExportData: expectedPayload,
});
this.export.digitalPlanningDataPayload = () =>
jest.fn().mockResolvedValue({
exportData: expectedPlanningPermissionPayload,
});
}
},
};
Expand All @@ -36,7 +41,10 @@ describe(`sending an application to BOPS`, () => {
data: {
bopsApplications: [],
},
variables: { session_id: "123" },
variables: {
session_id: "123",
search_string: "%/api/v1/planning_applications",
},
});

queryMock.mockQuery({
Expand Down Expand Up @@ -102,7 +110,10 @@ describe(`sending an application to BOPS`, () => {
{ response: { message: "Application created", id: "bops_app_id" } },
],
},
variables: { session_id: "previously_submitted_app" },
variables: {
session_id: "previously_submitted_app",
search_string: "%/api/v1/planning_applications",
},
});

await supertest(app)
Expand All @@ -119,3 +130,100 @@ describe(`sending an application to BOPS`, () => {
});
});
});

describe(`sending an application to BOPS v2`, () => {
beforeEach(() => {
queryMock.mockQuery({
name: "FindApplication",
data: {
bopsApplications: [],
},
variables: {
session_id: "123",
search_string: "%/api/v2/planning_applications",
},
});

queryMock.mockQuery({
name: "CreateBopsApplication",
matchOnVariables: false,
data: {
insertBopsApplication: { id: 22 },
},
});
});

it("successfully proxies request and returns hasura id", async () => {
nock(`${submissionURL}/api/v2/planning_applications`).post("").reply(200, {
application: "0000123",
});

await supertest(app)
.post("/bops-v2/southwark")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY })
.send({ payload: { sessionId: "123" } })
.expect(200)
.then((res) => {
expect(res.body).toEqual({
application: { id: 22, bopsResponse: { application: "0000123" } },
});
});
});

it("requires auth", async () => {
await supertest(app)
.post("/bops-v2/southwark")
.send({ payload: { sessionId: "123" } })
.expect(401);
});

it("throws an error if payload is missing", async () => {
await supertest(app)
.post("/bops-v2/southwark")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY })
.send({ payload: null })
.expect(400)
.then((res) => {
expect(res.body.error).toMatch(/Missing application/);
});
});

it("throws an error if team is unsupported", async () => {
await supertest(app)
.post("/bops-v2/unsupported-team")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY })
.send({ payload: { sessionId: "123" } })
.expect(400)
.then((res) => {
expect(res.body.error).toMatch(/not enabled for this local authority/);
});
});

it("does not re-send an application which has already been submitted", async () => {
queryMock.mockQuery({
name: "FindApplication",
data: {
bopsApplications: [
{ response: { message: "Application created", id: "bops_app_id" } },
],
},
variables: {
session_id: "previously_submitted_app",
search_string: "%/api/v2/planning_applications",
},
});

await supertest(app)
.post("/bops-v2/southwark")
.set({ Authorization: process.env.HASURA_PLANX_API_KEY })
.send({ payload: { sessionId: "previously_submitted_app" } })
.expect(200)
.then((res) => {
expect(res.body).toEqual({
sessionId: "previously_submitted_app",
bopsId: "bops_app_id",
message: "Skipping send, already successfully submitted",
});
});
});
});
148 changes: 144 additions & 4 deletions api.planx.uk/modules/send/bops/bops.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ interface CreateBopsApplication {
bopsId: string;
};
}

const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => {
// `/bops/:localAuthority` is only called via Hasura's scheduled event webhook now, so body is wrapped in a "payload" key
const { payload }: SendToBOPSRequest = req.body;
Expand All @@ -30,7 +31,7 @@ const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => {
}

// confirm that this session has not already been successfully submitted before proceeding
const submittedApp = await checkBOPSAuditTable(payload?.sessionId);
const submittedApp = await checkBOPSAuditTable(payload?.sessionId, "v1");
if (submittedApp?.message === "Application created") {
return res.status(200).send({
sessionId: payload?.sessionId,
Expand Down Expand Up @@ -144,6 +145,139 @@ const sendToBOPS = async (req: Request, res: Response, next: NextFunction) => {
}
};

const sendToBOPSV2 = async (
req: Request,
res: Response,
next: NextFunction,
) => {
// `/bops/:localAuthority` is only called via Hasura's scheduled event webhook now, so body is wrapped in a "payload" key
const { payload }: SendToBOPSRequest = req.body;
if (!payload) {
return next(
new ServerError({
status: 400,
message: `Missing application payload data to send to BOPS`,
}),
);
}

// confirm that this session has not already been successfully submitted before proceeding
const submittedApp = await checkBOPSAuditTable(payload?.sessionId, "v2");
if (submittedApp?.message === "Application created") {
return res.status(200).send({
sessionId: payload?.sessionId,
bopsId: submittedApp?.id,
message: `Skipping send, already successfully submitted`,
});
}

// confirm this local authority (aka team) is supported by BOPS before creating the proxy
// a local or staging API instance should send to the BOPS staging endpoint
// production should send to the BOPS production endpoint
const localAuthority = req.params.localAuthority;
const bopsSubmissionURLEnvName = `BOPS_SUBMISSION_URL_${localAuthority.toUpperCase()}`;
const bopsSubmissionURL = process.env[bopsSubmissionURLEnvName];
const isSupported = Boolean(bopsSubmissionURL);
if (!isSupported) {
return next(
new ServerError({
status: 400,
message: `Back-office Planning System (BOPS) is not enabled for this local authority (${localAuthority})`,
}),
);
}
const target = `${bopsSubmissionURL}/api/v2/planning_applications`;
const exportData = await $api.export.digitalPlanningDataPayload(
payload?.sessionId,
);

try {
const bopsResponse = await axios({
method: "POST",
url: target,
adapter: "http",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${process.env.BOPS_API_TOKEN}`,
},
data: exportData,
})
.then(async (res: AxiosResponse<{ id: string }>) => {
// Mark session as submitted so that reminder and expiry emails are not triggered
markSessionAsSubmitted(payload?.sessionId);

const applicationId = await $api.client.request<CreateBopsApplication>(
gql`
mutation CreateBopsApplication(
$bops_id: String = ""
$destination_url: String!
$request: jsonb!
$req_headers: jsonb = {}
$response: jsonb = {}
$response_headers: jsonb = {}
$session_id: String!
) {
insertBopsApplication: insert_bops_applications_one(
object: {
bops_id: $bops_id
destination_url: $destination_url
request: $request
req_headers: $req_headers
response: $response
response_headers: $response_headers
session_id: $session_id
}
) {
id
bopsId: bops_id
}
}
`,
{
bops_id: res.data.id,
destination_url: target,
request: exportData,
response: res.data,
response_headers: res.headers,
session_id: payload?.sessionId,
},
);

return {
application: {
...applicationId.insertBopsApplication,
bopsResponse: res.data,
},
};
})
.catch((error) => {
if (error.response) {
throw new Error(
`Sending to BOPS v2 failed (${localAuthority}):\n${JSON.stringify(
error.response.data,
null,
2,
)}`,
);
} else {
// re-throw other errors
throw new Error(
`Sending to BOPS v2 failed (${localAuthority}):\n${error}`,
);
}
});
res.send(bopsResponse);
} catch (err) {
next(
new ServerError({
status: 500,
message: `Sending to BOPS v2 failed (${localAuthority})`,
cause: err,
}),
);
}
};

interface FindApplication {
bopsApplications: {
response: Record<string, string>;
Expand All @@ -155,12 +289,17 @@ interface FindApplication {
*/
async function checkBOPSAuditTable(
sessionId: string,
version: "v1" | "v2",
): Promise<Record<string, string>> {
const searchString = `%/api/${version}/planning_applications`;
const application = await $api.client.request<FindApplication>(
gql`
query FindApplication($session_id: String = "") {
query FindApplication($session_id: String = "", $search_string: String) {
bopsApplications: bops_applications(
where: { session_id: { _eq: $session_id } }
where: {
session_id: { _eq: $session_id }
destination_url: { _like: $search_string }
}
order_by: { created_at: desc }
) {
response
Expand All @@ -169,10 +308,11 @@ async function checkBOPSAuditTable(
`,
{
session_id: sessionId,
search_string: searchString,
},
);

return application?.bopsApplications[0]?.response;
}

export { sendToBOPS };
export { sendToBOPS, sendToBOPSV2 };
11 changes: 11 additions & 0 deletions api.planx.uk/modules/send/createSendEvents/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ const createSendEvents: CreateSendEventsController = async (
comment: `bops_submission_${sessionId}`,
});
combinedResponse["bops"] = bopsEvent;

const isProduction = process.env.APP_ENVIRONMENT === "production";
if (!isProduction) {
const bopsV2Event = await createScheduledEvent({
webhook: `{{HASURA_PLANX_API_URL}}/bops-v2/${bops.localAuthority}`,
schedule_at: new Date(now.getTime() + 45 * 1000),
payload: bops.body,
comment: `bops_v2_submission_${sessionId}`,
});
combinedResponse["bops_v2"] = bopsV2Event;
}
}

if (uniform) {
Expand Down
Loading
Loading