From 3c0d322734e7c92c074b007869f9a720f775a5ec Mon Sep 17 00:00:00 2001
From: Tore Brede <Tore.Brede@uib.no>
Date: Mon, 7 Feb 2022 14:04:53 +0100
Subject: [PATCH] GREG-171: Populating gender and birthdate with suggestions
 based on national ID

---
 .../src/routes/guest/register/index.test.tsx  | 63 +++++++++++--
 .../guest/register/steps/register.test.tsx    |  2 +-
 .../routes/guest/register/steps/register.tsx  | 26 +++++-
 frontend/src/utils/index.test.ts              | 31 +++++++
 frontend/src/utils/index.ts                   | 91 ++++++++++++++++++-
 5 files changed, 203 insertions(+), 10 deletions(-)

diff --git a/frontend/src/routes/guest/register/index.test.tsx b/frontend/src/routes/guest/register/index.test.tsx
index 18c27199..28632333 100644
--- a/frontend/src/routes/guest/register/index.test.tsx
+++ b/frontend/src/routes/guest/register/index.test.tsx
@@ -13,9 +13,10 @@ const testData = {
     last_name: 'Tester',
     private_mobile: '+4797543910',
     private_email: 'test@example.org',
-    fnr: '04062141242',
+    fnr: '08015214555',
     passport: 'DK-123456',
-    date_of_birth: '1995-02-25',
+    date_of_birth: '1952-01-08',
+    gender: '',
   },
   role: {
     ou_name_en: 'English organizational unit name',
@@ -31,13 +32,10 @@ const testData = {
   },
 }
 
