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

feat: implement report problem with form submission API path #36

Merged
merged 13 commits into from
Sep 5, 2024
29 changes: 23 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,12 +1,29 @@
# Localstack
# Required when running the API locally so that it communicates with your Localstack instance
# (required) Environment mode (local, staging, production)
# Should be set to local when developers are running the API locally as it will disable some feature's side effects (e.g Freshdesk ticket creation)

ENVIRONMENT_MODE=local

# (required) Freshdesk
# If the environment mode is set to local, you can left it empty as there will not be any Freshdesk request sent

FRESHDESK_API_KEY=""

# (optional) Localstack
# This will be required if you want your application to target your LocalStack instance

LOCALSTACK_ENDPOINT=http://127.0.0.1:4566

# Redis
# (required) Redis

REDIS_URL=redis://redis:6379

# Zitadel
PROJECT_ID=123 # This is the GCForms project ID in Zitadel
# (required) Zitadel
# This is required by the authentication feature but can be left empty if you are disabling it when doing local tests

ZITADEL_DOMAIN=https://auth.forms-staging.cdssandbox.xyz
ZITADEL_TOKEN_URL=https://auth.forms-staging.cdssandbox.xyz/oauth/v2/token
ZITADEL_APPLICATION_KEY=YOUR_APPLICATION_KEY

# (optional) Zitadel but only used by the utilTokenGeneration script to generate access token

PROJECT_ID=123 # This is the GCForms project ID in Zitadel
ZITADEL_TOKEN_URL=https://auth.forms-staging.cdssandbox.xyz/oauth/v2/token
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"axios": "^1.7.3",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"express-validator": "^7.2.0",
"jose": "^5.6.3",
"redis": "^4.7.0"
},
Expand Down
23 changes: 23 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

38 changes: 36 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,35 @@
import dotenv from "dotenv";

export enum EnvironmentMode {
Local = "local",
craigzour marked this conversation as resolved.
Show resolved Hide resolved
Staging = "staging",
Production = "production",
}

// Load environment variables

dotenv.config();

// AWS SDK

export const AWS_REGION: string = "ca-central-1";

// Environment mode

export const ENVIRONMENT_MODE: EnvironmentMode =
mapEnvironmentModeFromStringToEnum(loadRequiredEnvVar("ENVIRONMENT_MODE"));

// Express

export const SERVER_PORT: number = 3001;

// Freshdesk

export const FRESHDESK_API_URL: string = "https://cds-snc.freshdesk.com/api";

export const FRESHDESK_API_KEY: string =
loadRequiredEnvVar("FRESHDESK_API_KEY");
craigzour marked this conversation as resolved.
Show resolved Hide resolved

// Local configuration

