@accounter/server
Version:
Accounter GraphQL server
83 lines (82 loc) • 3.65 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';
export type IngestInput = {
grantJti: string;
idempotencyKey: string;
tenantId: string;
messageId: string;
rawMessageHash: string;
correlationId?: 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;
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;
/**
* 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;
}