-
Notifications
You must be signed in to change notification settings - Fork 0
AS2 inbound and outbound connection #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string | null>(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<HTMLInputElement>) => { | ||
| if (e.target.files && e.target.files.length > 0) { | ||
| await applyFile(e.target.files[0]); | ||
| e.target.value = ''; | ||
| } | ||
| }; | ||
|
|
||
| const handleEditorWillMount = (monaco: any) => { | ||
| registerEdiLanguageAndTheme(monaco); | ||
| }; | ||
|
Comment on lines
+59
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win Duplicated Monaco language/theme registration.
🤖 Prompt for AI Agents |
||
|
|
||
| return ( | ||
| <div | ||
| className={`flex-1 p-0 bg-white min-h-[250px] flex flex-col relative group ${className}`} | ||
| onDragOver={handleDragOver} | ||
| onDragLeave={handleDragLeave} | ||
| onDrop={handleDrop} | ||
| > | ||
| {error && ( | ||
| <div className="absolute top-2 right-2 left-2 z-30 p-3 bg-red-50 border border-red-200 text-red-700 text-sm rounded-md shadow-sm flex items-center justify-between"> | ||
| <span>{error}</span> | ||
| <button onClick={() => setError(null)} className="text-red-500 hover:text-red-700 font-bold ml-4"> | ||
| × | ||
| </button> | ||
| </div> | ||
| )} | ||
| {isDragging && ( | ||
| <div className="absolute inset-0 bg-indigo-50/90 z-20 flex flex-col items-center justify-center border-2 border-indigo-400 border-dashed m-2 rounded-lg backdrop-blur-sm transition-all duration-200"> | ||
| <UploadCloud className="w-10 h-10 text-indigo-500 mb-3 animate-bounce" /> | ||
| <span className="text-indigo-700 font-semibold text-lg tracking-tight">Drop file to load</span> | ||
| </div> | ||
| )} | ||
| {!value && !isDragging && ( | ||
| <div className="absolute inset-0 z-10 flex flex-col items-center justify-center p-6 text-center pointer-events-none"> | ||
| {cornerPlaceholder && ( | ||
| <div className="absolute top-4 left-6 text-xs text-slate-400 font-mono text-left whitespace-pre-wrap pointer-events-none opacity-90"> | ||
| {cornerPlaceholder} | ||
| </div> | ||
| )} | ||
| <UploadCloud className="w-12 h-12 text-slate-200 mb-4" /> | ||
| <h3 className="text-lg font-medium text-slate-900 mb-1">Drag and drop your file here</h3> | ||
| <p className="text-sm text-slate-500 mb-6">{placeholder || `or upload a file, or click anywhere to paste raw ${language.toUpperCase()}`}</p> | ||
| <label className="pointer-events-auto cursor-pointer inline-flex items-center justify-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 transition-colors"> | ||
| Upload File | ||
| <input type="file" className="hidden" accept={acceptedFileExtensions} onChange={handleFileUpload} /> | ||
| </label> | ||
| </div> | ||
| )} | ||
|
|
||
| <Editor | ||
| height="100%" | ||
| language={language} | ||
| value={value} | ||
| onChange={(val) => 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, | ||
| }, | ||
| }} | ||
| /> | ||
| </div> | ||
| ); | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,159 +1,24 @@ | ||
| 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; | ||
| onChange: (pem: string) => void; | ||
| } | ||
|
|
||
| /** | ||
| * 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<CertMode>('upload'); | ||
| const [isDragging, setIsDragging] = useState(false); | ||
| const [pasteValue, setPasteValue] = useState(value); | ||
|
|
||
| React.useEffect(() => { | ||
| setPasteValue(value); | ||
| }, [value]); | ||
|
|
||
| const fileInputRef = useRef<HTMLInputElement>(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 ( | ||
| <div className="grid gap-2"> | ||
| {/* Tab toggle */} | ||
| <div className="flex items-center gap-1 bg-slate-100 rounded-lg p-0.5 w-fit"> | ||
| <button | ||
| type="button" | ||
| onClick={() => switchMode('upload')} | ||
| className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${mode === 'upload' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`} | ||
| > | ||
| <UploadCloud className="h-3.5 w-3.5" /> | ||
| Upload | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => switchMode('paste')} | ||
| className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${mode === 'paste' ? 'bg-white text-slate-800 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`} | ||
| > | ||
| <ClipboardPaste className="h-3.5 w-3.5" /> | ||
| Paste | ||
| </button> | ||
| </div> | ||
|
|
||
| {mode === 'upload' ? ( | ||
| <div | ||
| className={`relative flex flex-col items-center justify-center rounded-xl border-2 border-dashed p-6 transition-colors ${isDragging ? 'border-indigo-500 bg-indigo-50/50' : 'border-slate-200 bg-slate-50 hover:bg-slate-100'}`} | ||
| onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }} | ||
| onDragLeave={(e) => { e.preventDefault(); setIsDragging(false); }} | ||
| onDrop={handleDrop} | ||
| > | ||
| <input | ||
| type="file" | ||
| ref={fileInputRef} | ||
| className="hidden" | ||
| accept=".cer,.crt,.pem,.txt" | ||
| onChange={(e) => e.target.files?.[0] && readFile(e.target.files[0])} | ||
| /> | ||
|
|
||
| {value ? ( | ||
| <div className="flex flex-col items-center gap-3"> | ||
| <div className="flex h-12 w-12 items-center justify-center rounded-full bg-emerald-100 text-emerald-600"> | ||
| <FileCode className="h-6 w-6" /> | ||
| </div> | ||
| <div className="text-center"> | ||
| <span className="block text-sm font-semibold text-slate-700">Certificate Loaded</span> | ||
| <span className="block text-xs text-slate-500 mt-1 truncate max-w-[200px]"> | ||
| {value.substring(0, 30)}... | ||
| </span> | ||
| </div> | ||
| <Button | ||
| type="button" | ||
| variant="outline" | ||
| onClick={handleClear} | ||
| className="h-8 text-xs px-3 rounded-lg mt-1 border-slate-200 hover:bg-red-50 hover:text-red-600 hover:border-red-200" | ||
| > | ||
| Replace File | ||
| </Button> | ||
| </div> | ||
| ) : ( | ||
| <div | ||
| className="flex flex-col items-center gap-3 text-center cursor-pointer" | ||
| onClick={() => fileInputRef.current?.click()} | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Enter' || e.key === ' ') { | ||
| e.preventDefault(); | ||
| fileInputRef.current?.click(); | ||
| } | ||
| }} | ||
| tabIndex={0} | ||
| role="button" | ||
| > | ||
| <div className="rounded-full bg-white p-3 shadow-sm border border-slate-100"> | ||
| <UploadCloud className="h-6 w-6 text-indigo-500" /> | ||
| </div> | ||
| <div> | ||
| <span className="text-sm font-semibold text-indigo-600 hover:underline">Click to upload</span> | ||
| <span className="text-sm text-slate-500"> or drag and drop</span> | ||
| </div> | ||
| <span className="text-xs text-slate-400">PEM, CER, or CRT up to 10MB</span> | ||
| </div> | ||
| )} | ||
| </div> | ||
| ) : ( | ||
| <div className="grid gap-1.5"> | ||
| <textarea | ||
| className="w-full h-40 rounded-xl border border-slate-200 bg-slate-50 p-3 font-mono text-xs text-slate-700 resize-none focus:outline-none focus:ring-2 focus:ring-indigo-300 focus:border-indigo-400 placeholder:text-slate-400" | ||
| placeholder={"-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----"} | ||
| value={pasteValue} | ||
| onChange={(e) => handlePasteChange(e.target.value)} | ||
| /> | ||
| {value && ( | ||
| <div className="flex items-center gap-2 text-xs text-emerald-600 font-medium"> | ||
| <FileCode className="h-3.5 w-3.5" /> | ||
| Certificate detected | ||
| </div> | ||
| )} | ||
| </div> | ||
| )} | ||
| <div className="border border-slate-200 rounded-xl overflow-hidden h-[250px] flex flex-col"> | ||
| <EdiEditorPane | ||
| value={value} | ||
| onChange={onChange} | ||
| language="plaintext" | ||
| placeholder="or click anywhere to paste raw PEM" | ||
| cornerPlaceholder={"-----BEGIN CERTIFICATE-----\nMIID...\n-----END CERTIFICATE-----"} | ||
| /> | ||
| </div> | ||
| ); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.