feat: architecture refactor — core layer, spreadsheet engine, envelope bidding, tests
CI / quality (push) Waiting to run
CI / quality (push) Waiting to run
- Add src/core/ (config, errors, events, http, observability, storage, query) - Add src/Spreadsheet/ engine with native POI backend and Excel bridge - Add Envelope C spreadsheet flow and tender bidding envelope modules - Consolidate components into src/components/, remove legacy Component/ and Hooks/ - Add registration state/validation, auth session guard, profile edit modules - Add unit tests across axios, redux, bidding, registration, encryption, core - Add CI workflow, docs (architecture, audit, DSC API spec), keystore example - Remove tracked keystore binary; ignore scripts.zip Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
9acec328a7
commit
1d7646d938
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* EnvelopeCSpreadsheetScreen — full-screen native BOQ editor for Envelope-C.
|
||||
*
|
||||
* Opens the tender's BOQ template, renders the Excel-like editor (edit only the
|
||||
* originally-editable cells, live recalculation, validation), and submits the
|
||||
* metadata-preserving workbook through the existing Envelope-C API.
|
||||
*
|
||||
* Route params: { tender: <proceedToBid tender data>, buttonEnable?: boolean }.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { ActivityIndicator, KeyboardAvoidingView, Platform, StyleSheet, TouchableOpacity, View } from 'react-native';
|
||||
import { useNavigation, useRoute } from '@react-navigation/native';
|
||||
import { FileUp, Send } from 'lucide-react-native';
|
||||
|
||||
import InsideHeader from '../../../components/InsideHeader';
|
||||
import AppText from '../../../components/AppText';
|
||||
import { useTheme } from '../../../Hooks/useTheme';
|
||||
import SpreadsheetView from '../../../Spreadsheet/components/SpreadsheetView';
|
||||
import useEnvelopeCWorkbook from './useEnvelopeCWorkbook';
|
||||
|
||||
const EnvelopeCSpreadsheetScreen: React.FC = () => {
|
||||
const route = useRoute<any>();
|
||||
const navigation = useNavigation<any>();
|
||||
const { colors, spacing } = useTheme();
|
||||
|
||||
const tender = route.params?.tender;
|
||||
const buttonEnable = route.params?.buttonEnable ?? true;
|
||||
|
||||
const { ss, submitting, submit, importFromDevice } = useEnvelopeCWorkbook(tender, () =>
|
||||
navigation.goBack(),
|
||||
);
|
||||
|
||||
const canSubmit = ss.status === 'ready' && !submitting && ss.errorCount === 0;
|
||||
|
||||
return (
|
||||
<View style={{ flex: 1, backgroundColor: colors.background }}>
|
||||
<InsideHeader title="Envelope C" showArrow />
|
||||
|
||||
{/* Keep the grid + footer above the keyboard. Android already resizes the
|
||||
window (adjustResize); iOS needs padding behaviour. */}
|
||||
<KeyboardAvoidingView
|
||||
style={{ flex: 1 }}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={0}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<SpreadsheetView ss={ss} emptyHint="Loading the BOQ template…" />
|
||||
</View>
|
||||
|
||||
<View style={[styles.footer, { backgroundColor: colors.surface, borderTopColor: colors.border }]}>
|
||||
|
||||
|
||||
{buttonEnable && (
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.primaryBtn,
|
||||
{ backgroundColor: canSubmit ? colors.primary : colors.secondary, marginLeft: spacing.s },
|
||||
]}
|
||||
onPress={submit}
|
||||
disabled={!canSubmit}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{submitting ? (
|
||||
<ActivityIndicator size="small" color={colors.buttonText} />
|
||||
) : (
|
||||
<>
|
||||
<Send size={16} color={colors.buttonText} />
|
||||
<AppText variant="button" color={colors.buttonText}>
|
||||
{ss.errorCount > 0 ? `Fix ${ss.errorCount} error(s)` : 'Submit'}
|
||||
</AppText>
|
||||
</>
|
||||
)}
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
</KeyboardAvoidingView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
footer: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
},
|
||||
secondaryBtn: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
borderWidth: 1.5,
|
||||
},
|
||||
primaryBtn: {
|
||||
flex: 1,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 8,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
},
|
||||
});
|
||||
|
||||
export default EnvelopeCSpreadsheetScreen;
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* useEnvelopeCWorkbook — Envelope-C BOQ integration.
|
||||
*
|
||||
* Implements the mobile-bid encryption contract in MOBILE_BID_DSC_API_SPEC.md:
|
||||
* downloads the (plaintext) BOQ template into the editor, then on submit
|
||||
* 1. exports the metadata-preserving workbook bytes,
|
||||
* 2. AES/ECB encrypts the RAW file bytes with a SHA-1(K)-derived key,
|
||||
* 3. RSA-wraps the envelope key K with each officer's public key,
|
||||
* 4. uploads the raw ciphertext file + `randomKeyGenerated=K` + wrapped keys.
|
||||
*
|
||||
* Steps 2–3 are exactly what makes web rebid/opening able to RSA-unwrap the
|
||||
* key; without them the stored `contractor_enc_key` is a raw key and the DSC
|
||||
* unwrap times out.
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert } from 'react-native';
|
||||
import axios from 'axios';
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import useSpreadsheet from '../../../Spreadsheet/hooks/useSpreadsheet';
|
||||
import FileManager from '../../../Spreadsheet/engine/FileManager';
|
||||
import {
|
||||
encrypt,
|
||||
encryptFileBytesToBase64,
|
||||
encryptWithRSA,
|
||||
sha256HexOfBytes,
|
||||
} from '../../../Services/Encryption/encrypt';
|
||||
import { GET_ENVELOPE_C, SUBMIT_ENVELOPE_C_UPLOAD } from '../../../Services/ApiCall/ApiUrls';
|
||||
|
||||
interface OfficerKey {
|
||||
officersId: number;
|
||||
officerPublicKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
interface SaveEnvelopeOfficerDetail {
|
||||
officerId: number;
|
||||
officerEncryptedKey: string;
|
||||
officerType: string;
|
||||
}
|
||||
|
||||
const ENCRYPTED_FILE_NAME = 'envelopeC_encrypted.xlsx';
|
||||
|
||||
export const useEnvelopeCWorkbook = (tender: any, onSubmitted?: () => void) => {
|
||||
const ss = useSpreadsheet();
|
||||
const tokenObj = useSelector((state: any) => state.token?.token);
|
||||
const bearer = tokenObj?.token;
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const tenderId = tender?.tenderId;
|
||||
const encryptionKey: string = tender?.envelopeKeyDetailsResDTO?.envelopeCGenerateKey || '';
|
||||
|
||||
/* ------------------------------- load ------------------------------- */
|
||||
|
||||
const loadFromApi = useCallback(async () => {
|
||||
if (!tenderId) {
|
||||
Alert.alert('Error', 'Missing tender id.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await axios.get(`${GET_ENVELOPE_C}${tenderId}`, {
|
||||
responseType: 'arraybuffer',
|
||||
headers: { Authentication: `Bearer ${bearer}` },
|
||||
});
|
||||
await ss.loadFromBytes(response.data as ArrayBuffer, `envelopeC_${tenderId}.xlsx`);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Import failed', e?.response?.data?.message || e?.message || 'Could not load the BOQ template.');
|
||||
}
|
||||
// ss identity changes across renders; loadFromBytes is stable.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenderId, bearer]);
|
||||
|
||||
useEffect(() => {
|
||||
loadFromApi();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loadFromApi]);
|
||||
|
||||
/* --------------------------- import from device --------------------- */
|
||||
|
||||
const importFromDevice = useCallback(async () => {
|
||||
try {
|
||||
const picked = await FileManager.pickWorkbook();
|
||||
if (!picked) return;
|
||||
await ss.loadFromBytes(picked.bytes, picked.name);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Import failed', e?.message || 'Could not open the selected file.');
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
/* ------------------------------- submit ----------------------------- */
|
||||
|
||||
const buildOfficerEncryptedKeys = useCallback((): SaveEnvelopeOfficerDetail[] | null => {
|
||||
const officerKeys: OfficerKey[] = tender?.envelopeKeyDetailsResDTO?.departmentOfficerPublicKeys || [];
|
||||
// RSA/ECB/PKCS1-wrap the plaintext envelope key K with each officer's public
|
||||
// key (Base64 SPKI DER → ~344-char Base64 ciphertext), per
|
||||
// MOBILE_BID_DSC_API_SPEC.md. The web DSC applet unwraps it with the
|
||||
// officer's PRIVATE key during rebid/opening — a raw or empty value makes
|
||||
// the bid unrecoverable, so any wrap failure aborts the submission.
|
||||
// (`officersId` -> `officerId` per the API contract.)
|
||||
const wrapped: SaveEnvelopeOfficerDetail[] = [];
|
||||
for (const o of officerKeys) {
|
||||
const officerEncryptedKey = encryptWithRSA(encryptionKey, o.officerPublicKey);
|
||||
if (!officerEncryptedKey) return null;
|
||||
wrapped.push({
|
||||
officerId: o.officersId,
|
||||
officerEncryptedKey,
|
||||
officerType: o.officerType,
|
||||
});
|
||||
}
|
||||
return wrapped;
|
||||
}, [tender, encryptionKey]);
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
// 1) Validation gate — block submission while any editable cell is invalid.
|
||||
const errors = ss.validateAll();
|
||||
if (errors.length > 0) {
|
||||
Alert.alert('Validation failed', `Please fix ${errors.length} highlighted cell(s) before submitting.`);
|
||||
return;
|
||||
}
|
||||
if (!encryptionKey) {
|
||||
// Matches the server's HTTP 400 guard: a mobile bid must be client-
|
||||
// encrypted and echo randomKeyGenerated.
|
||||
Alert.alert('Error', 'Encryption key not available for this tender.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
let encryptedPath: string | null = null;
|
||||
try {
|
||||
// 2) RSA-wrap the envelope key K for every opening officer + contractor.
|
||||
const officerKeys = buildOfficerEncryptedKeys();
|
||||
if (!officerKeys) {
|
||||
throw new Error('Could not encrypt the envelope key for the opening officers.');
|
||||
}
|
||||
|
||||
// 3) Export the metadata-preserving workbook and AES-encrypt the RAW
|
||||
// bytes with the SHA-1(K) file key; upload as raw ciphertext bytes.
|
||||
const bytes = await ss.exportBytes();
|
||||
const cipherBase64 = encryptFileBytesToBase64(bytes, encryptionKey);
|
||||
if (!cipherBase64) throw new Error('Failed to encrypt the workbook.');
|
||||
|
||||
encryptedPath = await FileManager.writeBase64(ENCRYPTED_FILE_NAME, cipherBase64);
|
||||
const hashValue = sha256HexOfBytes(bytes);
|
||||
|
||||
// 4) Build the request exactly as MOBILE_BID_DSC_API_SPEC.md specifies.
|
||||
const requestDto = {
|
||||
tenderId,
|
||||
deptId: tender?.deptId,
|
||||
typesOfTender: tender?.typesOfTender,
|
||||
deviceType: 'Mobile',
|
||||
randomKeyGenerated: encryptionKey, // plaintext K, echoed back
|
||||
// The financial value lives inside the encrypted BOQ file; the text
|
||||
// fields still travel as AES(text) ciphertext per the contract.
|
||||
fieldValue: '',
|
||||
fieldValueInWord: encrypt('zero', encryptionKey) || '',
|
||||
hashValue,
|
||||
fileName: 'boq.xlsx',
|
||||
saveEnvelopesOfficersDetailsResDTO: officerKeys,
|
||||
};
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('requestDto', JSON.stringify(requestDto));
|
||||
formData.append('file', {
|
||||
uri: `file://${encryptedPath}`,
|
||||
name: 'boq.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
} as any);
|
||||
|
||||
// 5) Upload.
|
||||
await axios.post(SUBMIT_ENVELOPE_C_UPLOAD, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data', Authentication: `Bearer ${bearer}` },
|
||||
});
|
||||
|
||||
Alert.alert('Success', 'Envelope-C BOQ submitted successfully.');
|
||||
onSubmitted?.();
|
||||
} catch (e: any) {
|
||||
Alert.alert('Save failed', e?.response?.data?.message || e?.message || 'Could not submit the workbook.');
|
||||
} finally {
|
||||
if (encryptedPath) await FileManager.remove(encryptedPath);
|
||||
setSubmitting(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [ss, encryptionKey, tenderId, tender, bearer, buildOfficerEncryptedKeys, onSubmitted]);
|
||||
|
||||
return { ss, submitting, submit, importFromDevice, reload: loadFromApi };
|
||||
};
|
||||
|
||||
export default useEnvelopeCWorkbook;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user