This commit is contained in:
@@ -0,0 +1,125 @@
|
||||
import { IMPORT_ENTITIES, IMPORT_ENTITY_DEFINITIONS } from '@pig/core';
|
||||
import type { Database } from '@pig/db';
|
||||
import { Hono } from 'hono';
|
||||
import { z } from 'zod';
|
||||
import { requireCapability } from '../lib/auth';
|
||||
import type { ApiEnv, MutationDefinition } from '../lib/mutation';
|
||||
import { MutationError, mutation } from '../lib/mutation';
|
||||
import {
|
||||
ImportService,
|
||||
type ImportCommitResult,
|
||||
type ImportTransaction,
|
||||
} from '../services/imports';
|
||||
import {
|
||||
MAX_IMPORT_CELL_CHARS,
|
||||
MAX_IMPORT_COLUMNS,
|
||||
MAX_IMPORT_FILE_BYTES,
|
||||
MAX_IMPORT_ROWS,
|
||||
parseTabularFile,
|
||||
} from '../services/tabular-import';
|
||||
|
||||
const planSchema = z.object({
|
||||
entity: z.enum(IMPORT_ENTITIES),
|
||||
sourceName: z.string().trim().min(1).max(255),
|
||||
headers: z.array(z.string().min(1).max(255)).min(1).max(MAX_IMPORT_COLUMNS),
|
||||
rows: z.array(
|
||||
z.array(z.string().max(MAX_IMPORT_CELL_CHARS)).max(MAX_IMPORT_COLUMNS),
|
||||
).min(1).max(MAX_IMPORT_ROWS),
|
||||
mapping: z.record(z.string().min(1).max(255)),
|
||||
keySourceColumn: z.string().min(1).max(255),
|
||||
}).strict();
|
||||
const commitSchema = planSchema.extend({
|
||||
previewDigest: z.string().regex(/^[0-9a-f]{64}$/),
|
||||
}).strict();
|
||||
const parseSchema = z.object({
|
||||
fileName: z.string().trim().min(1).max(255),
|
||||
mimeType: z.string().max(255).optional(),
|
||||
base64: z.string().min(1).max(Math.ceil(MAX_IMPORT_FILE_BYTES * 4 / 3) + 16),
|
||||
}).strict();
|
||||
|
||||
interface ImportCommitOperations {
|
||||
commit(
|
||||
input: z.infer<typeof commitSchema>,
|
||||
principal: Parameters<MutationDefinition<typeof commitSchema, ImportCommitResult>['mutate']>[0]['principal'],
|
||||
now: Date,
|
||||
): Promise<ImportCommitResult>;
|
||||
}
|
||||
type ServiceFactory = (tx: ImportTransaction) => ImportCommitOperations;
|
||||
const service: ServiceFactory = (tx) => new ImportService(tx);
|
||||
|
||||
export function createImportCommitMutationDefinition(
|
||||
makeService: ServiceFactory = service,
|
||||
): MutationDefinition<typeof commitSchema, ImportCommitResult> {
|
||||
return {
|
||||
schema: commitSchema,
|
||||
permission: { authorize: (principal) => requireCapability(principal, 'data:import') },
|
||||
invalidMessage: 'Invalid import commit.',
|
||||
async mutate({ input, principal, tx, now }) {
|
||||
const result = await makeService(tx).commit(input, principal, now);
|
||||
const entityLabel = IMPORT_ENTITY_DEFINITIONS[input.entity].label.toLocaleLowerCase();
|
||||
return {
|
||||
data: result,
|
||||
activity: {
|
||||
type: 'note',
|
||||
subject: `Imported ${result.total} ${entityLabel}`,
|
||||
meta: {
|
||||
action: 'import',
|
||||
entity: input.entity,
|
||||
sourceName: input.sourceName,
|
||||
keySourceColumn: input.keySourceColumn,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createImportRoutes(db: Database): Hono<ApiEnv> {
|
||||
const routes = new Hono<ApiEnv>();
|
||||
routes.use('/api/imports/*', async (context, next) => {
|
||||
requireCapability(context.get('principal'), 'data:import');
|
||||
await next();
|
||||
});
|
||||
routes.get('/api/imports/config', (context) => context.json({
|
||||
entities: IMPORT_ENTITIES,
|
||||
definitions: IMPORT_ENTITY_DEFINITIONS,
|
||||
limits: {
|
||||
fileBytes: MAX_IMPORT_FILE_BYTES,
|
||||
rows: MAX_IMPORT_ROWS,
|
||||
columns: MAX_IMPORT_COLUMNS,
|
||||
cellCharacters: MAX_IMPORT_CELL_CHARS,
|
||||
},
|
||||
}));
|
||||
routes.post('/api/imports/parse', async (context) => {
|
||||
const parsed = parseSchema.safeParse(await context.req.json());
|
||||
if (!parsed.success) throw new MutationError('invalid_import_file', 'Invalid import file upload.', 400, parsed.error.issues);
|
||||
let bytes: Uint8Array;
|
||||
try {
|
||||
bytes = Buffer.from(parsed.data.base64, 'base64');
|
||||
} catch {
|
||||
throw new MutationError('invalid_import_file', 'The import file encoding is invalid.', 400);
|
||||
}
|
||||
try {
|
||||
return context.json(parseTabularFile({
|
||||
fileName: parsed.data.fileName,
|
||||
mimeType: parsed.data.mimeType,
|
||||
bytes,
|
||||
}));
|
||||
} catch (error) {
|
||||
throw new MutationError(
|
||||
'invalid_import_file',
|
||||
error instanceof Error ? error.message : 'The import file could not be parsed.',
|
||||
400,
|
||||
);
|
||||
}
|
||||
});
|
||||
routes.post('/api/imports/preview', async (context) => {
|
||||
const parsed = planSchema.safeParse(await context.req.json());
|
||||
if (!parsed.success) throw new MutationError('invalid_import', 'Invalid import plan.', 400, parsed.error.issues);
|
||||
return context.json(await new ImportService(db).preview(parsed.data));
|
||||
});
|
||||
routes.post('/api/imports/commit', mutation(db, createImportCommitMutationDefinition()));
|
||||
return routes;
|
||||
}
|
||||
Reference in New Issue
Block a user