import type { MySqlPool } from './mysql.js'; import { legacyDecimalToCents } from './legacy-money.js'; type LegacyEntity = 'stores' | 'rooms' | 'orders' | 'devices'; export interface LegacyTableMapping { table: string; idColumn: string; tenantColumn: string; parentColumn?: string; nameColumn?: string; statusColumn?: string; codeColumn?: string; startColumn?: string; endColumn?: string; totalAmountColumn?: string; paidAmountColumn?: string; renewalAmountColumn?: string; groupAmountColumn?: string; refundAmountColumn?: string; } export interface LegacyReadOptions { tenantId: number; parentId?: number; limit?: number; } export interface LegacyRecord { legacyId: string; tenantId: string; parentId: string | null; name: string | null; code: string | null; status: string | null; startAt: Date | string | null; endAt: Date | string | null; totalAmountCents: number | null; paidAmountCents: number | null; renewalAmountCents: number | null; groupAmountCents: number | null; refundAmountCents: number | null; } export const defaultLegacyMappings: Record = { stores: { table: 'member_store_info', idColumn: 'id', tenantColumn: 'tenant_id', nameColumn: 'store_name', statusColumn: 'status' }, rooms: { table: 'member_room_info', idColumn: 'id', tenantColumn: 'tenant_id', parentColumn: 'store_id', nameColumn: 'room_name', codeColumn: 'room_no', statusColumn: 'status' }, orders: { table: 'member_order_info', idColumn: 'id', tenantColumn: 'tenant_id', parentColumn: 'room_id', codeColumn: 'order_no', statusColumn: 'status', startColumn: 'start_time', endColumn: 'end_time', totalAmountColumn: 'price', paidAmountColumn: 'pay_price', renewalAmountColumn: 'renew_price', groupAmountColumn: 'group_pay_price', refundAmountColumn: 'refund_price' }, devices: { table: 'member_device_info', idColumn: 'id', tenantColumn: 'tenant_id', parentColumn: 'room_id', nameColumn: 'device_name', codeColumn: 'device_id', statusColumn: 'status' } }; const identifierPattern = /^[A-Za-z][A-Za-z0-9_]*$/; function quoteIdentifier(identifier: string): string { if (!identifierPattern.test(identifier)) { throw new Error(`Unsafe legacy SQL identifier: ${identifier}`); } return `\`${identifier}\``; } function selectedColumn(column: string | undefined, alias: string): string { return column ? `${quoteIdentifier(column)} AS ${quoteIdentifier(alias)}` : `NULL AS ${quoteIdentifier(alias)}`; } function normalizeLimit(limit = 100): number { if (!Number.isInteger(limit) || limit < 1 || limit > 500) { throw new Error('Legacy read limit must be an integer between 1 and 500.'); } return limit; } interface LegacyQueryRow extends Omit< LegacyRecord, | 'totalAmountCents' | 'paidAmountCents' | 'renewalAmountCents' | 'groupAmountCents' | 'refundAmountCents' > { totalAmountDecimal: unknown; paidAmountDecimal: unknown; renewalAmountDecimal: unknown; groupAmountDecimal: unknown; refundAmountDecimal: unknown; } function normalizeMoney( value: unknown, mapping: LegacyTableMapping, column: string | undefined, nullable: boolean ): number | null { if (!column) { return null; } return legacyDecimalToCents(value, { field: `${mapping.table}.${column}`, nullable }); } function normalizeRecord(row: LegacyQueryRow, mapping: LegacyTableMapping): LegacyRecord { const { totalAmountDecimal, paidAmountDecimal, renewalAmountDecimal, groupAmountDecimal, refundAmountDecimal, ...record } = row; return { ...record, totalAmountCents: normalizeMoney( totalAmountDecimal, mapping, mapping.totalAmountColumn, false ), paidAmountCents: normalizeMoney( paidAmountDecimal, mapping, mapping.paidAmountColumn, true ), renewalAmountCents: normalizeMoney( renewalAmountDecimal, mapping, mapping.renewalAmountColumn, true ), groupAmountCents: normalizeMoney( groupAmountDecimal, mapping, mapping.groupAmountColumn, true ), refundAmountCents: normalizeMoney( refundAmountDecimal, mapping, mapping.refundAmountColumn, true ) }; } export class LegacyReadRepository { constructor( private readonly pool: Pick, private readonly mappings: Record = defaultLegacyMappings ) {} listStores(options: LegacyReadOptions): Promise { return this.list('stores', options); } listRooms(options: LegacyReadOptions): Promise { return this.list('rooms', options); } listOrders(options: LegacyReadOptions): Promise { return this.list('orders', options); } listDevices(options: LegacyReadOptions): Promise { return this.list('devices', options); } private async list(entity: LegacyEntity, options: LegacyReadOptions): Promise { const mapping = this.mappings[entity]; const limit = normalizeLimit(options.limit); const clauses = [`${quoteIdentifier(mapping.tenantColumn)} = ?`]; const parameters: Array = [options.tenantId]; if (mapping.parentColumn && options.parentId !== undefined) { clauses.push(`${quoteIdentifier(mapping.parentColumn)} = ?`); parameters.push(options.parentId); } parameters.push(limit); const sql = [ 'SELECT', `${quoteIdentifier(mapping.idColumn)} AS ${quoteIdentifier('legacyId')},`, `${quoteIdentifier(mapping.tenantColumn)} AS ${quoteIdentifier('tenantId')},`, `${selectedColumn(mapping.parentColumn, 'parentId')},`, `${selectedColumn(mapping.nameColumn, 'name')},`, `${selectedColumn(mapping.codeColumn, 'code')},`, `${selectedColumn(mapping.statusColumn, 'status')},`, `${selectedColumn(mapping.startColumn, 'startAt')},`, `${selectedColumn(mapping.endColumn, 'endAt')},`, `${selectedColumn(mapping.totalAmountColumn, 'totalAmountDecimal')},`, `${selectedColumn(mapping.paidAmountColumn, 'paidAmountDecimal')},`, `${selectedColumn(mapping.renewalAmountColumn, 'renewalAmountDecimal')},`, `${selectedColumn(mapping.groupAmountColumn, 'groupAmountDecimal')},`, selectedColumn(mapping.refundAmountColumn, 'refundAmountDecimal'), `FROM ${quoteIdentifier(mapping.table)}`, `WHERE ${clauses.join(' AND ')}`, `ORDER BY ${quoteIdentifier(mapping.idColumn)} ASC`, 'LIMIT ?' ].join(' '); const [rows] = await this.pool.query(sql, parameters); return (rows as LegacyQueryRow[]).map((row) => normalizeRecord(row, mapping)); } }