diff --git a/TECHNICAL_DEBT.md b/TECHNICAL_DEBT.md new file mode 100644 index 00000000..59cbb7a1 --- /dev/null +++ b/TECHNICAL_DEBT.md @@ -0,0 +1,35 @@ +# Technical Debt + +A living record of known gaps, shortcuts, and missing enterprise capabilities. + +--- + +## AS2 Protocol + +### Async MDN — Inbound Callback Not Implemented +**Priority:** High +**Affects:** Large enterprise partners (banks, healthcare networks) that require async MDN. + +| Scenario | Status | +|---|---| +| Outbound, Sync MDN | ✅ Fully implemented | +| Outbound, Async MDN — `Receipt-Delivery-Option` header sent correctly | ✅ Implemented | +| Outbound, Async MDN — receiving the callback and reconciling it | ❌ Not implemented | +| Inbound — partner requests Async MDN via `Receipt-Delivery-Option` header | ❌ Not implemented | + +**Required work:** +- `services/as2_server/core/receive_as2.py`: Detect when an inbound POST is an MDN callback + (via `Content-Disposition: notification` or matching `Message-ID` header), route it to a + dedicated `MDNReconciliationUseCase` instead of treating it as new EDI. +- `services/worker`: When `mdn_type == "ASYNC"`, after sending, write `PENDING_MDN` status and + store the sent `Message-ID` and `MIC` so they can be matched when the callback arrives. +- Database: Add an `outbound_mdn_pending` table `(message_id, mic, trace_id, expires_at)`. + +--- + +## Testing + +### No Frontend Test Runner (Vitest) +**Priority:** Medium +`make test` skips frontend tests with a placeholder comment. +React component tests and TanStack Query mutation tests are not covered. diff --git a/frontend/web/src/components/ui/edi-editor-pane.tsx b/frontend/web/src/components/ui/edi-editor-pane.tsx new file mode 100644 index 00000000..27e2ecf9 --- /dev/null +++ b/frontend/web/src/components/ui/edi-editor-pane.tsx @@ -0,0 +1,127 @@ +import React, { useState } from 'react'; +import Editor from '@monaco-editor/react'; +import { UploadCloud } from 'lucide-react'; +import { registerEdiLanguageAndTheme } from '@/utils/monaco-edi'; + +export interface EdiEditorPaneProps { + value: string; + onChange: (value: string) => void; + language?: 'edi' | 'json' | 'plaintext'; + placeholder?: string; + cornerPlaceholder?: string; + className?: string; + acceptedFileExtensions?: string; +} + +export function EdiEditorPane({ value, onChange, language = 'edi', placeholder, cornerPlaceholder, className = '', acceptedFileExtensions = ".edi,.json,.txt,.x12" }: EdiEditorPaneProps) { + const [isDragging, setIsDragging] = useState(false); + const [error, setError] = useState(null); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const applyFile = async (file: File) => { + setError(null); + if (file.size > 1024 * 1024) { + setError('File size exceeds 1MB limit. Please upload a smaller file.'); + return; + } + try { + const text = await file.text(); + onChange(text); + } catch (err) { + setError(`Failed to read file: ${(err as Error).message}`); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { + await applyFile(e.dataTransfer.files[0]); + } + }; + + const handleFileUpload = async (e: React.ChangeEvent) => { + if (e.target.files && e.target.files.length > 0) { + await applyFile(e.target.files[0]); + e.target.value = ''; + } + }; + + const handleEditorWillMount = (monaco: any) => { + registerEdiLanguageAndTheme(monaco); + }; + + return ( +
+ {error && ( +
+ {error} + +
+ )} + {isDragging && ( +
+ + Drop file to load +
+ )} + {!value && !isDragging && ( +
+ {cornerPlaceholder && ( +
+ {cornerPlaceholder} +
+ )} + +

Drag and drop your file here

+

{placeholder || `or upload a file, or click anywhere to paste raw ${language.toUpperCase()}`}

+ +
+ )} + + onChange(val || '')} + theme="soopa-theme" + beforeMount={handleEditorWillMount} + options={{ + automaticLayout: true, + minimap: { enabled: false }, + scrollBeyondLastLine: false, + lineNumbersMinChars: 3, + wordWrap: 'on', + folding: true, + padding: { top: 16, bottom: 16 }, + renderLineHighlight: 'none', + hideCursorInOverviewRuler: true, + overviewRulerBorder: false, + scrollbar: { + verticalScrollbarSize: 8, + horizontalScrollbarSize: 8, + }, + }} + /> +
+ ); +} diff --git a/frontend/web/src/components/ui/form-modal.tsx b/frontend/web/src/components/ui/form-modal.tsx index 7a32d662..3eb320fb 100644 --- a/frontend/web/src/components/ui/form-modal.tsx +++ b/frontend/web/src/components/ui/form-modal.tsx @@ -35,7 +35,7 @@ export function FormModal({ footerContent }: FormModalProps) { return ( - + - e.preventDefault()}> + e.preventDefault()} + onFocusOutside={(e) => e.preventDefault()} + > {icon && ( diff --git a/frontend/web/src/features/partners/api/IPartnersRepository.ts b/frontend/web/src/features/partners/api/IPartnersRepository.ts index 4d76d061..40aed236 100644 --- a/frontend/web/src/features/partners/api/IPartnersRepository.ts +++ b/frontend/web/src/features/partners/api/IPartnersRepository.ts @@ -27,6 +27,7 @@ export interface IPartnersRepository { createPlatformPartnership(payload: CreatePartnershipPayload): Promise; updatePlatformPartnership(id: string, payload: UpdatePartnershipPayload): Promise; deletePlatformPartnership(id: string): Promise; + testAs2PartnershipConnection(id: string, custom_payload?: string): Promise<{ success: boolean; mdn_disposition?: string | null; reason?: string | null; sent_payload?: string | null; raw_mdn?: string | null }>; // Certificates exportCertificates(partnerId: string): Promise; diff --git a/frontend/web/src/features/partners/api/partnerHooks.ts b/frontend/web/src/features/partners/api/partnerHooks.ts index c37d175c..7698eb74 100644 --- a/frontend/web/src/features/partners/api/partnerHooks.ts +++ b/frontend/web/src/features/partners/api/partnerHooks.ts @@ -243,3 +243,11 @@ export function useTestExistingSftpConnectionMutation() { repo.testExistingSftpConnection(id, payload) }); } + +export function useTestAs2PartnershipConnectionMutation() { + const repo = useRepository(); + return useMutation({ + mutationFn: ({ id, custom_payload }: { id: string; custom_payload?: string }) => + repo.testAs2PartnershipConnection(id, custom_payload), + }); +} diff --git a/frontend/web/src/features/partners/api/partnersApi.ts b/frontend/web/src/features/partners/api/partnersApi.ts index 1ff15349..5b328b6d 100644 --- a/frontend/web/src/features/partners/api/partnersApi.ts +++ b/frontend/web/src/features/partners/api/partnersApi.ts @@ -103,6 +103,13 @@ class HttpPartnersRepository implements IPartnersRepository { }); } + testAs2PartnershipConnection(id: string, custom_payload?: string): Promise<{ success: boolean; mdn_disposition?: string | null; reason?: string | null; sent_payload?: string | null; raw_mdn?: string | null }> { + return this.request(`/api/v1/platform/trading-partners/as2/partnerships/${id}/test`, { + method: 'POST', + body: custom_payload ? JSON.stringify({ custom_payload }) : undefined, + }); + } + // ── Certificates ─────────────────────────── exportCertificates(partnerId: string): Promise { return this.request( diff --git a/frontend/web/src/features/partners/components/CertificateInput.tsx b/frontend/web/src/features/partners/components/CertificateInput.tsx index e08cf652..2bb9d332 100644 --- a/frontend/web/src/features/partners/components/CertificateInput.tsx +++ b/frontend/web/src/features/partners/components/CertificateInput.tsx @@ -1,8 +1,4 @@ -import React, { useState, useRef } from 'react'; -import { Button } from '@/components/ui/button'; -import { UploadCloud, FileCode, ClipboardPaste } from 'lucide-react'; - -type CertMode = 'upload' | 'paste'; +import { EdiEditorPane } from '@/components/ui/edi-editor-pane'; export interface CertificateInputProps { value: string; @@ -10,150 +6,19 @@ export interface CertificateInputProps { } /** - * Reusable certificate input with two modes: - * - Upload: drag-and-drop or file picker - * - Paste: monospace textarea for raw PEM text - * - * Stateless regarding the cert value itself — parent owns it. + * Reusable certificate input powered by Monaco Editor. + * Supports drag-and-drop, file picker, and pasting raw PEM text natively. */ export function CertificateInput({ value, onChange }: CertificateInputProps) { - const [mode, setMode] = useState('upload'); - const [isDragging, setIsDragging] = useState(false); - const [pasteValue, setPasteValue] = useState(value); - - React.useEffect(() => { - setPasteValue(value); - }, [value]); - - const fileInputRef = useRef(null); - - const readFile = (file: File) => { - const reader = new FileReader(); - reader.onload = (e) => { - const content = e.target?.result as string; - if (content) onChange(content); - }; - reader.readAsText(file); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragging(false); - if (e.dataTransfer.files.length > 0) readFile(e.dataTransfer.files[0]); - }; - - const handlePasteChange = (text: string) => { - setPasteValue(text); - onChange(text); - }; - - const handleClear = () => { - onChange(''); - setPasteValue(''); - if (fileInputRef.current) fileInputRef.current.value = ''; - }; - - const switchMode = (next: CertMode) => { - setMode(next); - }; - return ( -
- {/* Tab toggle */} -
- - -
- - {mode === 'upload' ? ( -
{ e.preventDefault(); setIsDragging(true); }} - onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} - onDrop={handleDrop} - > - e.target.files?.[0] && readFile(e.target.files[0])} - /> - - {value ? ( -
-
- -
-
- Certificate Loaded - - {value.substring(0, 30)}... - -
- -
- ) : ( -
fileInputRef.current?.click()} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - fileInputRef.current?.click(); - } - }} - tabIndex={0} - role="button" - > -
- -
-
- Click to upload - or drag and drop -
- PEM, CER, or CRT up to 10MB -
- )} -
- ) : ( -
-