import { Body, Controller, Delete, ForbiddenException, Get, HttpCode, HttpStatus, Inject, NotFoundException, Param, Patch, Post, UseGuards, } from '@nestjs/common'; import { routingRules, type Db, and, asc, eq, or, inArray } from '@mosaicstack/db'; import { DB } from '../../database/database.module.js'; import { AuthGuard } from '../../auth/auth.guard.js'; import { CurrentUser } from '../../auth/current-user.decorator.js'; import { CreateRoutingRuleDto, UpdateRoutingRuleDto, ReorderRoutingRulesDto, } from './routing.dto.js'; @Controller('api/routing/rules') @UseGuards(AuthGuard) export class RoutingController { constructor(@Inject(DB) private readonly db: Db) {} /** * GET /api/routing/rules * List all rules visible to the authenticated user: * - All system rules * - User's own rules * Ordered by priority ascending (lower number = higher priority). */ @Get() async list(@CurrentUser() user: { id: string }) { const rows = await this.db .select() .from(routingRules) .where( or( eq(routingRules.scope, 'system'), and(eq(routingRules.scope, 'user'), eq(routingRules.userId, user.id)), ), ) .orderBy(asc(routingRules.priority)); return rows; } /** * GET /api/routing/rules/effective * Return the merged rule set in priority order. * User-scoped rules are checked before system rules at the same priority * (achieved by ordering: priority ASC, then scope='user' first). */ @Get('effective') async effective(@CurrentUser() user: { id: string }) { const rows = await this.db .select() .from(routingRules) .where( and( eq(routingRules.enabled, true), or( eq(routingRules.scope, 'system'), and(eq(routingRules.scope, 'user'), eq(routingRules.userId, user.id)), ), ), ) .orderBy(asc(routingRules.priority)); // For rules with the same priority: user rules beat system rules. // Group by priority then stable-sort each group: user before system. const grouped = new Map(); for (const row of rows) { const bucket = grouped.get(row.priority) ?? []; bucket.push(row); grouped.set(row.priority, bucket); } const effective: typeof rows = []; for (const [, bucket] of [...grouped.entries()].sort(([a], [b]) => a - b)) { // user-scoped rules first within the same priority bucket const userRules = bucket.filter((r) => r.scope === 'user'); const systemRules = bucket.filter((r) => r.scope === 'system'); effective.push(...userRules, ...systemRules); } return effective; } /** * POST /api/routing/rules * Create a new routing rule. Scope is forced to 'user' (users cannot create * system rules). The authenticated user's ID is attached automatically. */ @Post() async create(@Body() dto: CreateRoutingRuleDto, @CurrentUser() user: { id: string }) { const [created] = await this.db .insert(routingRules) .values({ name: dto.name, priority: dto.priority, scope: 'user', userId: user.id, conditions: dto.conditions as unknown as Record[], action: dto.action as unknown as Record, enabled: dto.enabled ?? true, }) .returning(); return created; } /** * PATCH /api/routing/rules/reorder * Reassign priorities so that the order of `ruleIds` reflects ascending * priority (index 0 = priority 0, index 1 = priority 1, …). * Only the authenticated user's own rules can be reordered. */ @Patch('reorder') async reorder(@Body() dto: ReorderRoutingRulesDto, @CurrentUser() user: { id: string }) { // Verify all supplied IDs belong to this user const owned = await this.db .select({ id: routingRules.id }) .from(routingRules) .where( and( inArray(routingRules.id, dto.ruleIds), eq(routingRules.scope, 'user'), eq(routingRules.userId, user.id), ), ); const ownedIds = new Set(owned.map((r) => r.id)); const unowned = dto.ruleIds.filter((id) => !ownedIds.has(id)); if (unowned.length > 0) { throw new ForbiddenException( `Cannot reorder rules that do not belong to you: ${unowned.join(', ')}`, ); } // Apply new priorities in transaction const updates = await this.db.transaction(async (tx) => { const results = []; for (let i = 0; i < dto.ruleIds.length; i++) { const [updated] = await tx .update(routingRules) .set({ priority: i, updatedAt: new Date() }) .where(and(eq(routingRules.id, dto.ruleIds[i]!), eq(routingRules.userId, user.id))) .returning(); if (updated) results.push(updated); } return results; }); return updates; } /** * PATCH /api/routing/rules/:id * Update a user-owned rule. System rules cannot be modified by regular users. */ @Patch(':id') async update( @Param('id') id: string, @Body() dto: UpdateRoutingRuleDto, @CurrentUser() user: { id: string }, ) { const [existing] = await this.db.select().from(routingRules).where(eq(routingRules.id, id)); if (!existing) throw new NotFoundException('Routing rule not found'); if (existing.scope === 'system') { throw new ForbiddenException('System routing rules cannot be modified'); } if (existing.userId !== user.id) { throw new ForbiddenException('Routing rule does not belong to the current user'); } const updatePayload: Partial = { updatedAt: new Date(), }; if (dto.name !== undefined) updatePayload.name = dto.name; if (dto.priority !== undefined) updatePayload.priority = dto.priority; if (dto.conditions !== undefined) updatePayload.conditions = dto.conditions as unknown as Record[]; if (dto.action !== undefined) updatePayload.action = dto.action as unknown as Record; if (dto.enabled !== undefined) updatePayload.enabled = dto.enabled; const [updated] = await this.db .update(routingRules) .set(updatePayload) .where(and(eq(routingRules.id, id), eq(routingRules.userId, user.id))) .returning(); if (!updated) throw new NotFoundException('Routing rule not found'); return updated; } /** * DELETE /api/routing/rules/:id * Delete a user-owned routing rule. System rules cannot be deleted. */ @Delete(':id') @HttpCode(HttpStatus.NO_CONTENT) async remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { const [existing] = await this.db.select().from(routingRules).where(eq(routingRules.id, id)); if (!existing) throw new NotFoundException('Routing rule not found'); if (existing.scope === 'system') { throw new ForbiddenException('System routing rules cannot be deleted'); } if (existing.userId !== user.id) { throw new ForbiddenException('Routing rule does not belong to the current user'); } const [deleted] = await this.db .delete(routingRules) .where(and(eq(routingRules.id, id), eq(routingRules.userId, user.id))) .returning(); if (!deleted) throw new NotFoundException('Routing rule not found'); } }