feat(M4-009,M4-010,M4-011): routing rules CRUD, per-user overrides, agent capabilities (#320)
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Some checks failed
ci/woodpecker/push/ci Pipeline failed
Co-authored-by: Jason Woltje <jason@diversecanvas.com> Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #320.
This commit is contained in:
234
apps/gateway/src/agent/routing/routing.controller.ts
Normal file
234
apps/gateway/src/agent/routing/routing.controller.ts
Normal file
@@ -0,0 +1,234 @@
|
||||
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 '@mosaic/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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user