-beforeEach(() => {
+test('Field showing values correctly', async () => {
   fetchMock.mockIf('/api/ui/v1/invited/', () =>
     Promise.resolve<any>(JSON.stringify(testData))
   )
-})
-
-test('Field showing values correctly', async () => {
   render(
     <LocalizationProvider dateAdapter={AdapterDateFns}>
       <GuestRegister />
@@ -49,6 +47,10 @@ test('Field showing values correctly', async () => {
   await screen.findByDisplayValue(testData.person.private_email)
   await screen.findByDisplayValue(testData.person.fnr)
 
+  // Check that suggestions for date of birth and gender are showing
+  await screen.findByDisplayValue(testData.person.date_of_birth)
+  await screen.findByDisplayValue('male')
+
   // Passport nationality. The i18n-mock sets up en as the i18n.language property, so look for the English name
   await screen.findByText('DK')
   await screen.findByDisplayValue('123456')
@@ -69,3 +71,52 @@ test('Field showing values correctly', async () => {
   // For the default setup the contact person at unit field should be showing
   await screen.findByDisplayValue(testData.role.contact_person_unit)
 })
+
+test('Gender and birth date suggestions not if no national ID given', async () => {
+  const existingDateOfBirth = testData.person.date_of_birth
+  testData.person.fnr = ''
+  testData.person.date_of_birth = ''
+
+  fetchMock.mockIf('/api/ui/v1/invited/', () =>
+    Promise.resolve<any>(JSON.stringify(testData))
+  )
+  render(
+    <LocalizationProvider dateAdapter={AdapterDateFns}>
+      <GuestRegister />
+    </LocalizationProvider>
+  )
+
+  // Wait a bit so that all the values are showing
+  await screen.findByDisplayValue(testData.person.first_name)
+  await screen.findByDisplayValue(testData.person.last_name)
+
+  // No national is given in the input data so there should be no
+  // suggestion for the birthdate or gender
+  const dateOfBirth = screen.queryByDisplayValue(existingDateOfBirth)
+  expect(dateOfBirth).toBeNull()
+
+  const gender = screen.queryByDisplayValue('male')
+  expect(gender).toBeNull()
+})
+
+test('Gender and birth date suggestions not if no national ID given', async () => {
+  // Make the date of birth and national ID not match
+  testData.person.fnr = '08015214555'
+  testData.person.date_of_birth = '1960-01-08'
+  // Also set the gender to female to check that it is not overridden by a suggestion
+  testData.person.gender = 'female'
+
+  fetchMock.mockIf('/api/ui/v1/invited/', () =>
+    Promise.resolve<any>(JSON.stringify(testData))
+  )
+  render(
+    <LocalizationProvider dateAdapter={AdapterDateFns}>
+      <GuestRegister />
+    </LocalizationProvider>
+  )
+
+  // In this a date of birth was already set, and it should not have been overridden by a suggestion
+  await screen.findByDisplayValue(testData.person.date_of_birth)
+  // Check that the gender has not been overridden
+  await screen.findByDisplayValue('female')
+})
diff --git a/frontend/src/routes/guest/register/steps/register.test.tsx b/frontend/src/routes/guest/register/steps/register.test.tsx
index 8f8c1d83..d88a1312 100644
--- a/frontend/src/routes/guest/register/steps/register.test.tsx
+++ b/frontend/src/routes/guest/register/steps/register.test.tsx
@@ -17,7 +17,6 @@ function getEmptyGuestData(): GuestInviteInformation {
     role_name_nb: '',
     role_start: '',
     role_end: '',
-    comment: '',
     email: '',
     mobile_phone: '',
     date_of_birth: null,
@@ -25,6 +24,7 @@ function getEmptyGuestData(): GuestInviteInformation {
     passport: '',
     countryForCallingCode: '',
     authentication_method: AuthenticationMethod.Invite,
+    gender: '',
   }
 }
 
diff --git a/frontend/src/routes/guest/register/steps/register.tsx b/frontend/src/routes/guest/register/steps/register.tsx
index e958dc91..53b058e4 100644
--- a/frontend/src/routes/guest/register/steps/register.tsx
+++ b/frontend/src/routes/guest/register/steps/register.tsx
@@ -27,7 +27,12 @@ import {
 import { getAlpha2Codes, getName } from 'i18n-iso-countries'
 import { DatePicker } from '@mui/lab'
 import { subYears } from 'date-fns/fp'
-import { isValidFnr, isValidMobilePhoneNumber } from 'utils'
+import {
+  isValidFnr,
+  isValidMobilePhoneNumber,
+  extractGenderOrBlank,
+  extractBirthdateFromNationalId,
+} from 'utils'
 import { GuestInviteInformation } from '../guestDataForm'
 import { GuestRegisterData } from '../enteredGuestData'
 import { GuestRegisterCallableMethods } from '../guestRegisterCallableMethods'
@@ -62,7 +67,11 @@ const GuestRegisterStep = forwardRef(
     const [passportNationality, setPassportNationality] = useState<
       string | undefined
     >(undefined)
-    const [gender, setGender] = useState<string>('')
+
+    // Set suggestion for gender field is a gender is not already given in the input
+    const [gender, setGender] = useState<string>(
+      initialGuestData.gender ?? extractGenderOrBlank(initialGuestData.fnr)
+    )
     const [idErrorState, setIdErrorState] = useState<string>('')
     const [phoneErrorState, setPhoneErrorState] = useState<string>('')
     const { displayContactAtUnitGuestInput } = useContext(FeatureContext)
@@ -82,6 +91,19 @@ const GuestRegisterStep = forwardRef(
       defaultValues: registerData ?? {},
     })
 
+    // If there is no already a date of birth set, add a suggestion for
+    // this value based on the national ID, if it is set
+    if (
+      (!registerData || !registerData.dateOfBirth) &&
+      !initialGuestData.date_of_birth &&
+      initialGuestData.fnr
+    ) {
+      const dateOfBirth = extractBirthdateFromNationalId(initialGuestData.fnr)
+      if (dateOfBirth) {
+        setValue('dateOfBirth', dateOfBirth)
+      }
+    }
+
     const submit: SubmitHandler<GuestRegisterData> = async (data) => {
       console.log('submit data is', data)
       const result = await trigger()
diff --git a/frontend/src/utils/index.test.ts b/frontend/src/utils/index.test.ts
index 24f9d152..0c6a66c6 100644
--- a/frontend/src/utils/index.test.ts
+++ b/frontend/src/utils/index.test.ts
@@ -1,3 +1,4 @@
+import parse from 'date-fns/parse'
 import {
   getCookie,
   deleteCookie,
@@ -7,6 +8,8 @@ import {
   isValidMobilePhoneNumber,
   maybeCsrfToken,
   submitJsonOpts,
+  isFemaleBasedOnNationalId,
+  extractBirthdateFromNationalId,
 } from './index'
 
 // Mock i18next module to return a translation that just returns the key
@@ -117,3 +120,31 @@ test('Null fnr', async () => {
 test('Invalid fnr', async () => {
   expect(isValidFnr('')).toEqual('common:validation.invalidIdNumber')
 })
+
+test('Female extracted from fnr', async () => {
+  expect(isFemaleBasedOnNationalId('12103626631')).toEqual(true)
+  expect(isFemaleBasedOnNationalId('08015214474')).toEqual(true)
+  expect(isFemaleBasedOnNationalId('26052088029')).toEqual(true)
+  expect(isFemaleBasedOnNationalId('11082335449')).toEqual(true)
+  expect(isFemaleBasedOnNationalId('11081670619')).toEqual(true)
+})
+
+test('Male extracted from fnr', async () => {
+  expect(isFemaleBasedOnNationalId('12103626712')).toEqual(false)
+  expect(isFemaleBasedOnNationalId('08015214555')).toEqual(false)
+  expect(isFemaleBasedOnNationalId('01088538788')).toEqual(false)
+  expect(isFemaleBasedOnNationalId('15101739551')).toEqual(false)
+  expect(isFemaleBasedOnNationalId('05127648192')).toEqual(false)
+})
+
+test('Date of birth extract from D-number', async () => {
+  expect(extractBirthdateFromNationalId('53097248016')).toEqual(
+    parse('1972-09-13', 'yyyy-MM-dd', new Date())
+  )
+})
+
+test('Date of birth extracted from fødselsnummer', async () => {
+  expect(extractBirthdateFromNationalId('04062141242')).toEqual(
+    parse('1921-06-04', 'yyyy-MM-dd', new Date())
+  )
+})
diff --git a/frontend/src/utils/index.ts b/frontend/src/utils/index.ts
index f045820a..b8fedc45 100644
--- a/frontend/src/utils/index.ts
+++ b/frontend/src/utils/index.ts
@@ -1,5 +1,5 @@
 import validator from '@navikt/fnrvalidator'
-import { parseISO } from 'date-fns'
+import { getYear, parseISO } from 'date-fns'
 import { OuData } from 'hooks/useOus'
 import i18n from 'i18next'
 import {
@@ -11,6 +11,8 @@ import {
   FetchedConsent,
 } from 'interfaces'
 import { isValidPhoneNumber, parsePhoneNumber } from 'libphonenumber-js'
+import parse from 'date-fns/parse'
+import { parseInt } from 'lodash'
 
 const validEmailRegex =
   /^(?=.{1,254}$)(?=.{1,64}@)[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+(\.[-!#$%&'*+/0-9=?A-Z^_`a-z{|}~]+)*@[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*$/
@@ -221,3 +223,90 @@ export function getOuName(ou: OuData) {
   }
   return ou.nb ? ou.nb : ou.en
 }
+
+/**
+ * Note the the input is assumed to be either a D-number or a "fødselsnummer". Other types such as H-numbers are not supported.
+ *
+ * @param nationalId D-number or "fødselsnummer"
+ */
+function isDnr(nationalId: string): boolean {
+  return parseInt(nationalId.substring(0, 1), 10) >= 4
+}
+
+export function isFemaleBasedOnNationalId(nationalId: string): boolean {
+  if (isDnr(nationalId)) {
+    return parseInt(nationalId.charAt(10), 10) % 2 === 0
+  }
+  return parseInt(nationalId.charAt(8), 10) % 2 === 0
+}
+
+export function extractGenderOrBlank(nationalId?: string): string {
+  if (
+    nationalId == null ||
+    nationalId === '' ||
+    isValidFnr(nationalId) !== true
+  ) {
+    return ''
+  }
+
+  if (isFemaleBasedOnNationalId(nationalId)) {
+    return 'female'
+  }
+  return 'male'
+}
+
+/**
+ * Gives a guess of the birthdate with century included.
+ *
+ * @param dateOfBirth a date on the form ddMMyy
+ */
+function suggestBirthDate(dateOfBirth: string): Date {
+  const currentYear = getYear(new Date())
+  const year = dateOfBirth.substring(4, 6)
+  const yearAsInt = parseInt(year)
+  let century = '20'
+
+  // Check that the year the person is born is not a year in the future,
+  // given he is born in the 21th century. Also assuming he is born in
+  // the 21th century, check that he is older than 15 years, if not
+  // then assume he is born in the 20th century
+  if (
+    yearAsInt + 2000 > currentYear ||
+    !(currentYear - 2000 - yearAsInt > 15)
+  ) {
+    century = '19'
+  }
+  return parse(
+    dateOfBirth.substring(0, 4) + century + dateOfBirth.substring(4, 6),
+    'ddMMyyyy',
+    new Date()
+  )
+}
+
+function extractBirthdateFromFnr(nationalId: string): Date {
+  return suggestBirthDate(nationalId.substring(0, 6))
+}
+
+function extractBirthdateFromDnumber(nationalId: string): Date {
+  return suggestBirthDate(
+    (parseInt(nationalId.charAt(0), 10) - 4).toString(10) +
+      nationalId.substring(1, 6)
+  )
+}
+
+export function extractBirthdateFromNationalId(
+  nationalId?: string
+): Date | null {
+  if (
+    nationalId == null ||
+    nationalId === '' ||
+    isValidFnr(nationalId) !== true
+  ) {
+    return null
+  }
+
+  if (isDnr(nationalId)) {
+    return extractBirthdateFromDnumber(nationalId)
+  }
+  return extractBirthdateFromFnr(nationalId)
+}
-- 
GitLab