Skip to content

Commit

Permalink
refactor: convert CsvMergedHeadersGenerator to typescript (#2080)
Browse files Browse the repository at this point in the history
* refactor: convert response classes to typescript and change response to abstract class

* refactor: rearrange types

* refactor: convert CsvMergedHeadersGenerator to typescript

* refactor: edit export and import statements for CsvMergedHeadersGenerator

* fix: edit import statement of  generator in submissions.client.factory

* refactor: edit response types

* refactor: remove typing in jsdocs and edit types

* refactor: export response subclasses input types

* refactor: add ErrorResponse as default for response types

* refactor: shift hasProps to shared

* refactor: athrow error instead of returning ErrorResponse and edited typeguards

* refactor: change interface to type

* fix: fix incorrect import statement

* test: remove single header row test

* fix: fix bug caused by fieldtype guard and if else condition

* refactor: remove console.log

* refactor: convert extractAnswer to private function

* docs: add jsdocs for response factory

* docs: edit jsdocs for addRecord

* refactor: extract acheck for answer array

* refactor: revert extractAnswer to non-private function

* refactor: remove repeated check for type Array

* refactor: create SubmissionRecord type

* refactor: rename ccsv generator in submissions client factory

* refactor: make _data field private

* test: rrestore deleted test and edit expected unprocessed record

* test: remove repeated test

* refactor: use private modifier in typescript classes

* test: add tests for error cases

* refactor: add private modifier to date comparator

* refactor: remove underscore from private field

* refactor: extract type in csvmhgenerator

* refactor: replace strings with basic field enums

* fix: fix import statement
  • Loading branch information
chowyiyin authored Jun 22, 2021
1 parent 6512a84 commit 4a91084
Show file tree
Hide file tree
Showing 20 changed files with 381 additions and 155 deletions.
2 changes: 1 addition & 1 deletion src/app/modules/bounce/bounce.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
ResultAsync,
} from 'neverthrow'

