From 635ca7b675ae40adfc1efd793701529c9b16fd54 Mon Sep 17 00:00:00 2001 From: Himanshu Kansal Date: Thu, 12 Jan 2023 18:13:56 +0530 Subject: [PATCH] Image Fallback API integration for non-signed urls | Himanshu (#52) * non-signed images handle * Fix review comment * Saving & Fetching customer images from wm db * Review comment resolve * Fix component break * Reuse component CaseItemAvatar * Fix review comment * Minor fix --- package.json | 3 +- src/action/dataActions.ts | 17 ++++ src/common/Constants.ts | 15 +++ .../form/components/ImageUpload.tsx | 3 +- src/components/utlis/apiHelper.ts | 2 + src/components/utlis/commonFunctions.ts | 28 ++++++ src/components/utlis/customerDbHelper.ts | 55 +++++++++++ src/reducer/allCasesSlice.ts | 3 + src/screens/allCases/CaseItemAvatar.tsx | 73 +++++++++++--- .../caseDetails/UserDetailsSection.tsx | 12 +-- src/wmDB/const.ts | 3 +- src/wmDB/dao/CustomerImageDAO.ts | 94 +++++++++++++++++++ src/wmDB/db.ts | 3 +- src/wmDB/index.ts | 3 +- src/wmDB/model/CustomerImage.ts | 17 ++++ src/wmDB/schema.ts | 8 ++ yarn.lock | 25 +++++ 17 files changed, 336 insertions(+), 28 deletions(-) create mode 100644 src/components/utlis/customerDbHelper.ts create mode 100644 src/wmDB/dao/CustomerImageDAO.ts create mode 100644 src/wmDB/model/CustomerImage.ts diff --git a/package.json b/package.json index 34fb07e5..ffd58823 100644 --- a/package.json +++ b/package.json @@ -41,7 +41,8 @@ "react-native-toast-message": "2.1.5", "react-redux": "8.0.5", "redux": "4.2.0", - "redux-persist": "6.0.0" + "redux-persist": "6.0.0", + "rn-fetch-blob": "0.12.0" }, "devDependencies": { "@babel/core": "7.12.9", diff --git a/src/action/dataActions.ts b/src/action/dataActions.ts index 9d235a02..9efcde99 100644 --- a/src/action/dataActions.ts +++ b/src/action/dataActions.ts @@ -18,6 +18,7 @@ import { } from '../reducer/allCasesSlice'; import { ICaseItem } from '../screens/allCases/interface'; import { AppDispatch } from '../store/store'; +import { logError } from '../components/utlis/errorUtils'; export const getAllCases = () => (dispatch: AppDispatch) => { @@ -131,3 +132,19 @@ export const getFilters = () => (dispatch: AppDispatch) => { dispatch(setFilters(response.data)); }); }; + +export const getSignedURLs = (urlList: string[]) => (dispatch: AppDispatch) => { + const url = getApiUrl(ApiKeys.IMAGE_SIGNED_URLS); + dispatch(setLoading(true)); + return axiosInstance.post(url, urlList).then(response => { + if(response?.data) { + return response.data; + } + throw response; + }).catch((err) => { + logError(err); + }) + .finally(() => { + dispatch(setLoading(false)); + }) +}; diff --git a/src/common/Constants.ts b/src/common/Constants.ts index 0111c15a..49e349f6 100644 --- a/src/common/Constants.ts +++ b/src/common/Constants.ts @@ -1,3 +1,5 @@ +import { CaseStatuses } from "../screens/allCases/interface"; + export enum CONDITIONAL_OPERATORS { EQUALS = 'EQUALS', LESS_THAN_EQUAL_TO = 'LESS_THAN_EQUAL_TO', @@ -24,3 +26,16 @@ export enum FirestoreUpdateTypes { MODIFIED = 'modified', REMOVED = 'removed', } + +export enum MimeType { + 'image/jpeg' = 'image/jpeg', + 'image/png' = 'image/png' +} + +export const ClosedCaseStatusList = [CaseStatuses.CLOSED, CaseStatuses.FORCE_CLOSE, CaseStatuses.EXPIRED]; + +export const getPrefixBase64Image = (contentType: MimeType) => { + return `data:${contentType};base64,}`; +} + +export const PrefixJpegBase64Image = getPrefixBase64Image(MimeType["image/jpeg"]); diff --git a/src/components/form/components/ImageUpload.tsx b/src/components/form/components/ImageUpload.tsx index 512f81aa..61cefd6f 100644 --- a/src/components/form/components/ImageUpload.tsx +++ b/src/components/form/components/ImageUpload.tsx @@ -20,6 +20,7 @@ import { AnswerType } from '../interface'; import ErrorMessage from './ErrorMessage'; import withObservables from '@nozbe/with-observables'; import OfflineImageDAO from '../../../wmDB/dao/OfflineImageDAO'; +import { PrefixJpegBase64Image } from '../../../common/Constants'; interface IOfflineImage { idx: string; @@ -63,7 +64,7 @@ const ImageUpload: React.FC = props => { clickedImage: string | null, onChange: (...event: any[]) => void, ) => { - const data = `data:image/jpeg;base64,${clickedImage}`; + const data = `${PrefixJpegBase64Image}${clickedImage}`; var uniqueId = 'id' + (new Date()).getTime(); setImageId(uniqueId); onChange({ diff --git a/src/components/utlis/apiHelper.ts b/src/components/utlis/apiHelper.ts index 521139cd..baa0d4ff 100644 --- a/src/components/utlis/apiHelper.ts +++ b/src/components/utlis/apiHelper.ts @@ -23,6 +23,7 @@ export enum ApiKeys { LOGOUT, FEEDBACK, FILTERS, + IMAGE_SIGNED_URLS } const API_URLS: Record = {} as Record; @@ -34,6 +35,7 @@ API_URLS[ApiKeys.PINNED_CASES] = '/cases/pin'; API_URLS[ApiKeys.LOGOUT] = '/auth/logout'; API_URLS[ApiKeys.FEEDBACK] = '/cases/feedback'; API_URLS[ApiKeys.FILTERS] = '/cases/filters'; +API_URLS[ApiKeys.IMAGE_SIGNED_URLS] = '/cases/get-signed-urls'; const MOCK_API_URLS: Record = {} as Record; diff --git a/src/components/utlis/commonFunctions.ts b/src/components/utlis/commonFunctions.ts index a985c1c8..795421a2 100644 --- a/src/components/utlis/commonFunctions.ts +++ b/src/components/utlis/commonFunctions.ts @@ -1,5 +1,12 @@ +import RNFetchBlob from "rn-fetch-blob"; +import { getPrefixBase64Image, MimeType } from "../../common/Constants"; + import { Address } from "../../screens/caseDetails/interface"; +const fs = RNFetchBlob.fs; + +const RespHeaderContentTypeKey = 'Content-Type' + export const decideLoadingState = (textData: string): boolean => { if (!textData) { return true; @@ -17,3 +24,24 @@ export const getCommunicationAddress = (address: Address) => { const { houseNumber, lineOne, lineTwo, locality, street, city, state, pinCode } = address; return [houseNumber, lineOne, lineTwo, locality, street, city, state, pinCode].filter(element => element).join(', '); } + +export const getBase64FromUrl = async (imagePath: string) => { + let contentType: MimeType; + return RNFetchBlob.config({ + fileCache: true + }).fetch("GET", imagePath) + .then(resp => { + if (resp) { + imagePath = resp.path(); + contentType = resp.respInfo?.headers?.[RespHeaderContentTypeKey]; + return resp.readFile("base64"); + } + }) + .then(base64 => { + fs.unlink(imagePath); + if (contentType) { + return `${getPrefixBase64Image(contentType)}${base64}`; + } + return; + }); +} \ No newline at end of file diff --git a/src/components/utlis/customerDbHelper.ts b/src/components/utlis/customerDbHelper.ts new file mode 100644 index 00000000..b2b511a3 --- /dev/null +++ b/src/components/utlis/customerDbHelper.ts @@ -0,0 +1,55 @@ +import { ClosedCaseStatusList } from "../../common/Constants"; +import { GenericType } from "../../common/GenericTypes"; +import { CustomerImageDAO } from "../../wmDB"; +import { getBase64FromUrl } from "./commonFunctions"; + +const isCaseIdRecordExistOnCustomerDb = async (caseId: string) => { + let imageList = await CustomerImageDAO.getImageByCase(caseId); + return !!imageList?.length; +} + +export const getImage64FromCaseId = async (caseId: string) => { + let imageList = await CustomerImageDAO.getImageByCase(caseId); + return imageList?.[0]?.image64; +} + +export const updateRecordsOnCustomerDbFromCaseList = (caseList: GenericType[]) => { + if (!caseList?.length) return; + + const mappedCaseList = caseList + .map((caseItem: GenericType) => ( + { + imageURL: caseItem?.customerInfo?.imageURL, + caseId: caseItem?.id, + caseStatus: caseItem?.caseStatus + } + )); + + const filteredMappedCaseList = []; + const closedCaseIdList = []; + + (async () => { + for(const caseItem of mappedCaseList) { + if (caseItem.caseId && caseItem.imageURL) { + const isCaseIdRecordExist = await isCaseIdRecordExistOnCustomerDb(caseItem.caseId); + + if(isCaseIdRecordExist && ClosedCaseStatusList.includes(caseItem.caseStatus)) { + closedCaseIdList.push(caseItem.caseId); + } + + if (!isCaseIdRecordExist && !ClosedCaseStatusList.includes(caseItem.caseStatus)) { + const image64 = await getBase64FromUrl(caseItem.imageURL); + if(image64) { + filteredMappedCaseList.push({...caseItem, image64}); + } + } + } + } + if (filteredMappedCaseList?.length) { + await CustomerImageDAO.addBulkCustomerImage(filteredMappedCaseList); + } + if (closedCaseIdList?.length) { + await CustomerImageDAO.deleteImages(closedCaseIdList); + } + })(); +} \ No newline at end of file diff --git a/src/reducer/allCasesSlice.ts b/src/reducer/allCasesSlice.ts index a4ad7ecd..24638c9d 100644 --- a/src/reducer/allCasesSlice.ts +++ b/src/reducer/allCasesSlice.ts @@ -6,6 +6,7 @@ import { } from '../common/Constants'; import { CaseDetail } from '../screens/caseDetails/interface'; import { CaseUpdates } from '../hooks/useFirestoreUpdates'; +import { updateRecordsOnCustomerDbFromCaseList } from '../components/utlis/customerDbHelper'; import { _map } from '../../RN-UI-LIB/src/utlis/common'; export type ICasesMap = { [key: string]: ICaseItem }; @@ -119,6 +120,8 @@ const allCasesSlice = createSlice({ }, setCasesDetailsData: (state, action) => { const { details } = action.payload; + updateRecordsOnCustomerDbFromCaseList(details); + if (details?.length) { details.forEach((caseDetail: CaseDetail) => { const { id } = caseDetail; diff --git a/src/screens/allCases/CaseItemAvatar.tsx b/src/screens/allCases/CaseItemAvatar.tsx index 1237d316..1dae8feb 100644 --- a/src/screens/allCases/CaseItemAvatar.tsx +++ b/src/screens/allCases/CaseItemAvatar.tsx @@ -1,40 +1,83 @@ import React from 'react'; import {ICaseItem} from './interface'; import RoundCheckIcon from '../../icons/RoundCheckIcon'; -import {useAppSelector} from '../../hooks'; +import {useAppDispatch, useAppSelector} from '../../hooks'; import {CaseDetail} from '../caseDetails/interface'; import Avatar from '../../../RN-UI-LIB/src/components/Avatar'; import UnsyncedIcon from '../../../RN-UI-LIB/src/Icons/UnsyncedIcon'; import {StyleSheet, View} from 'react-native'; +import { getSignedURLs } from '../../action/dataActions'; +import { getImage64FromCaseId } from '../../components/utlis/customerDbHelper'; interface ICaseItemAvatar { - caseSelected: boolean; + caseSelected?: boolean; caseData: ICaseItem; + size?: number; } +const MAX_API_CALL = 3; + const CaseItemAvatar: React.FC = ({ - caseSelected, + caseSelected = false, caseData, + size = 32 }) => { + const dispatch = useAppDispatch(); + const {caseReferenceId} = caseData; const caseDetails: CaseDetail = useAppSelector( state => state.allCases.caseDetails?.[caseReferenceId], ); - if (caseSelected) { - return ; - } + + const [imageUrl, setImageUrl] = React.useState(''); + const [apiErrorCount, setApiErrorCount] = React.useState(0); + const isSynced = caseDetails?.isSynced; + + + React.useEffect(() => { + if (caseDetails?.customerInfo?.imageURL) { + (async () => { + let image64 = await getImage64FromCaseId(caseDetails?.id); + if (image64) { // if image exist in WM db, then setting the base64 + setImageUrl(image64); + } else { + setImageUrl(caseDetails.customerInfo.imageURL) + } + })(); + } + }, [caseDetails?.customerInfo?.imageURL]); + + const onError = async () => { + if (apiErrorCount < MAX_API_CALL) { + dispatch(getSignedURLs([imageUrl])).then((resp) => { + if (resp?.[imageUrl]) { + setApiErrorCount(apiErrorCount => apiErrorCount+1); + setImageUrl(resp[imageUrl]) + } + }) + } + } + return ( - - {!isSynced ? ( - - - - ) : null} + { + !caseSelected ? ( + + + {!isSynced ? ( + + + + ) : null} + + ) : + } ); }; diff --git a/src/screens/caseDetails/UserDetailsSection.tsx b/src/screens/caseDetails/UserDetailsSection.tsx index f09b6215..2c71924b 100644 --- a/src/screens/caseDetails/UserDetailsSection.tsx +++ b/src/screens/caseDetails/UserDetailsSection.tsx @@ -21,6 +21,8 @@ import CalenderIcon from '../../assets/icons/CalenderIcon'; import DocumentIcon from '../../assets/icons/DocumentIcon'; import IconLabel from '../../common/IconLabel'; import { decideLoadingState } from '../../components/utlis/commonFunctions'; +import CaseItemAvatar from '../allCases/CaseItemAvatar'; +import { ICaseItem } from '../allCases/interface'; import { CaseDetail, LoanAccountStatusUIMapping, @@ -64,16 +66,10 @@ const UserDetailsSection: React.FC = props => { /> }> - - {!isSynced ? ( - - - - ) : null} diff --git a/src/wmDB/const.ts b/src/wmDB/const.ts index fa4627b5..5a339e43 100644 --- a/src/wmDB/const.ts +++ b/src/wmDB/const.ts @@ -1,7 +1,8 @@ export enum TableName { OFFLINE_IMAGES = 'offline_image', + CUSTOMER_IMAGE = 'customer_image' }; -export const DB_VERSION = 1; +export const DB_VERSION = 2; export const DB_NAME = 'AVAPP'; diff --git a/src/wmDB/dao/CustomerImageDAO.ts b/src/wmDB/dao/CustomerImageDAO.ts new file mode 100644 index 00000000..0de26b4e --- /dev/null +++ b/src/wmDB/dao/CustomerImageDAO.ts @@ -0,0 +1,94 @@ +import { database } from '../db'; +import { Q } from '@nozbe/watermelondb' +import { TableName } from '../const'; +import { logError } from '../../components/utlis/errorUtils'; + +const customerImage = database.get(TableName.CUSTOMER_IMAGE); + +interface ICustomerOfflineImage { + caseId: string; + imageURL: string; + image64: string; +} + +export default { + observeOfflineImage: () => customerImage.query().observe(), + addBulkCustomerImage: async (customerImageList: ICustomerOfflineImage[]) => { + function prepareInsertion(customerImageList: ICustomerOfflineImage[]) { + return customerImageList.map(customerImageItem => { + try { + return customerImage.prepareCreate((imageRow: any) => { + imageRow.caseId = customerImageItem.caseId; + imageRow.imageURL = customerImageItem.imageURL; + imageRow.image64 = customerImageItem.image64; + }); + } catch (e) { + logError(e as Error, "WM DB") + } + }); + } + try { + return await database.action(async () => { + const allRecords = prepareInsertion(customerImageList); + await database.batch(...allRecords); + + }); + } catch (e) { + logError(e as Error, "WM DB") + } + }, + addCustomerImage: async (customerImageItem: ICustomerOfflineImage) => { + try { + return await database.action(async () => { + return await customerImage.create((imageRow: any) => { + imageRow.caseId = customerImageItem.caseId; + imageRow.imageURL = customerImageItem.imageURL; + imageRow.image64 = customerImageItem.image64; + }); + }); + } catch (e) { + logError(e as Error, "WM DB") + } + }, + getImageByURL: async (imageURL: string) => { + try { + return await database.action(async () => { + return await customerImage.query(Q.where('image_url', imageURL), Q.take(1)).fetch() + }); + } catch (e) { + logError(e as Error, "WM DB") + return; + } + }, + getImageByCase: async (caseId: string) => { + try { + return await database.action(async () => { + return await customerImage.query(Q.where('case_id', caseId), Q.take(1)).fetch() + }); + } catch (e) { + logError(e as Error, "WM DB") + return; + } + }, + deleteImages: async (caseIds: Array | string) => { + let tCaseIds = Array.isArray(caseIds) ? caseIds : [caseIds]; + try { + return await database.action(async () => { + await customerImage.query(Q.where('case_id', Q.oneOf(tCaseIds))).destroyAllPermanently() + }); + } catch (e) { + logError(e as Error, "WM DB") + return; + } + }, + deleteAll: async () => { + try { + return await database.action(async () => { + await customerImage.query().destroyAllPermanently(); + }); + } catch (e) { + logError(e as Error, "WM DB") + return; + } + }, +}; diff --git a/src/wmDB/db.ts b/src/wmDB/db.ts index 8bd7a94d..5002a52d 100644 --- a/src/wmDB/db.ts +++ b/src/wmDB/db.ts @@ -1,6 +1,7 @@ import { Database } from '@nozbe/watermelondb'; import SQLiteAdapter from '@nozbe/watermelondb/adapters/sqlite'; import { DB_NAME } from './const'; +import CustomerImage from './model/CustomerImage'; import OfflineImage from './model/OfflineImage'; import schema from './schema'; @@ -12,7 +13,7 @@ const adapter = new SQLiteAdapter({ const database = new Database({ adapter, - modelClasses: [OfflineImage] + modelClasses: [OfflineImage, CustomerImage] }); export { database }; diff --git a/src/wmDB/index.ts b/src/wmDB/index.ts index 8216b196..3df53f19 100644 --- a/src/wmDB/index.ts +++ b/src/wmDB/index.ts @@ -1,4 +1,5 @@ import PostDAO from './dao/OfflineImageDAO'; +import CustomerImageDAO from './dao/CustomerImageDAO'; export { database } from './db'; -export { PostDAO }; +export { PostDAO, CustomerImageDAO }; diff --git a/src/wmDB/model/CustomerImage.ts b/src/wmDB/model/CustomerImage.ts new file mode 100644 index 00000000..dbde5bf7 --- /dev/null +++ b/src/wmDB/model/CustomerImage.ts @@ -0,0 +1,17 @@ +import { Model } from '@nozbe/watermelondb'; +import { + field, + readonly, + date, +} from '@nozbe/watermelondb/decorators'; +import { TableName } from '../const'; + +export default class CustomerImage extends Model { + static table = TableName.CUSTOMER_IMAGE; + + @field('case_id') caseId!: string; + @field('image_url') imageURL!: string; + @field('image_64') image64!: string; + @readonly @date('created_at') createdAt!: any; + @readonly @date('updated_at') updatedAt!: any; +} \ No newline at end of file diff --git a/src/wmDB/schema.ts b/src/wmDB/schema.ts index b01a81fd..98e0a99d 100644 --- a/src/wmDB/schema.ts +++ b/src/wmDB/schema.ts @@ -10,6 +10,14 @@ export default appSchema({ { name: 'idx', type: 'string' }, { name: 'image_data', type: 'string' }, ] + }), + tableSchema({ + name: TableName.CUSTOMER_IMAGE, + columns: [ + { name: 'case_id', type: 'string' }, + { name: 'image_url', type: 'string' }, + { name: 'image_64', type: 'string' }, + ] }) ], }); \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index d4a414c3..9e82e695 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2531,6 +2531,11 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== +base-64@0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/base-64/-/base-64-0.1.0.tgz#780a99c84e7d600260361511c4877613bf24f6bb" + integrity sha512-Y5gU45svrR5tI2Vt/X9GPd3L0HNIKzGu202EjxrXMpuc2V2CiKgemAbUUsqYmZJvPtCXoUKjNZwBJzsNScUbXA== + base64-js@^1.1.2, base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" @@ -4137,6 +4142,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@7.0.6: + version "7.0.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.0.6.tgz#211bafaf49e525b8cd93260d14ab136152b3f57a" + integrity sha512-f8c0rE8JiCxpa52kWPAOa3ZaYEnzofDzCQLCn3Vdk0Z5OVLq3BsRFJI4S4ykpeVW6QMGBUkMeUpoEgWnMTnw5Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.2" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@7.1.6: version "7.1.6" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" @@ -7060,6 +7077,14 @@ rimraf@~2.6.2: dependencies: glob "^7.1.3" +rn-fetch-blob@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/rn-fetch-blob/-/rn-fetch-blob-0.12.0.tgz#ec610d2f9b3f1065556b58ab9c106eeb256f3cba" + integrity sha512-+QnR7AsJ14zqpVVUbzbtAjq0iI8c9tCg49tIoKO2ezjzRunN7YL6zFSFSWZm6d+mE/l9r+OeDM3jmb2tBb2WbA== + dependencies: + base-64 "0.1.0" + glob "7.0.6" + rsvp@^4.8.4: version "4.8.5" resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734"