diff --git a/src/app/models/field/index.ts b/src/app/models/field/index.ts index f84c6dd2b2..ae852535c5 100644 --- a/src/app/models/field/index.ts +++ b/src/app/models/field/index.ts @@ -17,6 +17,7 @@ import createSectionFieldSchema from './sectionField' import createShortTextFieldSchema from './shortTextField' import createStatementFieldSchema from './statementField' import createTableFieldSchema from './tableField' +import createUenFieldSchema from './uenField' import createYesNoFieldSchema from './yesNoField' export { @@ -38,6 +39,7 @@ export { createShortTextFieldSchema, createStatementFieldSchema, createTableFieldSchema, + createUenFieldSchema, createYesNoFieldSchema, BaseFieldSchema, } diff --git a/src/app/models/field/uenField.ts b/src/app/models/field/uenField.ts new file mode 100644 index 0000000000..7ad94372e6 --- /dev/null +++ b/src/app/models/field/uenField.ts @@ -0,0 +1,7 @@ +import { Schema } from 'mongoose' + +import { IUenFieldSchema } from '../../../types' + +const createUenFieldSchema = () => new Schema() + +export default createUenFieldSchema diff --git a/src/app/models/form.server.model.ts b/src/app/models/form.server.model.ts index 516aa8b4f9..7e6a87b00d 100644 --- a/src/app/models/form.server.model.ts +++ b/src/app/models/form.server.model.ts @@ -67,6 +67,7 @@ import { createShortTextFieldSchema, createStatementFieldSchema, createTableFieldSchema, + createUenFieldSchema, createYesNoFieldSchema, } from './field' import LogicSchema, { @@ -405,6 +406,7 @@ const compileFormModel = (db: Mongoose): IFormModel => { FormFieldPath.discriminator(BasicField.Image, createImageFieldSchema()) FormFieldPath.discriminator(BasicField.Date, createDateFieldSchema()) FormFieldPath.discriminator(BasicField.Nric, createNricFieldSchema()) + FormFieldPath.discriminator(BasicField.Uen, createUenFieldSchema()) FormFieldPath.discriminator(BasicField.YesNo, createYesNoFieldSchema()) FormFieldPath.discriminator( BasicField.Statement, diff --git a/src/app/utils/field-validation/answerValidator.factory.ts b/src/app/utils/field-validation/answerValidator.factory.ts index e0334c1ce0..79d83b58b4 100644 --- a/src/app/utils/field-validation/answerValidator.factory.ts +++ b/src/app/utils/field-validation/answerValidator.factory.ts @@ -18,6 +18,7 @@ import { isSectionField, isShortTextField, isTableField, + isUenField, isYesNoField, } from '../../../types/field/utils/guards' import { ResponseValidator } from '../../../types/field/utils/validation' @@ -43,6 +44,7 @@ import { constructRatingValidator } from './validators/ratingValidator' import { constructSectionValidator } from './validators/sectionValidator' import { constructTableValidator } from './validators/tableValidator' import constructTextValidator from './validators/textValidator' +import { constructUenValidator } from './validators/uenValidator' import { constructYesNoValidator } from './validators/yesNoValidator' /** @@ -76,6 +78,8 @@ export const constructSingleAnswerValidator = ( return constructDropdownValidator(formField) } else if (isEmailField(formField)) { return constructEmailValidator(formField) + } else if (isUenField(formField)) { + return constructUenValidator() } else if (isYesNoField(formField)) { return constructYesNoValidator() } diff --git a/src/app/utils/field-validation/validators/uenValidator.ts b/src/app/utils/field-validation/validators/uenValidator.ts new file mode 100644 index 0000000000..924281b440 --- /dev/null +++ b/src/app/utils/field-validation/validators/uenValidator.ts @@ -0,0 +1,28 @@ +import { chain, left, right } from 'fp-ts/lib/Either' +import { flow } from 'fp-ts/lib/function' + +import { ProcessedSingleAnswerResponse } from 'src/app/modules/submission/submission.types' +import { ResponseValidator } from 'src/types/field/utils/validation' + +import { isUenValid } from '../../../../shared/util/uen-validation' + +import { notEmptySingleAnswerResponse } from './common' + +type UenValidator = ResponseValidator +type UenValidatorConstructor = () => UenValidator + +/** + * Returns a validator to check if uen + * format is correct. + */ +const uenValidator: UenValidator = (response) => { + return isUenValid(response.answer) + ? right(response) + : left(`UenValidator:\tanswer is not a valid UEN`) +} + +/** + * Returns a validation function for a uen field when called. + */ +export const constructUenValidator: UenValidatorConstructor = () => + flow(notEmptySingleAnswerResponse, chain(uenValidator)) diff --git a/src/public/main.js b/src/public/main.js index bcd27611b4..54006b7809 100644 --- a/src/public/main.js +++ b/src/public/main.js @@ -234,6 +234,7 @@ require('./modules/forms/base/components/field-number.client.component.js') require('./modules/forms/base/components/field-rating.client.component.js') require('./modules/forms/base/components/field-section.client.component.js') require('./modules/forms/base/components/field-statement.client.component.js') +require('./modules/forms/base/components/field-uen.client.component.js') require('./modules/forms/base/components/field-textarea.client.component.js') // forms base directives @@ -244,6 +245,7 @@ require('./modules/forms/base/directives/validate-checkbox.client.directive.js') require('./modules/forms/base/directives/validate-email-domain.client.directive.js') require('./modules/forms/base/directives/validate-email-format.client.directive.js') require('./modules/forms/base/directives/validate-nric.client.directive.js') +require('./modules/forms/base/directives/validate-uen.client.directive.js') require('./modules/forms/base/directives/validate-url.client.directive.js') require('./modules/forms/base/directives/ng-intl-tel-input.js') require('./modules/forms/base/directives/submit-form.directive.js') @@ -599,6 +601,10 @@ app.run([ 'modules/forms/base/componentViews/field-textarea.client.view.html', require('./modules/forms/base/componentViews/field-textarea.client.view.html'), ) + $templateCache.put( + 'modules/forms/base/componentViews/field-uen.client.view.html', + require('./modules/forms/base/componentViews/field-uen.client.view.html'), + ) // Forms base directiveViews $templateCache.put( diff --git a/src/public/modules/forms/base/componentViews/field-uen.client.view.html b/src/public/modules/forms/base/componentViews/field-uen.client.view.html new file mode 100644 index 0000000000..794bcbc0c4 --- /dev/null +++ b/src/public/modules/forms/base/componentViews/field-uen.client.view.html @@ -0,0 +1,56 @@ +
+ + + + +
+ +
+ + +
+ +
+ + Please enter a valid UEN +
+
+
diff --git a/src/public/modules/forms/base/components/field-uen.client.component.js b/src/public/modules/forms/base/components/field-uen.client.component.js new file mode 100644 index 0000000000..758960d2ea --- /dev/null +++ b/src/public/modules/forms/base/components/field-uen.client.component.js @@ -0,0 +1,10 @@ +'use strict' + +angular.module('forms').component('uenFieldComponent', { + templateUrl: 'modules/forms/base/componentViews/field-uen.client.view.html', + bindings: { + field: '<', + forms: '<', + }, + controllerAs: 'vm', +}) diff --git a/src/public/modules/forms/base/directiveViews/field.client.directive.view.html b/src/public/modules/forms/base/directiveViews/field.client.directive.view.html index 3c817c28a1..c1e4c3675b 100644 --- a/src/public/modules/forms/base/directiveViews/field.client.directive.view.html +++ b/src/public/modules/forms/base/directiveViews/field.client.directive.view.html @@ -101,4 +101,6 @@ forms="forms" > + + diff --git a/src/public/modules/forms/base/directives/validate-uen.client.directive.js b/src/public/modules/forms/base/directives/validate-uen.client.directive.js new file mode 100644 index 0000000000..4991ef3833 --- /dev/null +++ b/src/public/modules/forms/base/directives/validate-uen.client.directive.js @@ -0,0 +1,17 @@ +'use strict' + +const { isUenValid } = require('../../../../../shared/util/uen-validation') + +angular.module('forms').directive('validateUen', validateUen) + +function validateUen() { + return { + restrict: 'A', + require: 'ngModel', + link: function (_scope, _elem, _attrs, ctrl) { + ctrl.$validators.uenValidator = function (modelValue) { + return ctrl.$isEmpty(modelValue) ? true : isUenValid(modelValue) + } + }, + } +} diff --git a/src/public/modules/forms/base/resources/icon-types.js b/src/public/modules/forms/base/resources/icon-types.js index bdcdfa28c9..1cb8b46398 100644 --- a/src/public/modules/forms/base/resources/icon-types.js +++ b/src/public/modules/forms/base/resources/icon-types.js @@ -15,6 +15,7 @@ const iconTypeMap = { decimal: 'bx bx-calculator', image: 'bx bx-image', nric: 'bx bx-user', + uen: 'bx bx-building', attachment: 'bx bx-cloud-download', radiobutton: 'bx bx-radio-circle-marked', table: 'bx bx-table', diff --git a/src/public/modules/forms/helpers/field-factory.js b/src/public/modules/forms/helpers/field-factory.js index 81791e627b..55efd5fe3f 100644 --- a/src/public/modules/forms/helpers/field-factory.js +++ b/src/public/modules/forms/helpers/field-factory.js @@ -73,6 +73,7 @@ const getClass = (fieldType) => { case 'textfield': return TextField case 'nric': + case 'uen': case 'yes_no': return SingleAnswerField case 'image': diff --git a/src/shared/resources/basic/index.ts b/src/shared/resources/basic/index.ts index 2befcefc8d..ad84dffcc5 100644 --- a/src/shared/resources/basic/index.ts +++ b/src/shared/resources/basic/index.ts @@ -116,6 +116,12 @@ export const types: IBasicFieldType[] = [ submitted: true, answerArray: false, }, + { + name: BasicField.Uen, + value: 'UEN', + submitted: true, + answerArray: false, + }, { name: BasicField.Table, value: 'Table', diff --git a/src/shared/util/uen-validation.ts b/src/shared/util/uen-validation.ts new file mode 100644 index 0000000000..1d132f19b8 --- /dev/null +++ b/src/shared/util/uen-validation.ts @@ -0,0 +1,164 @@ +/** + * Validates whether a provided string value adheres to the UIN/FIN format + * as provided on the Singapore Government's National Registration Identity Card. + * @param value The value to be validated + */ +export const isUenValid = (value: string): boolean => { + return validateUEN(value) +} + +/** + * validates UEN of businesses in Singapore + * https://www.uen.gov.sg/ueninternet/faces/pages/admin/aboutUEN.jspx + * https://gist.github.com/mervintankw/90d5660c6ab03a83ddf77fa8199a0e52 + * @param {string} uen + * @returns {boolean} + */ +function validateUEN(uen: string) { + const entityTypeIndicator = [ + 'LP', + 'LL', + 'FC', + 'PF', + 'RF', + 'MQ', + 'MM', + 'NB', + 'CC', + 'CS', + 'MB', + 'FM', + 'GS', + 'GA', + 'GB', + 'DP', + 'CP', + 'NR', + 'CM', + 'CD', + 'MD', + 'HS', + 'VH', + 'CH', + 'MH', + 'CL', + 'XL', + 'CX', + 'RP', + 'TU', + 'TC', + 'FB', + 'FN', + 'PA', + 'PB', + 'SS', + 'MC', + 'SM', + ] + + // check that uen is not empty + if (!uen || String(uen) === '') { + return false + } + + // check if uen is 9 or 10 digits + if (uen.length < 9 || uen.length > 10) { + return false + } + + uen = uen.toUpperCase() + const uenStrArray: any[] = uen.split('') + + // (A) Businesses registered with ACRA + if (uenStrArray.length === 9) { + // check that last character is a letter + if (!isNaN(uenStrArray[uenStrArray.length - 1])) { + return false + } + + for (let i = 0; i < uenStrArray.length - 1; i++) { + // check that first 8 letters are all numbers + if (isNaN(uenStrArray[i])) { + return false + } + } + + // (A) Businesses registered with ACRA (SUCCESS) + return true + } else if (uenStrArray.length === 10) { + // check that last character is a letter + if (!isNaN(uenStrArray[uenStrArray.length - 1])) { + return false + } + + // (B) Local companies registered with ACRA + if ( + !isNaN(uenStrArray[0]) && + !isNaN(uenStrArray[1]) && + !isNaN(uenStrArray[2]) && + !isNaN(uenStrArray[3]) + ) { + // check that 5th to 9th letters are all numbers + if ( + !isNaN(uenStrArray[4]) && + !isNaN(uenStrArray[5]) && + !isNaN(uenStrArray[6]) && + !isNaN(uenStrArray[7]) && + !isNaN(uenStrArray[8]) + ) { + // (B) Local companies registered with ACRA (SUCCESS) + return true + } else { + return false + } + } + // (C) All other entities which will be issued new UEN + else { + // check that 1st letter is either T or S or R + if ( + uenStrArray[0] !== 'T' && + uenStrArray[0] !== 'S' && + uenStrArray[0] !== 'R' + ) { + return false + } + + // check that 2nd and 3rd letters are numbers only + if (isNaN(uenStrArray[1]) || isNaN(uenStrArray[2])) { + return false + } + + // check that 4th letter is an alphabet + if (!isNaN(uenStrArray[3])) { + return false + } + + // check entity-type indicator + let entityTypeMatch = false + const entityType = String(uenStrArray[3]) + String(uenStrArray[4]) + for (let i = 0; i < entityTypeIndicator.length; i++) { + if (String(entityTypeIndicator[i]) === String(entityType)) { + entityTypeMatch = true + } + } + if (!entityTypeMatch) { + return false + } + + // check that 6th to 9th letters are numbers only + if ( + isNaN(uenStrArray[5]) || + isNaN(uenStrArray[6]) || + isNaN(uenStrArray[7]) || + isNaN(uenStrArray[8]) + ) { + return false + } + + // (C) All other entities which will be issued new UEN (SUCCESS) + return true + } + } + + return false +} diff --git a/src/types/field/fieldTypes.ts b/src/types/field/fieldTypes.ts index 0e7eda7360..e72862f865 100644 --- a/src/types/field/fieldTypes.ts +++ b/src/types/field/fieldTypes.ts @@ -18,6 +18,7 @@ export enum BasicField { Rating = 'rating', Nric = 'nric', Table = 'table', + Uen = 'uen', } export enum MyInfoAttribute { diff --git a/src/types/field/index.ts b/src/types/field/index.ts index 52df4bc36f..eb195970f4 100644 --- a/src/types/field/index.ts +++ b/src/types/field/index.ts @@ -16,6 +16,7 @@ import { ISectionField, ISectionFieldSchema } from './sectionField' import { IShortTextField, IShortTextFieldSchema } from './shortTextField' import { IStatementField, IStatementFieldSchema } from './statementField' import { ITableField, ITableFieldSchema } from './tableField' +import { IUenField, IUenFieldSchema } from './uenField' import { IYesNoField, IYesNoFieldSchema } from './yesNoField' export * from './fieldTypes' @@ -39,6 +40,7 @@ export * from './sectionField' export * from './shortTextField' export * from './statementField' export * from './tableField' +export * from './uenField' export * from './yesNoField' export type FormFieldSchema = @@ -60,6 +62,7 @@ export type FormFieldSchema = | IShortTextFieldSchema | IStatementFieldSchema | ITableFieldSchema + | IUenFieldSchema | IYesNoFieldSchema export type FormField = @@ -81,6 +84,7 @@ export type FormField = | IShortTextField | IStatementField | ITableField + | IUenField | IYesNoField /** diff --git a/src/types/field/uenField.ts b/src/types/field/uenField.ts new file mode 100644 index 0000000000..b2638ff3fd --- /dev/null +++ b/src/types/field/uenField.ts @@ -0,0 +1,5 @@ +import { IField, IFieldSchema } from './baseField' + +export type IUenField = IField + +export interface IUenFieldSchema extends IUenField, IFieldSchema {} diff --git a/src/types/field/utils/guards.ts b/src/types/field/utils/guards.ts index 25e50e7efc..19efe8b723 100644 --- a/src/types/field/utils/guards.ts +++ b/src/types/field/utils/guards.ts @@ -19,6 +19,7 @@ import { ISectionFieldSchema, IShortTextField, ITableFieldSchema, + IUenField, IYesNoField, } from '..' @@ -88,6 +89,10 @@ export const isDecimalField = ( return formField.fieldType === BasicField.Decimal } +export const isUenField = (formField: IField): formField is IUenField => { + return formField.fieldType === BasicField.Uen +} + export const isYesNoField = (formField: IField): formField is IYesNoField => { return formField.fieldType === BasicField.YesNo }