import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource, EntityTarget, FindOptionsWhere, ObjectLiteral } from 'typeorm'; export type OrderField = 'displayOrder' | 'stepOrder'; @Injectable() export class DisplayOrderService { constructor(private readonly dataSource: DataSource) {} async getMaxOrder( entity: EntityTarget, field: OrderField, where?: FindOptionsWhere, ): Promise { const repo = this.dataSource.getRepository(entity); const qb = repo.createQueryBuilder('e').select(`MAX(e.${field})`, 'max'); if (where) { Object.entries(where).forEach(([key, value]) => { if (value !== undefined) { qb.andWhere(`e.${key} = :${key}`, { [key]: value }); } }); } const row = await qb.getRawOne<{ max: string | null }>(); return row?.max ? Number(row.max) : 0; } async resolveCreateOrder( entity: EntityTarget, field: OrderField, options: { explicitOrder?: number; insertAfterId?: string; scopeWhere?: FindOptionsWhere; }, ): Promise { const { explicitOrder, insertAfterId, scopeWhere } = options; if (insertAfterId) { if (explicitOrder !== undefined) { throw new BadRequestException('Cannot set both explicit order and insertAfterId'); } const repo = this.dataSource.getRepository(entity); const after = await repo.findOne({ where: { id: insertAfterId, ...scopeWhere } as unknown as FindOptionsWhere, }); if (!after) { throw new NotFoundException(`Record ${insertAfterId} not found in scope`); } const afterOrder = Number((after as Record)[field]); await this.shiftOrdersFrom(entity, field, afterOrder + 1, 1, scopeWhere); return afterOrder + 1; } if (explicitOrder !== undefined) { return explicitOrder; } const max = await this.getMaxOrder(entity, field, scopeWhere); return max + 1; } async reorderByIds( entity: EntityTarget, field: OrderField, ids: string[], scopeWhere?: FindOptionsWhere, ): Promise { const repo = this.dataSource.getRepository(entity); const existing = await repo.find({ where: scopeWhere, order: { [field]: 'ASC' } as never, }); const scopedIds = new Set(existing.map((row) => String(row.id))); if (ids.length !== scopedIds.size) { throw new BadRequestException('Reorder list must include every item in scope exactly once'); } for (const id of ids) { if (!scopedIds.has(id)) { throw new BadRequestException(`ID ${id} is not in the reorder scope`); } } const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { for (let i = 0; i < ids.length; i++) { await queryRunner.manager.update(entity, ids[i], { [field]: -(i + 1) } as never); } for (let i = 0; i < ids.length; i++) { await queryRunner.manager.update(entity, ids[i], { [field]: i + 1 } as never); } await queryRunner.commitTransaction(); } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } async moveOne( entity: EntityTarget, field: OrderField, id: string, direction: 'up' | 'down', scopeWhere?: FindOptionsWhere, ): Promise { const repo = this.dataSource.getRepository(entity); const items = await repo.find({ where: scopeWhere, order: { [field]: 'ASC' } as never, }); const index = items.findIndex((row) => String(row.id) === id); if (index === -1) { throw new NotFoundException(`Record ${id} not found in scope`); } const targetIndex = direction === 'up' ? index - 1 : index + 1; if (targetIndex < 0 || targetIndex >= items.length) { throw new BadRequestException(`Cannot move ${direction}`); } const current = items[index] as Record; const neighbor = items[targetIndex] as Record; const currentOrder = Number(current[field]); const neighborOrder = Number(neighbor[field]); const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { await queryRunner.manager.update(entity, String(current.id), { [field]: -1 } as never); await queryRunner.manager.update(entity, String(neighbor.id), { [field]: -2 } as never); await queryRunner.manager.update(entity, String(current.id), { [field]: neighborOrder } as never); await queryRunner.manager.update(entity, String(neighbor.id), { [field]: currentOrder } as never); await queryRunner.commitTransaction(); } catch (err) { await queryRunner.rollbackTransaction(); throw err; } finally { await queryRunner.release(); } } private async shiftOrdersFrom( entity: EntityTarget, field: OrderField, fromOrder: number, delta: number, scopeWhere?: FindOptionsWhere, ): Promise { const repo = this.dataSource.getRepository(entity); const orderColumn = repo.metadata.findColumnWithPropertyName(field)?.databaseName ?? field; const qb = repo .createQueryBuilder() .update() .set({ [field]: () => `"${orderColumn}" + ${delta}` } as never) .where(`"${orderColumn}" >= :fromOrder`, { fromOrder }); if (scopeWhere) { Object.entries(scopeWhere).forEach(([key, value]) => { if (value !== undefined) { const col = repo.metadata.findColumnWithPropertyName(key)?.databaseName ?? key; qb.andWhere(`"${col}" = :scope_${key}`, { [`scope_${key}`]: value }); } }); } await qb.execute(); } }