export const LOCALSTACK_ENDPOINT: string | undefined = loadOptionalEnvVar(
Expand All @@ -22,12 +42,12 @@ export const REDIS_URL: string = loadRequiredEnvVar("REDIS_URL");

// Zitadel

export const ZITADEL_DOMAIN: string = loadRequiredEnvVar("ZITADEL_DOMAIN");

export const ZITADEL_APPLICATION_KEY: string = loadRequiredEnvVar(
"ZITADEL_APPLICATION_KEY",
);

export const ZITADEL_DOMAIN: string = loadRequiredEnvVar("ZITADEL_DOMAIN");

// Internal function to load environment variables

function loadOptionalEnvVar(envVarName: string): string | undefined {
Expand All @@ -43,3 +63,17 @@ function loadRequiredEnvVar(envVarName: string): string {

return envVar;
}

function mapEnvironmentModeFromStringToEnum(
environmentMode: string,
): EnvironmentMode {
if (environmentMode === "staging") {
return EnvironmentMode.Staging;
}

if (environmentMode === "production") {
return EnvironmentMode.Production;
}

return EnvironmentMode.Local;
craigzour marked this conversation as resolved.
Show resolved Hide resolved
}
70 changes: 70 additions & 0 deletions src/lib/support/freshdeskApiClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import axios from "axios";
import { FRESHDESK_API_KEY, FRESHDESK_API_URL } from "@src/config";

export type FreshdeskTicketPayload = {
name: string;
email: string;
type: string;
subject: string;
tags: string[];
description: string;
preferredLanguage: "en" | "fr";
};

export async function createFreshdeskTicket(
payload: FreshdeskTicketPayload,
): Promise<void> {
try {
await axios({
url: `${FRESHDESK_API_URL}/v2/tickets`,
method: "POST",
timeout: 5000,
headers: {
"Content-Type": "application/json",
Authorization: `Basic ${btoa(`${FRESHDESK_API_KEY}:X`)}`,
},
data: {
name: payload.name,
email: payload.email,
type: payload.type,
subject: payload.subject,
tags: payload.tags,
description: payload.description,
custom_fields: {
cf_language:
payload.preferredLanguage === "en" ? "English" : "Français",
},
source: 2,
priority: 1,
status: 2,
product_id: 61000000642,
group_id: 61000172262,
},
});
} catch (error) {
let errorMessage = "";

if (axios.isAxiosError(error)) {
if (error.response) {
/*
* The request was made and the server responded with a
* status code that falls out of the range of 2xx
*/
errorMessage = `Freshdesk API errored with status code ${error.response.status} and returned the following errors ${JSON.stringify(error.response.data)}.`;
} else if (error.request) {
/*
* The request was made but no response was received, `error.request`
* is an instance of XMLHttpRequest in the browser and an instance
* of http.ClientRequest in Node.js
*/
errorMessage = "Request timed out.";
}
} else if (error instanceof Error) {
errorMessage = `${(error as Error).message}.`;
}

throw new Error(
`Failed to create Freshdesk ticket. Reason: ${errorMessage}`,
);
}
}
73 changes: 73 additions & 0 deletions src/lib/support/notifySupportAboutFormSubmissionProblem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { createFreshdeskTicket } from "@lib/support/freshdeskApiClient";
import { EnvironmentMode } from "@src/config";

export async function notifySupportAboutFormSubmissionProblem(
formId: string,
submissionName: string,
contactEmail: string,
description: string,
preferredLanguage: "en" | "fr",
environmentMode: EnvironmentMode,
): Promise<void> {
try {
await createFreshdeskTicket({
name: contactEmail,
email: contactEmail,
type: "Problem",
subject: "Problem with GC Forms / Problème avec Formulaires GC",
tags: [tagFromEnvironmentMode(environmentMode), "Forms_API_Submission"],
description: prepareTicketDescription(
formId,
submissionName,
contactEmail,
description,
),
preferredLanguage: preferredLanguage,
});
} catch (error) {
console.error(
`[support] Failed to notify support about form submission problem. FormId: ${formId} / SubmissionName: ${submissionName} / Contact email: ${contactEmail}. Reason: ${JSON.stringify(
error,
Object.getOwnPropertyNames(error),
)}`,
);

throw error;
}
}

function tagFromEnvironmentMode(environmentMode: EnvironmentMode): string {
switch (environmentMode) {
case EnvironmentMode.Local:
return "Forms_Dev";
case EnvironmentMode.Staging:
return "Forms_Staging";
case EnvironmentMode.Production:
return "Forms_Production";
}
}

function prepareTicketDescription(
formId: string,
submissionName: string,
contactEmail: string,
description: string,
): string {
return `
User (${contactEmail}) reported problems with some of the submissions for form \`${formId}\`.<br/>
<br/>
Submission names:<br/>
${submissionName}
<br/>
Description:<br/>
${description}<br/>
****<br/>
L'utilisateur (${contactEmail}) a signalé avoir rencontré des problèmes avec certaines des soumissions du formulaire \`${formId}\`.<br/>
<br/>
Nom des soumissions:<br/>
${submissionName}
<br/>
Description:<br/>
${description}<br/>
`;
}
2 changes: 1 addition & 1 deletion src/lib/vault/confirmFormSubmission.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {
FormSubmissionAlreadyConfirmedException,
FormSubmissionNotFoundException,
FormSubmissionIncorrectConfirmationCodeException,
} from "./dataStructures/exceptions";
} from "@lib/vault/dataStructures/exceptions";

const REMOVAL_DATE_DELAY_IN_DAYS = 30;

Expand Down
50 changes: 50 additions & 0 deletions src/lib/vault/reportProblemWithFormSubmission.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { UpdateCommand } from "@aws-sdk/lib-dynamodb";
import { AwsServicesConnector } from "@lib/awsServicesConnector";
import { FormSubmissionNotFoundException } from "@lib/vault/dataStructures/exceptions";
import { ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";

export async function reportProblemWithFormSubmission(
formId: string,
submissionName: string,
): Promise<void> {
try {
await AwsServicesConnector.getInstance().dynamodbClient.send(
new UpdateCommand({
TableName: "Vault",
Key: {
FormID: formId,
NAME_OR_CONF: `NAME#${submissionName}`,
},
/**
* An update operation will insert a new item in DynamoDB if the targeted one does not exist. Since this is not what we want to happen
* with the report problem operation, we are adding a `attribute_exists` check. This way, if no item was found with the composite primary key
* the condition will fail as no `Status` property will be found.
*/
ConditionExpression: "attribute_exists(#status)",
UpdateExpression:
"SET #status = :status, ProblemTimestamp = :problemTimestamp REMOVE RemovalDate",
ExpressionAttributeNames: {
"#status": "Status",
},
ExpressionAttributeValues: {
":status": "Problem",
":problemTimestamp": Date.now(),
},
ReturnValuesOnConditionCheckFailure: "NONE",
}),
);
} catch (error) {
if (error instanceof ConditionalCheckFailedException) {
throw new FormSubmissionNotFoundException();
}

console.error(
`[dynamodb] Failed to report problem with form submission. FormId: ${formId} / SubmissionName: ${submissionName}. Reason: ${JSON.stringify(
error,
Object.getOwnPropertyNames(error),
)}`,
);

throw error;
}
}
Loading
Loading