Files
stack/apps/gateway/src/agent/routing/routing.controller.ts
Jarvis 774b76447d
Some checks failed
ci/woodpecker/pr/ci Pipeline failed
ci/woodpecker/push/ci Pipeline failed
fix: rename all packages from @mosaic/* to @mosaicstack/*
- Updated all package.json name fields and dependency references
- Updated all TypeScript/JavaScript imports
- Updated .woodpecker/publish.yml filters and registry paths
- Updated tools/install.sh scope default
- Updated .npmrc registry paths (worktree + host)
- Enhanced update-checker.ts with checkForAllUpdates() multi-package support
- Updated CLI update command to show table of all packages
- Added KNOWN_PACKAGES, formatAllPackagesTable, getInstallAllCommand
- Marked checkForUpdate() with @deprecated JSDoc

Closes #391
2026-04-04 21:43:23 -05:00

235 lines
7.3 KiB
TypeScript

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<number, typeof rows>();
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<string, unknown>[],
action: dto.action as unknown as Record<string, unknown>,
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<typeof routingRules.$inferInsert> = {
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<string, unknown>[];
if (dto.action !== undefined)
updatePayload.action = dto.action as unknown as Record<string, unknown>;
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');
}
}