import { hasProp } from '../../../shared/util/has-prop'
import {
BounceType,
IBounceSchema,
Expand All @@ -25,7 +26,6 @@ import { EMAIL_HEADERS, EmailType } from '../../services/mail/mail.constants'
import MailService from '../../services/mail/mail.service'
import { SmsFactory } from '../../services/sms/sms.factory'
import { transformMongoError } from '../../utils/handle-mongo-error'
import { hasProp } from '../../utils/has-prop'
import { PossibleDatabaseError } from '../core/core.errors'
import { getCollabEmailsWithPermission } from '../form/form.utils'
import * as UserService from '../user/user.service'
Expand Down
2 changes: 1 addition & 1 deletion src/app/modules/myinfo/myinfo.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { err, ok, Result } from 'neverthrow'
import { v4 as uuidv4, validate as validateUUID } from 'uuid'

import { types as myInfoTypes } from '../../../shared/resources/myinfo'
import { hasProp } from '../../../shared/util/has-prop'
import {
AuthType,
BasicField,
Expand All @@ -16,7 +17,6 @@ import {
MapRouteError,
} from '../../../types'
import { createLoggerWithLabel } from '../../config/logger'
import { hasProp } from '../../utils/has-prop'
import { DatabaseError, MissingFeatureError } from '../core/core.errors'
import {
AuthTypeMismatchError,
Expand Down
2 changes: 1 addition & 1 deletion src/app/modules/spcp/spcp.util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import crypto from 'crypto'
import { StatusCodes } from 'http-status-codes'
import { err, ok, Result } from 'neverthrow'

import { hasProp } from '../../../shared/util/has-prop'
import {
AuthType,
BasicField,
Expand All @@ -11,7 +12,6 @@ import {
SPCPFieldTitle,
} from '../../../types'
import { createLoggerWithLabel } from '../../config/logger'
import { hasProp } from '../../utils/has-prop'
import { MissingFeatureError } from '../core/core.errors'
import {
AuthTypeMismatchError,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,21 +1,40 @@
const moment = require('moment-timezone')
const keyBy = require('lodash/keyBy')
const { CsvGenerator } = require('./CsvGenerator')
const { getResponseInstance } = require('./response-factory')

/**
* @typedef {{
* _id: string,
* question: string,
* answer?: string,
* answerArray?: string[],
* fieldType: string,
* isHeader?: boolean,
* }} DisplayedResponse
*/

class CsvMergedHeadersGenerator extends CsvGenerator {
constructor(expectedNumberOfRecords, numOfMetaDataRows) {
import keyBy from 'lodash/keyBy'
import moment from 'moment-timezone'

import { DisplayedResponseWithoutAnswer } from '../../../../types/response'

import { Response } from './csv-response-classes'
import { CsvGenerator } from './CsvGenerator'
import { getResponseInstance } from './response-factory'

type KeyedResponse = { [fieldId: string]: Response }

type UnprocessedRecord = {
created: string
submissionId: string
record: KeyedResponse
}

type SubmissionRecord = {
record: DisplayedResponseWithoutAnswer[]
created: string
submissionId: string
}

export class CsvMergedHeadersGenerator extends CsvGenerator {
hasBeenProcessed: boolean
hasBeenSorted: boolean
fieldIdToQuestion: Map<
string,
{
created: string
question: string
}
>
fieldIdToNumCols: Record<string, number>
unprocessed: UnprocessedRecord[]

constructor(expectedNumberOfRecords: number, numOfMetaDataRows: number) {
super(expectedNumberOfRecords, numOfMetaDataRows)

this.hasBeenProcessed = false
Expand All @@ -28,25 +47,25 @@ class CsvMergedHeadersGenerator extends CsvGenerator {
/**
* Returns current length of CSV file excluding header and meta-data
*/
length() {
length(): number {
return this.unprocessed.length
}

/**
*
* @param {Object} decryptedContent
* @param {DisplayedResponse[]} decryptedContent.record
* @param {string} decryptedContent.created
* @param {string} decryptedContent.submissionId
* Extracts information from input record, rearranges record and then adds an UnprocessedRecord to this.unprocessed
* @param decryptedContent
* @param decryptedContent.record
* @param decryptedContent.created
* @param decryptedContent.submissionId
* @throws Error when trying to convert record into a response instance. Should be caught in submissions client factory.
*/
addRecord({ record, created, submissionId }) {
addRecord({ record, created, submissionId }: SubmissionRecord): void {
// First pass, create object with { [fieldId]: question } from
// decryptedContent to get all the questions.
const fieldRecords = record.map((content) => {
const fieldRecord = getResponseInstance(content)
if (!fieldRecord.isHeader) {
const currentMapping = this.fieldIdToQuestion.get(fieldRecord.id)

// Only set new mapping if it does not exist or this record is a later
// submission.
// Might need to differentiate the question headers if we allow
Expand Down Expand Up @@ -77,28 +96,16 @@ class CsvMergedHeadersGenerator extends CsvGenerator {
})
}

/**
* Extracts the string representation from an unprocessed record.
* @param {Object} unprocessedRecord
* @param {string} fieldId
* @returns {string}
*/
_extractAnswer(unprocessedRecord, fieldId, colIndex) {
const fieldRecord = unprocessedRecord[fieldId]
if (!fieldRecord) return ''
return fieldRecord.getAnswer(colIndex)
}

/**
* Process the unprocessed records by creating the correct headers and
* assigning each answer to their respective locations in each response row in
* the csv data.
*/
process() {
process(): void {
if (this.hasBeenProcessed) return

// Create a header row in CSV using the fieldIdToQuestion map.
let headers = ['Reference number', 'Timestamp']
const headers = ['Reference number', 'Timestamp']
this.fieldIdToQuestion.forEach((value, fieldId) => {
for (let i = 0; i < this.fieldIdToNumCols[fieldId]; i++) {
headers.push(value.question)
Expand All @@ -109,26 +116,44 @@ class CsvMergedHeadersGenerator extends CsvGenerator {
// Craft a new csv row for each unprocessed record
// O(qn), where q = number of unique questions, n = number of submissions.
this.unprocessed.forEach((up) => {
let createdAt = moment(up.created).tz('Asia/Singapore')
createdAt = createdAt.isValid()
const createdAt = moment(up.created).tz('Asia/Singapore')
const formattedDate = createdAt.isValid()
? createdAt.format('DD MMM YYYY hh:mm:ss A')
: createdAt
let row = [up.submissionId, createdAt]
for (let [fieldId] of this.fieldIdToQuestion) {
: createdAt.toString() // just convert to string if given date is not valid
const row = [up.submissionId, formattedDate]

this.fieldIdToQuestion.forEach((_question, fieldId) => {
const numCols = this.fieldIdToNumCols[fieldId]
for (let colIndex = 0; colIndex < numCols; colIndex++) {
row.push(this._extractAnswer(up.record, fieldId, colIndex))
}
}
})
this.addLine(row)
})
this.hasBeenProcessed = true
}

/**
* Extracts the string representation from a field response
* @param unprocessedRecord
* @param fieldId
* @param colIndex
* @returns string representation of unprocessed record
*/
private _extractAnswer(
unprocessedRecord: KeyedResponse,
fieldId: string,
colIndex: number,
): string {
const fieldRecord = unprocessedRecord[fieldId]
if (!fieldRecord) return ''
return fieldRecord.getAnswer(colIndex)
}

/**
* Sorts unprocessed records from oldest to newest
*/
sort() {
sort(): void {
if (this.hasBeenSorted) return
this.unprocessed.sort((a, b) => this._dateComparator(a.created, b.created))
this.hasBeenSorted = true
Expand All @@ -138,8 +163,8 @@ class CsvMergedHeadersGenerator extends CsvGenerator {
* Add meta-data as first three rows of the CSV. If there is already meta-data
* added, it will be replaced by the latest counts.
*/
addMetaDataFromSubmission(errorCount, unverifiedCount) {
let metaDataRows = [
addMetaDataFromSubmission(errorCount: number, unverifiedCount: number): void {
const metaDataRows = [
['Expected total responses', this.expectedNumberOfRecords],
['Success count', this.length()],
['Error count', errorCount],
Expand All @@ -151,20 +176,20 @@ class CsvMergedHeadersGenerator extends CsvGenerator {

/**
* Main method to call to retrieve a downloadable csv.
* @param {string} filename
* @param filename name of csv file
*/
downloadCsv(filename) {
downloadCsv(filename: string): void {
this.sort()
this.process()
this.triggerFileDownload(filename)
}

/**
* Comparator for dates
* @param string firstDate
* @param string secondDate
* @param firstDate
* @param secondDate
*/
_dateComparator(firstDate, secondDate) {
private _dateComparator(firstDate: string, secondDate: string): number {
// cast to Asia/Singapore to ensure both dates are of the same timezone
const first = moment(firstDate).tz('Asia/Singapore')
const second = moment(secondDate).tz('Asia/Singapore')
Expand All @@ -178,5 +203,3 @@ class CsvMergedHeadersGenerator extends CsvGenerator {
}
}
}

module.exports = CsvMergedHeadersGenerator

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ArrayResponse } from '../../../../../types/response'

import { Response } from './Response.class'

export class ArrayAnswerResponse extends Response {
private response: ArrayResponse

constructor(responseData: ArrayResponse) {
super(responseData)
this.response = responseData
}

getAnswer(): string {
return this.response.answerArray.join(';')
}

get numCols(): number {
return 1
}
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { DisplayedResponseWithoutAnswer } from '../../../../../types/response'

export abstract class Response {
private data: DisplayedResponseWithoutAnswer

constructor(responseData: DisplayedResponseWithoutAnswer) {
this.data = responseData
}

get id(): string {
return this.data._id
}

/**
* Gets the CSV header.
* @returns {string}
*/
get question(): string {
return this.data.question
}

get isHeader(): boolean {
return this.data.isHeader ?? false
}

abstract get numCols(): number

abstract getAnswer(colIndex?: number): string
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { SingleResponse } from '../../../../../types/response'

import { Response } from './Response.class'

export class SingleAnswerResponse extends Response {
private response: SingleResponse

constructor(responseData: SingleResponse) {
super(responseData)
this.response = responseData
}

getAnswer(): string {
return this.response.answer
}

get numCols(): number {
return 1
}
}
Loading

0 comments on commit 4a91084

Please sign in to comment.