-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
add protected/person-case/previous-sin route (#231)
- Loading branch information
Showing
8 changed files
with
335 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
172 changes: 172 additions & 0 deletions
172
frontend/app/routes/protected/person-case/previous-sin.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,172 @@ | ||
import { useId, useState } from 'react'; | ||
import type { ChangeEvent } from 'react'; | ||
|
||
import { data, useFetcher } from 'react-router'; | ||
import type { RouteHandle, SessionData } from 'react-router'; | ||
|
||
import { useTranslation } from 'react-i18next'; | ||
import * as v from 'valibot'; | ||
|
||
import type { Info, Route } from './+types/previous-sin'; | ||
|
||
import { requireAuth } from '~/.server/utils/auth-utils'; | ||
import { i18nRedirect } from '~/.server/utils/route-utils'; | ||
import { Button } from '~/components/button'; | ||
import { FetcherErrorSummary } from '~/components/error-summary'; | ||
import { InputPatternField } from '~/components/input-pattern-field'; | ||
import { InputRadios } from '~/components/input-radios'; | ||
import { PageTitle } from '~/components/page-title'; | ||
import { AppError } from '~/errors/app-error'; | ||
import { getFixedT } from '~/i18n-config.server'; | ||
import { handle as parentHandle } from '~/routes/protected/layout'; | ||
import { getLanguage } from '~/utils/i18n-utils'; | ||
import { formatSin, isValidSin, sinInputPatternFormat } from '~/utils/sin-utils'; | ||
|
||
type PreviousSinSessionData = NonNullable<SessionData['inPersonSINCase']['previousSin']>; | ||
|
||
const VALID_HAS_PREVIOUS_SIN_OPTIONS = { yes: 'yes', no: 'no', unknown: 'unknown' } as const; | ||
|
||
export const handle = { | ||
i18nNamespace: [...parentHandle.i18nNamespace, 'protected'], | ||
} as const satisfies RouteHandle; | ||
|
||
export async function loader({ context, request }: Route.LoaderArgs) { | ||
requireAuth(context.session, new URL(request.url), ['user']); | ||
const t = await getFixedT(request, handle.i18nNamespace); | ||
return { | ||
documentTitle: t('protected:previous-sin.page-title'), | ||
defaultFormValues: context.session.inPersonSINCase?.previousSin, | ||
}; | ||
} | ||
|
||
export function meta({ data }: Route.MetaArgs) { | ||
return [{ title: data.documentTitle }]; | ||
} | ||
|
||
export async function action({ context, request }: Route.ActionArgs) { | ||
requireAuth(context.session, new URL(request.url), ['user']); | ||
const lang = getLanguage(request); | ||
const t = await getFixedT(request, handle.i18nNamespace); | ||
|
||
const formData = await request.formData(); | ||
const action = formData.get('action'); | ||
|
||
switch (action) { | ||
case 'back': { | ||
//TODO: replace with correct route | ||
throw i18nRedirect('routes/protected/person-case/privacy-statement.tsx', request); | ||
} | ||
|
||
case 'next': { | ||
const schema = v.pipe( | ||
v.object({ | ||
hasPreviousSin: v.picklist( | ||
Object.keys(VALID_HAS_PREVIOUS_SIN_OPTIONS), | ||
t('protected:previous-sin.error-messages.has-previous-sin-required'), | ||
), | ||
socialInsuranceNumber: v.optional( | ||
v.pipe( | ||
v.string(), | ||
v.trim(), | ||
v.check((sin) => isValidSin(sin), t('protected:previous-sin.error-messages.sin-required')), | ||
v.transform((sin) => formatSin(sin, '')), | ||
), | ||
), | ||
}), | ||
v.forward( | ||
v.partialCheck( | ||
[['hasPreviousSin'], ['socialInsuranceNumber']], | ||
(input) => | ||
input.socialInsuranceNumber === undefined || | ||
(input.hasPreviousSin === VALID_HAS_PREVIOUS_SIN_OPTIONS.yes && isValidSin(input.socialInsuranceNumber ?? '')), | ||
t('protected:previous-sin.error-messages.sin-required'), | ||
), | ||
['socialInsuranceNumber'], | ||
), | ||
) satisfies v.GenericSchema<PreviousSinSessionData>; | ||
|
||
const input = { | ||
hasPreviousSin: formData.get('hasPreviousSin') as string, | ||
socialInsuranceNumber: | ||
formData.get('hasPreviousSin') === VALID_HAS_PREVIOUS_SIN_OPTIONS.yes | ||
? formData.get('socialInsuranceNumber') | ||
: undefined, | ||
} satisfies Partial<PreviousSinSessionData>; | ||
|
||
const parseResult = v.safeParse(schema, input, { lang }); | ||
|
||
if (!parseResult.success) { | ||
return data({ errors: v.flatten<typeof schema>(parseResult.issues).nested }, { status: 400 }); | ||
} | ||
|
||
context.session.inPersonSINCase ??= {}; | ||
context.session.inPersonSINCase.previousSin = parseResult.output; | ||
|
||
//TODO: replace with correct route | ||
throw i18nRedirect('routes/protected/person-case/primary-docs.tsx', request); | ||
} | ||
|
||
default: { | ||
throw new AppError(`Unrecognized action: ${action}`); | ||
} | ||
} | ||
} | ||
|
||
export default function PreviousSin({ loaderData, actionData, params }: Route.ComponentProps) { | ||
const { t } = useTranslation(handle.i18nNamespace); | ||
const fetcherKey = useId(); | ||
const fetcher = useFetcher<Info['actionData']>({ key: fetcherKey }); | ||
const isSubmitting = fetcher.state !== 'idle'; | ||
const errors = fetcher.data?.errors; | ||
|
||
const [hasPreviousSin, setHasPreviousSin] = useState<string | undefined>(loaderData.defaultFormValues?.hasPreviousSin); | ||
function handleHasPreviousSinChanged(event: ChangeEvent<HTMLInputElement>) { | ||
setHasPreviousSin(event.target.value); | ||
} | ||
|
||
const hasPreviousSinOptions = Object.values(VALID_HAS_PREVIOUS_SIN_OPTIONS).map((value) => ({ | ||
value: value, | ||
children: t(`protected:previous-sin.has-previous-sin-options.${value}`), | ||
defaultChecked: value === loaderData.defaultFormValues?.hasPreviousSin, | ||
onChange: handleHasPreviousSinChanged, | ||
})); | ||
|
||
return ( | ||
<> | ||
<PageTitle subTitle={t('protected:in-person.title')}>{t('protected:previous-sin.page-title')}</PageTitle> | ||
<FetcherErrorSummary fetcherKey={fetcherKey}> | ||
<fetcher.Form method="post" noValidate> | ||
<div className="space-y-6"> | ||
<InputRadios | ||
id="has-previous-sin" | ||
legend={t('protected:previous-sin.has-previous-sin-label')} | ||
name="hasPreviousSin" | ||
options={hasPreviousSinOptions} | ||
required | ||
errorMessage={errors?.hasPreviousSin?.at(0)} | ||
/> | ||
{hasPreviousSin === VALID_HAS_PREVIOUS_SIN_OPTIONS.yes && ( | ||
<InputPatternField | ||
defaultValue={loaderData.defaultFormValues?.socialInsuranceNumber ?? ''} | ||
inputMode="numeric" | ||
format={sinInputPatternFormat} | ||
id="social-insurance-number" | ||
name="socialInsuranceNumber" | ||
label={t('protected:previous-sin.social-insurance-number-label')} | ||
errorMessage={errors?.socialInsuranceNumber?.at(0)} | ||
/> | ||
)} | ||
</div> | ||
<div className="mt-8 flex flex-row-reverse flex-wrap items-center justify-end gap-3"> | ||
<Button name="action" value="next" variant="primary" id="continue-button" disabled={isSubmitting}> | ||
{t('protected:person-case.next')} | ||
</Button> | ||
<Button name="action" value="back" id="back-button" disabled={isSubmitting}> | ||
{t('protected:person-case.previous')} | ||
</Button> | ||
</div> | ||
</fetcher.Form> | ||
</FetcherErrorSummary> | ||
</> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,69 @@ | ||
import { AppError } from '~/errors/app-error'; | ||
import { ErrorCodes } from '~/errors/error-codes'; | ||
|
||
/** | ||
* Regular expression to validate Canadian SIN (Social Insurance Number) format. | ||
* | ||
* The SIN must follow the format XXXXXXXXX or XXX XXX XXX or XXX-XXX-XXX. | ||
* The SIN number cannot consist entirely of zeros (e.g., 000000000 or 000 000 000 or 000-000-000 is not valid). | ||
* | ||
* Note: This regular expression only validates the format of the SIN. | ||
* Consumers must validate the SIN against the Luhn algorithm separately. | ||
* | ||
* Examples of valid SIN formats: | ||
* - 123-456-789 | ||
* - 123 456 789 | ||
* - 123456789 | ||
* - 000-000-010 | ||
* | ||
* Examples of invalid SIN formats: | ||
* - 000-000-000 | ||
* - 000000000 | ||
* - 123-45-6789 | ||
* - ABC-DEF-GHI | ||
*/ | ||
const sinFormatRegex = /^(?!0{3}[ -]?0{3}[ -]?0{3})\d{3}[ -]?\d{3}[ -]?\d{3}$/; | ||
|
||
/** | ||
* This pattern is intended for use with the `format` property of the `InputPatternField` component. | ||
* | ||
* Example: | ||
* ```typescript | ||
* // Usage with InputPatternField | ||
* <InputPatternField format={sinInputPatternFormat} /> | ||
* ``` | ||
*/ | ||
export const sinInputPatternFormat = '### ### ###'; | ||
|
||
/** | ||
* | ||
* @param sin - the Social Insurance Number (SIN) | ||
* @returns a boolean indicating if a SIN is valid using Luhn's Algorithm | ||
* | ||
* Luhn's Alogrithm (also known as "mod 10") | ||
* Social Insurance Numbers can be validated through a simple check digit process called the Luhn algorithm. | ||
* 046 454 286 <--- A fictitious, but valid, SIN. | ||
* 121 212 121 <--- Multiply every second digit by 2. | ||
* The result of the multiplication is: | ||
* 0 8 6 8 5 8 2 16 6 | ||
* Then, add all of the digits together (note that 16 is 1+6): | ||
* 0 + 8 + 6 + 8 + 5 + 8 + 2 + 1+6 + 6 = 50 | ||
* If the SIN is valid, this number will be evenly divisible by 10. | ||
* | ||
*/ | ||
export function isValidSin(sin: string): boolean { | ||
if (!sinFormatRegex.test(sin)) return false; | ||
const multDigitString = [...sin.replace(/\D/g, '')].map((digit, index) => Number(digit) * (index % 2 === 0 ? 1 : 2)).join(''); | ||
const digitSum = [...multDigitString].reduce((acc, cur) => acc + Number(cur), 0); | ||
return digitSum % 10 === 0; | ||
} | ||
|
||
/** | ||
* | ||
* @param sin - the Social Insurance Number (SIN) | ||
* @returns a formatted SIN using a supplied separator | ||
*/ | ||
export function formatSin(sin: string, separator = ' '): string { | ||
if (!isValidSin(sin)) throw new AppError('Invalid SIN format', ErrorCodes.INVALID_SIN_FORMAT); | ||
return (sin.replace(/\D/g, '').match(/.../g) ?? []).join(separator); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import { describe, expect, it } from 'vitest'; | ||
|
||
import { formatSin, isValidSin, sinInputPatternFormat } from '~/utils/sin-utils'; | ||
|
||
describe('sin-utils', () => { | ||
describe('isValidSin', () => { | ||
it.each([['000000042'], ['000 000 042'], ['000-000-042'], ['800000002'], ['800 000 002'], ['800-000-002']])( | ||
'should return true for valid SIN "%s"', | ||
(sin) => { | ||
expect(isValidSin(sin)).toEqual(true); | ||
}, | ||
); | ||
|
||
it.each([['000000000'], ['000 000 000'], ['000-000-000'], ['800000003'], ['800 000 003'], ['800-000-003']])( | ||
'should return false for invalid SIN "%s"', | ||
(sin) => { | ||
expect(isValidSin(sin)).toEqual(false); | ||
}, | ||
); | ||
|
||
it('should return false for an invalid SIN of incorrect length', () => { | ||
expect(isValidSin('123456')).toEqual(false); | ||
}); | ||
|
||
it('should return false for an invalid SIN of incorrect form', () => { | ||
expect(isValidSin('123abc&^+')).toEqual(false); | ||
}); | ||
|
||
it('should return false when passed an empty string', () => { | ||
expect(isValidSin('')).toEqual(false); | ||
}); | ||
}); | ||
|
||
describe('formatSin', () => { | ||
it('should format a SIN using the default separator', () => { | ||
expect(formatSin('800000002')).toEqual('800 000 002'); | ||
}); | ||
|
||
it('should format a SIN using the a supplied separator', () => { | ||
expect(formatSin('800000002', '-')).toEqual('800-000-002'); | ||
}); | ||
|
||
it('should throw an error for invalid SIN', () => { | ||
expect(() => formatSin('123456789')).toThrowError(); | ||
}); | ||
}); | ||
|
||
describe('sinInputPatternFormat', () => { | ||
it('should have correct format', () => { | ||
expect(sinInputPatternFormat).toBe('### ### ###'); | ||
}); | ||
}); | ||
}); |