186 lines
7.3 KiB
JavaScript
186 lines
7.3 KiB
JavaScript
'use strict';
|
|
|
|
const express = require('express');
|
|
const { standardizeError } = require('../middleware/errorFormatter');
|
|
const router = express.Router();
|
|
const {
|
|
previewSpreadsheet,
|
|
applyImportDecisions,
|
|
getImportHistory,
|
|
} = require('../services/spreadsheetImportService');
|
|
const {
|
|
previewUserDbImport,
|
|
applyUserDbImport,
|
|
} = require('../services/userDbImportService');
|
|
|
|
function makeErrorId() {
|
|
return `imp_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
}
|
|
|
|
function sendImportError(res, err, fallback, defaultCode) {
|
|
if (err.status) {
|
|
return res.status(err.status).json({
|
|
error: fallback,
|
|
message: err.message,
|
|
code: err.code || defaultCode,
|
|
details: Array.isArray(err.details) ? err.details : [],
|
|
});
|
|
}
|
|
|
|
// Log error ID server-side only — never expose to clients
|
|
const errorId = makeErrorId();
|
|
console.error(`[import] ${fallback} (${errorId}):`, err.stack || err.message);
|
|
return res.status(500).json({
|
|
error: fallback,
|
|
message: 'Unexpected import server error. Please try again or adjust the import decisions.',
|
|
code: defaultCode,
|
|
});
|
|
}
|
|
|
|
// ─── POST /api/import/spreadsheet/preview ─────────────────────────────────────
|
|
// Accepts an XLSX file as raw binary body.
|
|
// Send with Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
|
|
// or application/octet-stream. Optionally pass X-Filename header for audit logging.
|
|
// Returns a preview with proposed row mappings and bill matches; writes no data.
|
|
|
|
router.post(
|
|
'/spreadsheet/preview',
|
|
express.raw({
|
|
type: [
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'application/octet-stream',
|
|
'application/xlsx',
|
|
'application/vnd.ms-excel',
|
|
],
|
|
limit: '10mb',
|
|
}),
|
|
async (req, res) => {
|
|
try {
|
|
if (!Buffer.isBuffer(req.body) || req.body.length === 0) {
|
|
return res.status(400).json({
|
|
error: 'XLSX file is required. Send as raw binary with Content-Type application/octet-stream or the XLSX MIME type.',
|
|
});
|
|
}
|
|
|
|
const rawFilename = req.headers['x-filename'];
|
|
const originalFilename = rawFilename
|
|
? rawFilename.replace(/[^a-zA-Z0-9._\-\s]/g, '').trim().slice(0, 255)
|
|
: null;
|
|
|
|
const options = {
|
|
sheet_name: req.query.sheet || null,
|
|
parse_all_sheets: req.query.parse_all_sheets === 'true',
|
|
default_year: req.query.year ? parseInt(req.query.year, 10) : null,
|
|
default_month: req.query.month ? parseInt(req.query.month, 10) : null,
|
|
original_filename: originalFilename,
|
|
};
|
|
|
|
if (options.default_year && (options.default_year < 2000 || options.default_year > 2100)) {
|
|
return res.status(400).json(standardizeError('year must be a 4-digit integer between 2000 and 2100', 'VALIDATION_ERROR', 'year'));
|
|
}
|
|
if (options.default_month && (options.default_month < 1 || options.default_month > 12)) {
|
|
return res.status(400).json(standardizeError('month must be an integer between 1 and 12', 'VALIDATION_ERROR', 'month'));
|
|
}
|
|
|
|
const result = await previewSpreadsheet(req.user.id, req.body, options);
|
|
res.json(result);
|
|
} catch (err) {
|
|
return sendImportError(res, err, 'Import preview failed', 'IMPORT_PREVIEW_ERROR');
|
|
}
|
|
},
|
|
);
|
|
|
|
// ─── POST /api/import/spreadsheet/apply ──────────────────────────────────────
|
|
// Applies confirmed import decisions from a previous preview session.
|
|
// Body: { import_session_id, decisions: [...], options: { overwrite: false } }
|
|
// Each decision must have: row_id, action, and action-specific fields.
|
|
// Only writes data for explicitly confirmed decisions; skips ambiguous rows.
|
|
|
|
router.post('/spreadsheet/apply', express.json({ limit: '2mb' }), async (req, res) => {
|
|
try {
|
|
const { import_session_id, decisions, options } = req.body || {};
|
|
|
|
if (!import_session_id || typeof import_session_id !== 'string') {
|
|
return res.status(400).json(standardizeError('import_session_id is required', 'VALIDATION_ERROR', 'import_session_id'));
|
|
}
|
|
if (!Array.isArray(decisions) || decisions.length === 0) {
|
|
return res.status(400).json(standardizeError('decisions array is required and must not be empty', 'VALIDATION_ERROR', 'decisions'));
|
|
}
|
|
if (decisions.length > 5000) {
|
|
return res.status(400).json(standardizeError('Too many decisions in a single apply request (max 5000)', 'VALIDATION_ERROR', 'decisions'));
|
|
}
|
|
|
|
const result = await applyImportDecisions(
|
|
req.user.id,
|
|
import_session_id,
|
|
decisions,
|
|
options || {},
|
|
);
|
|
res.json(result);
|
|
} catch (err) {
|
|
return sendImportError(res, err, 'Import apply failed', 'IMPORT_APPLY_ERROR');
|
|
}
|
|
});
|
|
|
|
// ─── POST /api/import/user-db/preview ────────────────────────────────────────
|
|
// Accepts a SQLite user data export created by this app. This is not an admin
|
|
// full-database restore and writes no live bill/payment/category data.
|
|
|
|
router.post(
|
|
'/user-db/preview',
|
|
express.raw({
|
|
type: [
|
|
'application/octet-stream',
|
|
'application/x-sqlite3',
|
|
'application/vnd.sqlite3',
|
|
'application/x-sqlite',
|
|
'application/vnd.sqlite',
|
|
],
|
|
limit: '50mb',
|
|
}),
|
|
async (req, res) => {
|
|
try {
|
|
const rawFilename = req.headers['x-filename'];
|
|
const originalFilename = rawFilename
|
|
? rawFilename.replace(/[^a-zA-Z0-9._\-\s]/g, '').trim().slice(0, 255)
|
|
: null;
|
|
const result = await previewUserDbImport(req.user.id, req.body, { original_filename: originalFilename });
|
|
res.json(result);
|
|
} catch (err) {
|
|
return sendImportError(res, err, 'User SQLite import preview failed', 'USER_DB_IMPORT_PREVIEW_ERROR');
|
|
}
|
|
},
|
|
);
|
|
|
|
// ─── POST /api/import/user-db/apply ──────────────────────────────────────────
|
|
// Applies a previously previewed user SQLite export session. User ownership is
|
|
// derived from req.user only; existing data is skipped by default.
|
|
|
|
router.post('/user-db/apply', express.json({ limit: '1mb' }), async (req, res) => {
|
|
try {
|
|
const { import_session_id, options } = req.body || {};
|
|
if (!import_session_id || typeof import_session_id !== 'string') {
|
|
return res.status(400).json(standardizeError('import_session_id is required', 'VALIDATION_ERROR', 'import_session_id'));
|
|
}
|
|
const result = await applyUserDbImport(req.user.id, import_session_id, options || {});
|
|
res.json(result);
|
|
} catch (err) {
|
|
return sendImportError(res, err, 'User SQLite import apply failed', 'USER_DB_IMPORT_APPLY_ERROR');
|
|
}
|
|
});
|
|
|
|
// ─── GET /api/import/history ──────────────────────────────────────────────────
|
|
// Returns the authenticated user's import history (last 100 imports).
|
|
|
|
router.get('/history', (req, res) => {
|
|
try {
|
|
const history = getImportHistory(req.user.id);
|
|
res.json({ history });
|
|
} catch (err) {
|
|
console.error('[import] history error:', err.message);
|
|
res.status(500).json({ error: 'Failed to load import history' });
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|