diff --git a/README.md b/README.md index f8028b485..6bfff1d15 100644 --- a/README.md +++ b/README.md @@ -102,8 +102,8 @@ This will likely change as features are rolled out. * `REACT_APP_FEATURE_MULTILANG` - Enable/disable multi-language support in the React app. * `REACT_APP_FEATURE_CAREER_PATHWAYS` - Toggle the display of career pathways feature as well as any reference to it. * `REACT_APP_FEATURE_CAREER_NAVIGATOR` - Toggle the display of the Career Navigator landing page as well as any references to it. -* `REACT_APP_FEATURE_PINPOINT` - Show or hide any instance of the Pinpoint email collection tool. - +* `REACT_APP_SIGNUP_FOR_UPDATES` - Toggle the display of the user signup modal, which uses MailChimp on the backend. +* ##### Database Dev, QA, and production databases are hosted in AWS as SQL instances running PostgreSQL. diff --git a/backend/.env.sample b/backend/.env.sample index 620434ffb..8cd451d6b 100644 --- a/backend/.env.sample +++ b/backend/.env.sample @@ -38,4 +38,8 @@ DB_HOST_DEV='localhost' DB_PASS_AWSDEV=[DB_PASS_AWSDEV] DB_PASS_ENCODED_AWSDEV=[DB_PASS_ENCODED_AWSDEV] DB_PASS_TEST=[DB_PASS_TEST] -DB_PASS_DEV=[DB_PASS_DEV] \ No newline at end of file +DB_PASS_DEV=[DB_PASS_DEV] + +# MailChimp +MAILCHIMP_API_KEY=[MAILCHIMP_API_KEY] +MAILCHIMP_LIST_ID=[MAILCHIMP_LIST_ID] \ No newline at end of file diff --git a/backend/src/app.ts b/backend/src/app.ts index 7a78dfc42..574078573 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -9,6 +9,7 @@ import { routerFactory } from "./routes/router"; import emailSubmissionRouter from './routes/emailRoutes'; import contentfulRouter from './contentful/index'; import contactRouter from './routes/contactRoutes' +import signUpRouter from './routes/signupRoutes' import { PostgresDataClient } from "./database/data/PostgresDataClient"; import { PostgresSearchClient } from "./database/search/PostgresSearchClient"; import { findTrainingsByFactory } from "./domain/training/findTrainingsBy"; @@ -211,6 +212,7 @@ app.use(express.static(path.join(__dirname, "build"), { etag: false, lastModifie app.use(express.json()); app.use("/api", router); app.use('/api/contact', contactRouter) +app.use('/api/signup', signUpRouter) app.use('/api/emails', emailSubmissionRouter); app.use('/api/contentful', contentfulRouter); diff --git a/backend/src/controllers/signUpController.test.ts b/backend/src/controllers/signUpController.test.ts new file mode 100644 index 000000000..eaa39d756 --- /dev/null +++ b/backend/src/controllers/signUpController.test.ts @@ -0,0 +1,110 @@ +import { submitSignupForm } from "./signUpController"; +import { addSubscriberToMailchimp } from "../mailchimp/mailchimpAPI"; +import { Request, Response } from "express"; + +// Mock Mailchimp function +jest.mock("../mailchimp/mailchimpAPI", () => ({ + addSubscriberToMailchimp: jest.fn(), +})); + +describe("submitSignupForm Controller", () => { + let mockReq: Partial; + let mockRes: Partial; + + beforeEach(() => { + mockReq = { body: {} }; + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + }); + + test("should return 200 if signup is successful", async () => { + (addSubscriberToMailchimp as jest.Mock).mockResolvedValueOnce(undefined); + + mockReq.body = { + firstName: "John", + lastName: "Doe", + email: "test@example.com", + phone: "1234567890", + }; + + await submitSignupForm(mockReq as Request, mockRes as Response); + + expect(addSubscriberToMailchimp).toHaveBeenCalledWith("John", "Doe", "test@example.com", "1234567890"); + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith({ message: "Signup successful" }); + }); + + test("should return 400 if email is already registered", async () => { + (addSubscriberToMailchimp as jest.Mock).mockRejectedValueOnce(new Error("is already a list member")); + + mockReq.body = { + firstName: "John", + lastName: "Doe", + email: "test@example.com", + phone: "1234567890", + }; + + await submitSignupForm(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: `The email "test@example.com" is already registered for My Career NJ updates. If you believe this is an error or need assistance, please contact support.`, + }); + }); + + test("should return 400 for an invalid email", async () => { + (addSubscriberToMailchimp as jest.Mock).mockRejectedValueOnce(new Error("invalid email")); + + mockReq.body = { + firstName: "John", + lastName: "Doe", + email: "invalid-email", + phone: "1234567890", + }; + + await submitSignupForm(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: `The email address "invalid-email" is not valid. Please check for typos and try again.`, + }); + }); + + test("should return 400 for an invalid phone number", async () => { + (addSubscriberToMailchimp as jest.Mock).mockRejectedValueOnce(new Error("phone number invalid")); + + mockReq.body = { + firstName: "John", + lastName: "Doe", + email: "test@example.com", + phone: "123", + }; + + await submitSignupForm(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: `The phone number "123" is not valid. Please enter a US phone number in the format XXX-XXX-XXXX.`, + }); + }); + + test("should return 400 for an unexpected error", async () => { + (addSubscriberToMailchimp as jest.Mock).mockRejectedValueOnce(new Error("Some unexpected error")); + + mockReq.body = { + firstName: "John", + lastName: "Doe", + email: "test@example.com", + phone: "1234567890", + }; + + await submitSignupForm(mockReq as Request, mockRes as Response); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + error: "An unexpected error occurred. Please try again later.", + }); + }); +}); diff --git a/backend/src/controllers/signUpController.ts b/backend/src/controllers/signUpController.ts new file mode 100644 index 000000000..bd08c4d6a --- /dev/null +++ b/backend/src/controllers/signUpController.ts @@ -0,0 +1,27 @@ +import { Request, Response } from "express"; +import { addSubscriberToMailchimp } from "../mailchimp/mailchimpAPI"; + +export const submitSignupForm = async (req: Request, res: Response) => { + const { firstName, lastName, email, phone } = req.body; + + try { + await addSubscriberToMailchimp(firstName, lastName, email, phone); + return res.status(200).json({ message: "Signup successful" }); + } catch (error: unknown) { + let errorMessage = "An unexpected error occurred. Please try again later."; + + if (error instanceof Error) { + const errorMsg = error.message.toLowerCase(); + + if (errorMsg.includes("is already a list member")) { + errorMessage = `The email "${email}" is already registered for My Career NJ updates. If you believe this is an error or need assistance, please contact support.`; + } else if (errorMsg.includes("invalid email")) { + errorMessage = `The email address "${email}" is not valid. Please check for typos and try again.`; + } else if (errorMsg.includes("phone number invalid")) { + errorMessage = `The phone number "${phone}" is not valid. Please enter a US phone number in the format XXX-XXX-XXXX.`; + } + } + + return res.status(400).json({ error: errorMessage }); + } +}; diff --git a/backend/src/helpers/emailValidator.test.ts b/backend/src/helpers/emailValidator.test.ts new file mode 100644 index 000000000..e4b2c6476 --- /dev/null +++ b/backend/src/helpers/emailValidator.test.ts @@ -0,0 +1,93 @@ +import { isValidEmail } from '../helpers/emailValidator'; +import dns from 'dns'; + +jest.mock('dns'); + +const mockedDns = dns as jest.Mocked; + +describe('isValidEmail Function (Full and Root Domain Validation)', () => { + + afterEach(() => { + jest.resetAllMocks(); + }); + + test('should return false for invalid email formats', async () => { + const invalidEmails = [ + '', + 'plainaddress', + '@missingusername.com', + 'username@.nodomain', + 'username@domain.c', + 'username@domain_with_invalid_characters.com', + 'toolongusername'.repeat(5) + '@example.com', + ]; + + for (const email of invalidEmails) { + const result = await isValidEmail(email); + expect(result).toBe(false); + } + }); + + test('should return false for an invalid email format', async () => { + const result = await isValidEmail('invalid-email'); + expect(result).toBe(false); + }); + + test('should return true for a valid email with MX records on the full domain', async () => { + mockedDns.resolveMx.mockImplementation((domain, callback) => { + if (domain === 'gmail.com') { + callback(null, [{ exchange: 'mail.google.com', priority: 10 }]); + } else { + callback(null, []); + } + }); + + const result = await isValidEmail('test@gmail.com'); + expect(result).toBe(true); + }); + + test('should return true for a valid email where MX records exist on the root domain', async () => { + mockedDns.resolveMx.mockImplementation((domain, callback) => { + if (domain === 'subdomain.example.com') { + callback(null, []); + } else if (domain === 'example.com') { + callback(null, [{ exchange: 'mail.example.com', priority: 20 }]); + } + }); + + const result = await isValidEmail('test@subdomain.example.com'); + expect(result).toBe(true); + }); + + test('should return false when neither the full nor root domain has MX records', async () => { + mockedDns.resolveMx.mockImplementation((domain, callback) => { + callback(null, []); + }); + + const result = await isValidEmail('test@nodns.com'); + expect(result).toBe(false); + }); + + test('should return false when an error occurs during full domain lookup and no MX records exist on root', async () => { + mockedDns.resolveMx.mockImplementation((domain, callback) => { + if (domain === 'subdomain.fakeexample.com') { + callback(new Error('Domain not found'), []); + } else { + callback(null, []); + } + }); + + const result = await isValidEmail('test@subdomain.fakeexample.com'); + expect(result).toBe(false); + }); + + test('should return false when an error occurs during both full and root domain lookups', async () => { + mockedDns.resolveMx.mockImplementation((domain, callback) => { + callback(new Error('Domain not found'), []); + }); + + const result = await isValidEmail('test@error.com'); + expect(result).toBe(false); + }); + +}); diff --git a/backend/src/helpers/emailValidator.ts b/backend/src/helpers/emailValidator.ts new file mode 100644 index 000000000..d37e0fae1 --- /dev/null +++ b/backend/src/helpers/emailValidator.ts @@ -0,0 +1,76 @@ +import dns from 'dns'; + +const emailTester = /^[-!#$%&'*+/0-9=?A-Z^_a-z{|}~](\.?[-!#$%&'*+/0-9=?A-Z^_a-z`{|}~])*@[a-zA-Z0-9](-*\.?[a-zA-Z0-9])*\.[a-zA-Z](-?[a-zA-Z0-9])+$/; + +const validateEmailFormat = (email: string): boolean => { + if (!email || email.length > 254) { + return false; + } + + const valid = emailTester.test(email); + if (!valid) { + return false; + } + + const parts = email.split("@"); + if (parts[0].length > 64) { + return false; + } + + const domainParts = parts[1].split("."); + if (domainParts.some((part) => part.length > 63)) { + return false; + } + + return true; +}; + +export const isValidEmail = async (email: string): Promise => { + if (!validateEmailFormat(email)) { + console.log("Invalid email format"); + return false; + } + + try { + const isValidDomain = await validateDomainMX(email); + return isValidDomain; + } catch (error) { + console.error("Error during MX record validation:", error); + return false; + } +}; + +const validateDomainMX = (email: string): Promise => { + const domain = email.split('@')[1]; + const domainParts = domain.split('.'); + const rootDomain = domainParts.length > 2 ? domainParts.slice(-2).join('.') : domain; + + return new Promise((resolve) => { + // Trying full domain first + dns.resolveMx(domain, (err, addresses) => { + if (err) { + console.error("DNS error while resolving MX for domain", domain, ":", err.message); + } + if (!err && addresses.length > 0) { + console.log("Valid MX records found for", domain, ":", addresses); + return resolve(true); + } + + // Fallback to root domain if the full domain fails + dns.resolveMx(rootDomain, (rootErr, rootAddresses) => { + if (rootErr) { + console.error("DNS error while resolving MX for root domain", rootDomain, ":", rootErr.message); + } + if (!rootErr && rootAddresses.length > 0) { + console.log("Valid MX records found for", rootDomain, ":", rootAddresses); + resolve(true); + } else { + console.log("Invalid domain or no MX records for:", domain); + resolve(false); + } + }); + }); + }); +}; + + diff --git a/backend/src/helpers/nameValidator.test.ts b/backend/src/helpers/nameValidator.test.ts new file mode 100644 index 000000000..85025dd96 --- /dev/null +++ b/backend/src/helpers/nameValidator.test.ts @@ -0,0 +1,49 @@ +import { isValidName } from "./nameValidator"; + +describe("isValidName", () => { + test("should return true for a valid name", () => { + expect(isValidName("John")).toBe(true); + expect(isValidName("Alice Smith")).toBe(true); + expect(isValidName("O'Connor")).toBe(true); + expect(isValidName("Jean-Luc")).toBe(true); + }); + + test("should return false for names exceeding 50 characters", () => { + expect(isValidName("A".repeat(51))).toBe(false); + }); + + test("should return false for names with numbers", () => { + expect(isValidName("John123")).toBe(false); + }); + + test("should return false for names with special characters", () => { + expect(isValidName("John@Doe")).toBe(false); + expect(isValidName("Jane#Doe")).toBe(false); + expect(isValidName("John$Doe")).toBe(false); + }); + + test("should return false for names with underscores", () => { + expect(isValidName("John_Doe")).toBe(false); + }); + + test("should return false for empty string", () => { + expect(isValidName("")).toBe(false); + }); + + test("should return false for null or undefined", () => { + expect(isValidName(null as unknown as string)).toBe(false); + expect(isValidName(undefined as unknown as string)).toBe(false); + }); + + test("should return false for non-string inputs", () => { + expect(isValidName(123 as unknown as string)).toBe(false); + expect(isValidName({} as unknown as string)).toBe(false); + expect(isValidName([] as unknown as string)).toBe(false); + }); + + test("should allow valid names with spaces, hyphens, and apostrophes", () => { + expect(isValidName("Anne-Marie")).toBe(true); + expect(isValidName("D'Angelo")).toBe(true); + expect(isValidName("Mary Jane")).toBe(true); + }); +}); diff --git a/backend/src/helpers/nameValidator.ts b/backend/src/helpers/nameValidator.ts new file mode 100644 index 000000000..c49f04586 --- /dev/null +++ b/backend/src/helpers/nameValidator.ts @@ -0,0 +1,5 @@ +export const isValidName = (name: string): boolean => { + // Ensure name is a string and does not contain suspicious characters + const nameRegex = /^[a-zA-Z\s'-]{1,50}$/; // Allows letters, spaces, hyphens, and apostrophes + return typeof name === "string" && nameRegex.test(name); + }; \ No newline at end of file diff --git a/backend/src/helpers/phoneValidator.test.ts b/backend/src/helpers/phoneValidator.test.ts new file mode 100644 index 000000000..bdb03b8f5 --- /dev/null +++ b/backend/src/helpers/phoneValidator.test.ts @@ -0,0 +1,50 @@ +import { isValidPhoneNumber } from "./phoneValidator"; + +describe("isValidPhoneNumber", () => { + test("should return true for a valid 10-digit phone number", () => { + expect(isValidPhoneNumber("1234567890")).toBe(true); + }); + + test("should return false for a number with less than 10 digits", () => { + expect(isValidPhoneNumber("123456789")).toBe(false); + }); + + test("should return false for a number with more than 10 digits", () => { + expect(isValidPhoneNumber("12345678901")).toBe(false); + }); + + test("should return true for a valid number with dashes", () => { + expect(isValidPhoneNumber("123-456-7890")).toBe(true); + }); + + test("should return true for a valid number with spaces", () => { + expect(isValidPhoneNumber("123 456 7890")).toBe(true); + }); + + test("should return true for a valid number with parentheses", () => { + expect(isValidPhoneNumber("(123) 456-7890")).toBe(true); + }); + + test("should return false for a number containing letters", () => { + expect(isValidPhoneNumber("12345678a0")).toBe(false); + }); + + test("should return false for a number containing special characters", () => { + expect(isValidPhoneNumber("123-456-78@0")).toBe(false); + }); + + test("should return false for an empty string", () => { + expect(isValidPhoneNumber("")).toBe(false); + }); + + test("should return false for null or undefined", () => { + expect(isValidPhoneNumber(null as unknown as string)).toBe(false); + expect(isValidPhoneNumber(undefined as unknown as string)).toBe(false); + }); + + test("should return false for non-string inputs", () => { + expect(isValidPhoneNumber(1234567890 as unknown as string)).toBe(false); + expect(isValidPhoneNumber({} as unknown as string)).toBe(false); + expect(isValidPhoneNumber([] as unknown as string)).toBe(false); + }); +}); diff --git a/backend/src/helpers/phoneValidator.ts b/backend/src/helpers/phoneValidator.ts new file mode 100644 index 000000000..e8d5df0b3 --- /dev/null +++ b/backend/src/helpers/phoneValidator.ts @@ -0,0 +1,17 @@ +export const isValidPhoneNumber = (phoneNumber: string): boolean => { + if (!phoneNumber || typeof phoneNumber !== "string") { + console.error("Invalid input: phone number must be a string."); + return false; + } + + // Remove non-numeric characters + const cleanedNumber = phoneNumber.replace(/\D/g, ""); + + // Ensure it's exactly 10 digits long + if (cleanedNumber.length !== 10) { + console.error("Invalid phone number: must be exactly 10 digits."); + return false; + } + + return true; +}; \ No newline at end of file diff --git a/backend/src/mailchimp/mailchimpAPI.test.ts b/backend/src/mailchimp/mailchimpAPI.test.ts new file mode 100644 index 000000000..33c84157a --- /dev/null +++ b/backend/src/mailchimp/mailchimpAPI.test.ts @@ -0,0 +1,114 @@ +// Mock environment variables before importing the module +process.env.MAILCHIMP_API_KEY = 'dummy-us11'; +process.env.MAILCHIMP_LIST_ID = 'test123'; + +// Mock dotenv +jest.mock('dotenv', () => ({ + config: jest.fn() +})); + +import axios from 'axios'; +import { addSubscriberToMailchimp } from './mailchimpAPI'; + +// Mock axios module +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('addSubscriberToMailchimp', () => { + const MOCK_URL = 'https://us11.api.mailchimp.com/3.0/lists/test123/members'; + + beforeEach(() => { + // Clear all mocks before each test + jest.clearAllMocks(); + }); + + it('should successfully add a subscriber', async () => { + const mockResponse = { + data: { + id: '123', + email_address: 'test@example.com', + status: 'subscribed', + merge_fields: { + FNAME: 'John', + LNAME: 'Doe', + PHONE: '1234567890' + } + } + }; + + mockedAxios.post.mockResolvedValueOnce(mockResponse); + + const result = await addSubscriberToMailchimp( + 'John', + 'Doe', + 'test@example.com', + '1234567890' + ); + + expect(result).toEqual(mockResponse.data); + expect(mockedAxios.post).toHaveBeenCalledWith( + MOCK_URL, + { + email_address: 'test@example.com', + status: 'subscribed', + merge_fields: { + FNAME: 'John', + LNAME: 'Doe', + PHONE: '1234567890' + } + }, + { + auth: { + username: '', + password: 'dummy-us11' + }, + headers: { + 'Content-Type': 'application/json' + } + } + ); + }); + + it('should throw error when email is missing', async () => { + await expect(addSubscriberToMailchimp('John', 'Doe', '', '1234567890')) + .rejects + .toThrow('Email address is required.'); + }); + + it('should handle Mailchimp API error', async () => { + + const errorMessage = 'Invalid email address'; + const axiosError = new Error(errorMessage) as Error & { + response: { + data: { + detail: string; + }; + }; + }; + axiosError.response = { + data: { + detail: errorMessage + } + }; + + mockedAxios.post.mockRejectedValueOnce(axiosError); + + await expect(addSubscriberToMailchimp( + 'John', + 'Doe', + 'invalid-email', + '1234567890' + )).rejects.toThrow(errorMessage); + }); + + it('should handle network error', async () => { + mockedAxios.post.mockRejectedValueOnce(new Error('Network Error')); + + await expect(addSubscriberToMailchimp( + 'John', + 'Doe', + 'test@example.com', + '1234567890' + )).rejects.toThrow('Failed to connect to Mailchimp.'); + }); +}); \ No newline at end of file diff --git a/backend/src/mailchimp/mailchimpAPI.ts b/backend/src/mailchimp/mailchimpAPI.ts new file mode 100644 index 000000000..4028811b6 --- /dev/null +++ b/backend/src/mailchimp/mailchimpAPI.ts @@ -0,0 +1,61 @@ +import axios from "axios"; +import dotenv from "dotenv"; + +dotenv.config(); + +const API_KEY = process.env.MAILCHIMP_API_KEY || ""; +const LIST_ID = process.env.MAILCHIMP_LIST_ID || ""; +const DATACENTER = API_KEY.includes("-") ? API_KEY.split("-")[1] : ""; + +// Validate API configuration at startup +if (!API_KEY) { + throw new Error("MAILCHIMP_API_KEY is missing in environment variables."); +} + +if (!LIST_ID) { + throw new Error("MAILCHIMP_LIST_ID is missing in environment variables."); +} + +if (!DATACENTER) { + throw new Error("MAILCHIMP_API_KEY format is incorrect. Expected format: 'key-usX' (e.g., '123456-us21')."); +} + +const MAILCHIMP_URL = `https://${DATACENTER}.api.mailchimp.com/3.0/lists/${LIST_ID}/members`; + +export const addSubscriberToMailchimp = async (fname: string, lname: string, email: string, phone: string) => { + if (!email) { + throw new Error("Email address is required."); + } + + try { + const response = await axios.post( + MAILCHIMP_URL, + { + email_address: email, + status: "subscribed", + merge_fields: { + FNAME: fname, + LNAME: lname, + PHONE: phone, + }, + }, + { + auth: { + username: "", //required but anystring works here! + password: API_KEY, + }, + headers: { + "Content-Type": "application/json", + }, + } + ); + + return response.data; // Success response + } catch (error: unknown) { + if (error instanceof Error && 'response' in error && error.response && typeof error.response === 'object' && 'data' in error.response) { + throw new Error((error.response as { data?: { detail?: string } }).data?.detail || "Mailchimp API error"); + } else { + throw new Error("Failed to connect to Mailchimp."); + } + } +} diff --git a/backend/src/middleware/signUpValidation.test.ts b/backend/src/middleware/signUpValidation.test.ts new file mode 100644 index 000000000..4a41dc3db --- /dev/null +++ b/backend/src/middleware/signUpValidation.test.ts @@ -0,0 +1,119 @@ +import { validateSignupForm } from "./signUpValidations"; +import { isValidEmail } from "../helpers/emailValidator"; +import { isValidPhoneNumber } from "../helpers/phoneValidator"; +import { isValidName } from "../helpers/nameValidator"; +import { Request, Response, NextFunction } from "express"; + +// Mock validation helper functions +jest.mock("../helpers/emailValidator", () => ({ + isValidEmail: jest.fn(), +})); + +jest.mock("../helpers/phoneValidator", () => ({ + isValidPhoneNumber: jest.fn(), +})); + +jest.mock("../helpers/nameValidator", () => ({ + isValidName: jest.fn(), +})); + +describe("validateSignupForm Middleware", () => { + let mockReq: Partial; + let mockRes: Partial; + let next: NextFunction; + + beforeEach(() => { + mockReq = { body: {} }; + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + next = jest.fn(); + }); + + test("should call next() if all fields are valid", async () => { + (isValidEmail as jest.Mock).mockResolvedValue(true); + (isValidName as jest.Mock).mockReturnValue(true); + (isValidPhoneNumber as jest.Mock).mockReturnValue(true); + + mockReq.body = { + email: "test@example.com", + phone: "1234567890", + fname: "John", + lname: "Doe", + }; + + await validateSignupForm(mockReq as Request, mockRes as Response, next); + + expect(next).toHaveBeenCalled(); + }); + + test("should return 400 if email is missing or invalid", async () => { + (isValidEmail as jest.Mock).mockResolvedValue(false); + + mockReq.body = { email: "invalid-email" }; + + await validateSignupForm(mockReq as Request, mockRes as Response, next); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: "This email address seems invalid. Please check for typos or try a different one." }); + }); + + test("should return 400 if first name is invalid", async () => { + (isValidEmail as jest.Mock).mockResolvedValue(true); + (isValidName as jest.Mock).mockReturnValue(false); + + mockReq.body = { + email: "test@example.com", + fname: "J0hn!", // Invalid name + }; + + await validateSignupForm(mockReq as Request, mockRes as Response, next); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: "Invalid first name" }); + }); + + test("should return 400 if last name is invalid", async () => { + (isValidEmail as jest.Mock).mockResolvedValue(true); + (isValidName as jest.Mock).mockReturnValue(false); + + mockReq.body = { + email: "test@example.com", + lname: "D0e!", // Invalid last name + }; + + await validateSignupForm(mockReq as Request, mockRes as Response, next); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: "Invalid last name" }); + }); + + test("should return 400 if phone number is invalid", async () => { + (isValidEmail as jest.Mock).mockResolvedValue(true); + (isValidName as jest.Mock).mockReturnValue(true); + (isValidPhoneNumber as jest.Mock).mockReturnValue(false); + + mockReq.body = { + email: "test@example.com", + phone: "123", // Invalid phone number + }; + + await validateSignupForm(mockReq as Request, mockRes as Response, next); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ error: "Invalid phone number format" }); + }); + + test("should allow missing optional fields (fname, lname, phone)", async () => { + (isValidEmail as jest.Mock).mockResolvedValue(true); + + mockReq.body = { + email: "test@example.com", + }; + + await validateSignupForm(mockReq as Request, mockRes as Response, next); + + expect(next).toHaveBeenCalled(); + }); +}); diff --git a/backend/src/middleware/signUpValidations.ts b/backend/src/middleware/signUpValidations.ts new file mode 100644 index 000000000..5a38bfd1f --- /dev/null +++ b/backend/src/middleware/signUpValidations.ts @@ -0,0 +1,36 @@ +import { Request, Response, NextFunction } from "express"; +import { isValidEmail } from "../helpers/emailValidator"; +import { isValidPhoneNumber } from "../helpers/phoneValidator"; +import { isValidName } from "../helpers/nameValidator"; + +export const validateSignupForm = async (req: Request, res: Response, next: NextFunction) => { + const { email, phone, fname, lname } = req.body; + + // Validate email (Required) + if (!email) { + return res.status(400).json({ error: "Email is required." }); + } + + const isEmailValid = await isValidEmail(email); // 🔹 Await the validation here + + if (!isEmailValid) { + return res.status(400).json({ error: "This email address seems invalid. Please check for typos or try a different one." }); + } + + // Validate first name (Optional but must be a safe string if provided) + if (fname && (!isValidName(fname))) { + return res.status(400).json({ error: "Invalid first name" }); + } + + // Validate last name (Optional but must be a safe string if provided) + if (lname && (!isValidName(lname))) { + return res.status(400).json({ error: "Invalid last name" }); + } + + // Validate phone number (Optional but must be valid if provided) + if (phone && !isValidPhoneNumber(phone)) { + return res.status(400).json({ error: "Invalid phone number format" }); + } + + next(); +}; diff --git a/backend/src/routes/signupRoutes.ts b/backend/src/routes/signupRoutes.ts new file mode 100644 index 000000000..ae95b30b1 --- /dev/null +++ b/backend/src/routes/signupRoutes.ts @@ -0,0 +1,10 @@ +import express from "express"; +import { submitSignupForm } from "../controllers/signUpController"; +import { validateSignupForm } from '../middleware/signUpValidations'; + +const router = express.Router(); + + +router.post("/", validateSignupForm, submitSignupForm); + +export default router; \ No newline at end of file diff --git a/frontend/.env.sample b/frontend/.env.sample index 074c15f16..bc244efb0 100644 --- a/frontend/.env.sample +++ b/frontend/.env.sample @@ -7,3 +7,4 @@ REACT_APP_FEATURE_SHOW_PINPOINT_SEGMENTS=true REACT_APP_FEATURE_CAREER_NAVIGATOR=true REACT_APP_FEATURE_BETA=true REACT_APP_FEATURE_BETA_MESSAGE="My Career NJ is in beta. You can send feedback and comments to us through our [contact form](https://docs.google.com/forms/d/e/1FAIpQLScAP50OMhuAgb9Q44TMefw7y5p4dGoE_czQuwGq2Z9mKmVvVQ/viewform)." +REACT_APP_SIGNUP_FOR_UPDATES=true \ No newline at end of file diff --git a/frontend/src/components/SignUpFormModal.tsx b/frontend/src/components/SignUpFormModal.tsx index 588033ebd..ac05c6db2 100644 --- a/frontend/src/components/SignUpFormModal.tsx +++ b/frontend/src/components/SignUpFormModal.tsx @@ -1,8 +1,11 @@ import { useEffect, useState } from "react"; import { Button } from "./Button"; import { CircleNotch, EnvelopeSimple, X } from "@phosphor-icons/react"; +import { useTranslation } from "react-i18next"; export const SignUpFormModal = () => { + const { t } = useTranslation(); + const [isOpen, setIsOpen] = useState(false); const [firstName, setFirstName] = useState(""); const [firstNameError, setFirstNameError] = useState(""); @@ -17,79 +20,96 @@ export const SignUpFormModal = () => { const [submitting, setSubmitting] = useState(false); const [success, setSuccess] = useState(false); - const handleSubmission = (e: React.FormEvent) => { + const emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/; + + const handleSubmission = async (e: React.FormEvent) => { e.preventDefault(); if (resetForm) return; - + + setSubmitting(true); + setHasErrors(""); + const allErrorCheck = () => { if ( (firstName.length !== 0 && firstName.length < 2) || (lastName.length !== 0 && lastName.length < 2) || !email || - (email && !email.includes("@")) || + (email && !emailRegex.test(email)) || (phone && phone.length < 12) ) { return true; - } else { - return false; } + return false; }; - - // check if first name has 2 or more characters + + // Perform validations if (firstName.length !== 0 && firstName.length < 2) { setFirstNameError("First name must be 2 or more characters."); } else { setFirstNameError(""); } - - // check if last name has 2 or more characters + if (lastName.length !== 0 && lastName.length < 2) { setLastNameError("Last name must be 2 or more characters."); } else { setLastNameError(""); } - - // check if there is entered email and is valid + if (!email) { setEmailError("Email is required."); - } else if (email && !email.includes("@")) { - setEmailError("Email invalid."); + } else if (!emailRegex.test(email)) { + setEmailError("Please enter a valid email address."); } else { setEmailError(""); } - - // check if phone number is valid + if (phone && phone.length < 12) { - setPhoneError("Phone number invalid."); + setPhoneError("Please enter a valid phone number"); } else { setPhoneError(""); } - + if (allErrorCheck()) { - console.error("ERROR:", "There are items that require your attention."); setSubmitting(false); - } else { - setSubmitting(true); - - setTimeout(() => { - const reandomTrueFalse = Math.random() < 0.5; - if (reandomTrueFalse) { - setSuccess(false); - setHasErrors("There was an error submitting the form. Please try again later."); - console.error("ERROR: Form submission failed."); - } else { - setSuccess(true); - console.info("SUCCESS: Form submitted successfully.", { - firstName, - lastName, - email, - phone, - }); - } - setSubmitting(false); - }, 2000); + setHasErrors("There are items that require your attention."); + return; } + + // Construct payload + const formData = { + firstName, + lastName, + email, + phone, + }; + + try { + const response = await fetch("/api/signup", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(formData), + }); + + const result = await response.json(); + + if (response.ok) { + setSuccess(true); + setHasErrors(""); + } else { + setSuccess(false); + setHasErrors(result.error || "There was an error submitting the form. Please try again."); + } + } catch (error) { + console.error("ERROR:", error); + setSuccess(false); + setHasErrors("There was an error connecting to the server. Please try again later."); + } + + setSubmitting(false); }; + function formatPhoneNumber(input: string): string { const cleaned = input.replace(/\D/g, ""); @@ -147,7 +167,7 @@ export const SignUpFormModal = () => { setIsOpen(!isOpen); }} > - Sign up for updates + {t("SignUpFormModal.buttonText")}
@@ -157,17 +177,20 @@ export const SignUpFormModal = () => {
Close
-

My Career NJ User Sign Up Form

-

- Sign-up to stay up to date on the latest new features, news, and resources from My - Career NJ. -

+ {!success && ( + <> +

{t("SignUpFormModal.formTitle")}

+

+ {t("SignUpFormModal.formDescription")} +

+ + )} {success ? ( <>
-

For submission successful.

-

Please check your email for confirmation.

+

{t("SignUpFormModal.successMessage")}

+

{t("SignUpFormModal.confirmationMessage")}

@@ -177,19 +200,23 @@ export const SignUpFormModal = () => { setIsOpen(false); }} > - Back to My Career NJ + {t("SignUpFormModal.backToHomepage")}
) : ( <> - A red asterick (*) indicates a required field. +

+ {t("SignUpFormModal.requiredFieldIndicator.part1")} + (*) + {t("SignUpFormModal.requiredFieldIndicator.part2")} +

setResetForm(false)}>
-

- Read about our privacy policy and our{" "} - sms use policy. + Read about our privacy policy and our{" "} + sms use policy.

)} diff --git a/frontend/src/locales/en.ts b/frontend/src/locales/en.ts index 3d47038cb..8608de5b7 100644 --- a/frontend/src/locales/en.ts +++ b/frontend/src/locales/en.ts @@ -486,4 +486,39 @@ This SMS Use Policy is subject to the laws and regulations of the State of New J The New Jersey Department of Labor and Workforce Development reserves the right to modify or terminate this service at any time.`, }, + SignUpFormModal: { + buttonText: "Sign Up for Updates", + formTitle: "My Career NJ User Sign Up Form", + formDescription: "Sign-up to stay up to date on the latest new features, news, and resources from My Career NJ.", + firstNameLabel: "First Name", + firstNameError: "First name must be 2 or more characters.", + lastNameLabel: "Last Name", + lastNameError: "Last name must be 2 or more characters.", + emailLabel: "Email", + emailRequired: "Email is required.", + emailError: "Please enter a valid email address.", + phoneLabel: "Mobile phone number", + usPhoneOnlyLabel: "US phone numbers only", + phoneError: "Please enter a valid phone number", + submitButton: "Submit form", + cancelButton: "Cancel", + successMessage: "You've successfully subscribed to updates from My Career NJ.", + confirmationMessage: "A confirmation email should be in your inbox. If it's not there, please check your spam or junk folder.", + errorMessage: "An unexpected error occurred. Please try again later.", + serverErrorMessage: "There was an error connecting to the server. Please try again later.", + loadingMessage: "Submitting", + alreadyRegisteredMessage: "You are already registered with this email.", + attentionRequired: "There are items that require your attention.", + backToHomepage: "Back to My Career NJ", + close: "Close", + resendVerificationEmail: "Resend verification email", + resetForm: "Reset form", + requiredFieldIndicator: { + part1: "A red asterisk ", + part2: " indicates a required field." + }, + readAboutOur: "Read about our", + privacyPolicy: "privacy policy", + smsUsePolicy: "sms use policy", + }, };