feat(contracts): Add legal export package and ESIGN compliance improvements

- Add export_legal endpoint for signed contracts that generates a ZIP with:
  - Signed contract PDF
  - Audit certificate PDF with signature details and hash verification
  - Machine-readable signature_record.json
  - Integrity verification report
  - README documentation

- Add audit certificate template with:
  - Contract and signature information
  - Consent records with exact legal text
  - Document integrity verification (SHA-256 hash comparison)
  - ESIGN Act and UETA compliance statement

- Update ContractSigning page for ESIGN/UETA compliance:
  - Consent checkbox text now matches backend-stored legal text
  - Added proper legal notice with ESIGN Act references

- Add signed_at field to ContractListSerializer
- Add view/print buttons for signed contracts in Contracts page
- Allow viewing signed contracts via public signing URL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
poduck
2025-12-05 02:29:35 -05:00
parent 6feaa8dda5
commit 35f4301fe1
8 changed files with 1156 additions and 186 deletions

View File

@@ -205,7 +205,7 @@ export const useContracts = (filters?: {
template_version: c.template_version,
scope: c.scope as ContractScope,
status: c.status,
content: c.content,
content: c.content_html || c.content, // Backend returns content_html
customer: c.customer ? String(c.customer) : undefined,
customer_name: c.customer_name || undefined,
customer_email: c.customer_email || undefined,
@@ -219,7 +219,7 @@ export const useContracts = (filters?: {
expires_at: c.expires_at,
voided_at: c.voided_at,
voided_reason: c.voided_reason,
public_token: c.public_token,
public_token: c.signing_token || c.public_token, // Backend returns signing_token
created_at: c.created_at,
updated_at: c.updated_at,
}));
@@ -258,7 +258,7 @@ export const useContract = (id: string) => {
expires_at: data.expires_at,
voided_at: data.voided_at,
voided_reason: data.voided_reason,
public_token: data.public_token,
public_token: data.signing_token || data.public_token, // Backend returns signing_token
created_at: data.created_at,
updated_at: data.updated_at,
};
@@ -270,9 +270,9 @@ export const useContract = (id: string) => {
interface ContractInput {
template: string;
customer?: string;
appointment?: string;
service?: string;
customer_id?: string;
event_id?: string;
send_email?: boolean;
}
/**
@@ -352,8 +352,8 @@ export const usePublicContract = (token: string) => {
return useQuery<ContractPublicView>({
queryKey: ['public-contracts', token],
queryFn: async () => {
// Use a plain axios instance without auth
const { data } = await apiClient.get(`/contracts/public/${token}/`);
// Use the public signing endpoint
const { data } = await apiClient.get(`/contracts/sign/${token}/`);
return data;
},
enabled: !!token,
@@ -368,21 +368,57 @@ export const useSignContract = () => {
return useMutation({
mutationFn: async ({
token,
signature_data,
signer_name,
signer_email,
consent_checkbox_checked,
electronic_consent_given,
}: {
token: string;
signature_data: string;
signer_name: string;
signer_email: string;
consent_checkbox_checked: boolean;
electronic_consent_given: boolean;
}) => {
const { data } = await apiClient.post(`/contracts/public/${token}/sign/`, {
signature_data,
const { data } = await apiClient.post(`/contracts/sign/${token}/`, {
signer_name,
signer_email,
consent_checkbox_checked,
electronic_consent_given,
});
return data;
},
});
};
/**
* Hook to export a legal compliance package for a signed contract
*/
export const useExportLegalPackage = () => {
return useMutation({
mutationFn: async (id: string) => {
const response = await apiClient.get(`/contracts/${id}/export_legal/`, {
responseType: 'blob',
});
// Create download link
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
// Extract filename from Content-Disposition header if available
const contentDisposition = response.headers['content-disposition'];
let filename = `legal_export_${id}.zip`;
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="?([^";\n]+)"?/);
if (filenameMatch && filenameMatch[1]) {
filename = filenameMatch[1];
}
}
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
return { success: true };
},
});
};