@accounter/server
Version:
Accounter GraphQL server
112 lines (111 loc) • 5.19 kB
TypeScript
import { type Injector } from 'graphql-modules';
import { CloudinaryProvider } from '../../app-providers/cloudinary.js';
import { DBProvider } from '../../app-providers/db.provider.js';
import { IngestOutcome } from '../contracts.js';
import { EmailIngestionControlProvider } from './email-ingestion-control.provider.js';
/**
* Thrown by {@link EmailIngestionIngestProvider.prepareDocuments} when a document
* cannot be uploaded/prepared (e.g. the Cloudinary upload fails). Distinguishes an
* expected, recoverable preparation failure — which the ingest flow turns into an
* `UPLOAD_FAILED` quarantine (recorded, reprocessable) — from a truly unexpected
* error, which is rethrown as-is. The original cause is carried on `.cause`.
*/
export declare class DocumentPreparationError extends Error {
constructor(message: string, options?: {
cause?: unknown;
});
}
export type IngestInput = {
grantJti: string;
idempotencyKey: string;
tenantId: string;
messageId: string;
rawMessageHash: string;
correlationId?: string;
/** Email subject header, used to build a human-readable charge description. */
subject?: string;
/** Sender (From header) address, used in the charge description. */
sender?: string;
/** ISO-8601 timestamp the message was received, used in the charge description. */
receivedAt?: string;
extractedDocuments: Array<{
hash: string;
sizeBytes: number;
mimeType: string;
filename?: string | null;
/** Base64-encoded document bytes (inline transport); omitted = metadata only. */
content?: string | null;
}>;
};
export type IngestResult = {
outcome: typeof IngestOutcome.INSERTED;
ingestId: string;
auditId: string;
} | {
outcome: typeof IngestOutcome.DUPLICATE;
existingIngestId: string | null;
auditId: string;
} | {
outcome: typeof IngestOutcome.QUARANTINED | typeof IngestOutcome.IGNORED;
auditId: string;
reasonCode: string;
} | {
outcome: typeof IngestOutcome.REJECTED;
reasonCode: string;
};
/**
* Orchestrates the v2 ingest flow for gateway-initiated ingestion.
*
* The caller presents a gateway_control_plane auth token with an empty
* businessId, so TenantAwareDBClient (which derives the tenant from the auth
* session) cannot be used here — the authoritative tenant comes from the
* single-use, cryptographically-validated grant instead. To still get RLS as a
* second defense layer, all tenant-bound reads/writes run inside one
* transaction whose `app.current_business_id` is pinned to the grant tenant
* (see {@link withTenantContext}); the `tenant_isolation` WITH CHECK policies
* on the idempotency/dedup/quarantine tables then enforce
* `owner_id = tenantId` on top of the explicit owner_id filters in each query.
*
* Flow: grant validation → idempotency check → dedup check → quarantine or insert.
*/
export declare class EmailIngestionIngestProvider {
private dbProvider;
private controlProvider;
private cloudinaryProvider;
constructor(dbProvider: DBProvider, controlProvider: EmailIngestionControlProvider, cloudinaryProvider: CloudinaryProvider);
performIngest(input: IngestInput, injector: Injector): Promise<IngestResult>;
/**
* Prepare documents for persistence WITHOUT holding the write transaction open:
* dedup new documents by hash (a short read), then upload to Cloudinary and OCR
* (Anthropic) in parallel, outside any transaction. This mirrors the legacy
* `getDocumentFromFile` path (Cloudinary upload + `getOcrData` + `figureOutSides`)
* so v2 produces the same classified documents as `insertEmailDocuments`, but
* owned by the grant tenant (the auth-coupled providers cannot run in the gateway
* control-plane context).
*
* The hash matches the legacy `hashStringToInt(file.text())` scheme so the dedup
* is consistent across both paths; re-deliveries short-circuit here and never
* re-upload or re-OCR. Metadata-only entries (no inline bytes) yield an empty
* result. OCR failure is non-fatal — the document falls back to UNPROCESSED
* rather than failing the whole ingest.
*/
private prepareDocuments;
/**
* Record a non-insert outcome inside the caller's tenant-pinned write transaction:
* insert the audit row and persist the idempotency key + dedup fingerprint (so a
* re-delivery short-circuits) under a single audit id.
*
* The row always lands in the quarantine table — it is the module's durable record
* of "an email arrived and was not inserted", and the reprocessing workflow reads
* from it. `outcome` is what distinguishes a failure needing triage (QUARANTINED:
* NO_DOCUMENTS, UPLOAD_FAILED) from a deliberate skip (IGNORED: SELF_ISSUED).
*/
private recordNonInsert;
/**
* Insert already-prepared (uploaded + OCR'd) documents under one charge, owned
* by the grant tenant. Runs entirely inside the caller's transaction with no
* network I/O. Returns the created charge id.
*/
private insertPreparedDocuments;
private persistIdempotencyAndDedup;
}