feat: route-centric rules, approve popup, emoji picker, 5-column sync table

- Schema: SyncRouteRuleMap junction table, ruleIntent on TenantRule,
  ForwardFormat/Frequency/ApprovalMode columns on SyncRoute, INTEREST
  match type
- Worker: remove global AUTO_APPROVE forwarding; per-route rule evaluation
  in ingest processor — POSITIVE rules auto-forward that route, NEGATIVE
  rules block it, fallback to approvalMode
- API: PATCH /routes/:id, rule assign/unassign endpoints, approve now
  accepts targetGroupIds, new GET /messages/:id/routes for popup data
- Web: 5-column route table with settings + rules dialogs, ForwardTarget
  popup with rule-based pre-selection, ruleIntent toggle + emoji dropdown
  (👍/👎 only) + INTEREST type in rules manager

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 13:58:02 +05:30
parent e598073dc7
commit 73956fd1e3
24 changed files with 1277 additions and 225 deletions
@@ -0,0 +1,45 @@
-- CreateEnum
CREATE TYPE "RuleIntent" AS ENUM ('POSITIVE', 'NEGATIVE');
CREATE TYPE "ForwardFormat" AS ENUM ('THREADED', 'DIGEST', 'SUMMARY');
CREATE TYPE "ForwardFrequency" AS ENUM ('INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS');
CREATE TYPE "ApprovalMode" AS ENUM ('MANUAL', 'AUTO');
-- AlterEnum: Add INTEREST to RuleMatchType
ALTER TYPE "RuleMatchType" ADD VALUE IF NOT EXISTS 'INTEREST';
-- AlterTable: TenantRule - add ruleIntent
ALTER TABLE "TenantRule" ADD COLUMN IF NOT EXISTS "ruleIntent" "RuleIntent" NOT NULL DEFAULT 'POSITIVE';
-- AlterTable: SyncRoute - add new columns (updatedAt with default NOW() for existing rows)
ALTER TABLE "SyncRoute"
ADD COLUMN IF NOT EXISTS "forwardFormat" "ForwardFormat" NOT NULL DEFAULT 'THREADED',
ADD COLUMN IF NOT EXISTS "withAttachment" BOOLEAN NOT NULL DEFAULT true,
ADD COLUMN IF NOT EXISTS "frequency" "ForwardFrequency" NOT NULL DEFAULT 'INSTANT',
ADD COLUMN IF NOT EXISTS "approvalMode" "ApprovalMode" NOT NULL DEFAULT 'MANUAL',
ADD COLUMN IF NOT EXISTS "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT NOW();
-- CreateTable: SyncRouteRuleMap
CREATE TABLE IF NOT EXISTS "SyncRouteRuleMap" (
"id" TEXT NOT NULL,
"syncRouteId" TEXT NOT NULL,
"tenantRuleId" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SyncRouteRuleMap_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_tenantRuleId_key"
ON "SyncRouteRuleMap"("syncRouteId", "tenantRuleId");
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_syncRouteId_idx"
ON "SyncRouteRuleMap"("syncRouteId");
CREATE INDEX IF NOT EXISTS "SyncRouteRuleMap_tenantRuleId_idx"
ON "SyncRouteRuleMap"("tenantRuleId");
-- AddForeignKey
ALTER TABLE "SyncRouteRuleMap"
ADD CONSTRAINT "SyncRouteRuleMap_syncRouteId_fkey"
FOREIGN KEY ("syncRouteId") REFERENCES "SyncRoute"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SyncRouteRuleMap"
ADD CONSTRAINT "SyncRouteRuleMap_tenantRuleId_fkey"
FOREIGN KEY ("tenantRuleId") REFERENCES "TenantRule"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+68 -19
View File
@@ -337,20 +337,40 @@ enum ApprovalDecision {
} }
model SyncRoute { model SyncRoute {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
sourceGroupId String sourceGroupId String
sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id]) sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id])
targetGroupId String targetGroupId String
targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id]) targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id])
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) forwardFormat ForwardFormat @default(THREADED)
withAttachment Boolean @default(true)
frequency ForwardFrequency @default(INSTANT)
approvalMode ApprovalMode @default(MANUAL)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
assignedRules SyncRouteRuleMap[]
@@unique([sourceGroupId, targetGroupId]) @@unique([sourceGroupId, targetGroupId])
@@index([tenantId]) @@index([tenantId])
} }
model SyncRouteRuleMap {
id String @id @default(cuid())
syncRouteId String
syncRoute SyncRoute @relation(fields: [syncRouteId], references: [id], onDelete: Cascade)
tenantRuleId String
tenantRule TenantRule @relation(fields: [tenantRuleId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([syncRouteId, tenantRuleId])
@@index([syncRouteId])
@@index([tenantRuleId])
}
// ============================================================================ // ============================================================================
// Group claiming + sharing // Group claiming + sharing
// ============================================================================ // ============================================================================
@@ -515,6 +535,7 @@ enum RuleMatchType {
HASHTAG HASHTAG
PREFIX PREFIX
REACTION_EMOJI REACTION_EMOJI
INTEREST
} }
enum RuleAction { enum RuleAction {
@@ -524,17 +545,45 @@ enum RuleAction {
REJECT REJECT
} }
enum RuleIntent {
POSITIVE
NEGATIVE
}
enum ForwardFormat {
THREADED
DIGEST
SUMMARY
}
enum ForwardFrequency {
INSTANT
FIVE_MIN
FIFTEEN_MIN
ONE_HOUR
ONE_DAY
SEVEN_DAYS
}
enum ApprovalMode {
MANUAL
AUTO
}
model TenantRule { model TenantRule {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
matchType RuleMatchType matchType RuleMatchType
matchValue String matchValue String
action RuleAction action RuleAction
priority Int @default(0) ruleIntent RuleIntent @default(POSITIVE)
isActive Boolean @default(true) priority Int @default(0)
createdAt DateTime @default(now()) isActive Boolean @default(true)
updatedAt DateTime @updatedAt createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
syncRouteRuleMaps SyncRouteRuleMap[]
@@unique([tenantId, matchType, matchValue]) @@unique([tenantId, matchType, matchValue])
@@index([tenantId, isActive]) @@index([tenantId, isActive])
@@ -1,4 +1,5 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { IsArray, IsOptional, IsString } from 'class-validator';
import { MessagesService } from './messages.service'; import { MessagesService } from './messages.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { RolesGuard } from '../auth/roles.guard'; import { RolesGuard } from '../auth/roles.guard';
@@ -6,6 +7,10 @@ import { Roles } from '../auth/roles.decorator';
import { CurrentTenantContext } from '../auth/current-tenant.decorator'; import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context'; import { TenantContext } from '../../common/tenant-context';
class ApproveMessageDto {
@IsArray() @IsString({ each: true }) @IsOptional() targetGroupIds?: string[];
}
@Controller('admin/messages') @Controller('admin/messages')
@UseGuards(JwtAuthGuard, RolesGuard) @UseGuards(JwtAuthGuard, RolesGuard)
@Roles('OWNER', 'ADMIN') @Roles('OWNER', 'ADMIN')
@@ -22,6 +27,11 @@ export class MessagesController {
return this.messagesService.pendingCount(ctx.tenantId); return this.messagesService.pendingCount(ctx.tenantId);
} }
@Get(':id/routes')
getRoutes(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.messagesService.getRoutesForMessage(ctx.tenantId, id);
}
@Get(':id') @Get(':id')
get( get(
@CurrentTenantContext() ctx: TenantContext, @CurrentTenantContext() ctx: TenantContext,
@@ -34,8 +44,9 @@ export class MessagesController {
approve( approve(
@CurrentTenantContext() ctx: TenantContext, @CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string, @Param('id') id: string,
@Body() body: ApproveMessageDto,
) { ) {
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id); return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id, body.targetGroupIds);
} }
@Post('reindex') @Post('reindex')
@@ -1,7 +1,7 @@
import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common'; import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Queue } from 'bullmq'; import { Queue } from 'bullmq';
import { ForwardJobData, IndexJobData } from '@tower/types'; import { ForwardJobData, IndexJobData, RouteForMessage } from '@tower/types';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { AuditService } from '../audit/audit.service'; import { AuditService } from '../audit/audit.service';
import { AuditAction } from '../audit/audit.types'; import { AuditAction } from '../audit/audit.types';
@@ -112,7 +112,76 @@ export class MessagesService {
})); }));
} }
async approve(tenantId: string, adminId: string, messageId: string): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> { async getRoutesForMessage(tenantId: string, messageId: string): Promise<RouteForMessage[]> {
const message = await this.prisma.message.findUnique({
where: { id: messageId },
select: { id: true, tenantId: true, sourceGroupId: true, content: true },
});
if (!message) throw new NotFoundException('Message not found');
if (message.tenantId !== tenantId) throw new NotFoundException('Message not found');
const syncRoutes = await this.prisma.syncRoute.findMany({
where: { sourceGroupId: message.sourceGroupId, isActive: true },
include: {
targetGroup: { select: { id: true, name: true, isActive: true } },
assignedRules: {
include: {
tenantRule: {
select: { matchType: true, matchValue: true, ruleIntent: true, isActive: true },
},
},
},
},
});
return syncRoutes
.filter((r: any) => r.targetGroup?.isActive)
.map((route: any) => {
const activeRules = route.assignedRules
.filter((m: any) => m.tenantRule.isActive)
.map((m: any) => m.tenantRule);
let ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE' = 'NONE';
if (activeRules.length > 0) {
const negativeRules = activeRules.filter((r: any) => r.ruleIntent === 'NEGATIVE');
const positiveRules = activeRules.filter((r: any) => r.ruleIntent === 'POSITIVE');
if (negativeRules.length > 0 && this.matchesAny(message.content, negativeRules)) {
ruleDecision = 'BLOCK';
} else if (positiveRules.length > 0 && this.matchesAny(message.content, positiveRules)) {
ruleDecision = 'FORWARD';
}
}
return {
routeId: route.id,
targetGroupId: route.targetGroup.id,
targetGroupName: route.targetGroup.name,
approvalMode: route.approvalMode,
forwardFormat: route.forwardFormat,
withAttachment: route.withAttachment,
frequency: route.frequency,
ruleDecision,
} as RouteForMessage;
});
}
private matchesAny(content: string, rules: Array<{ matchType: string; matchValue: string }>): boolean {
for (const rule of rules) {
if (rule.matchType === 'HASHTAG') {
const escaped = rule.matchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (new RegExp(`(?:^|\\W)${escaped}(?:$|\\W)`, 'i').test(content)) return true;
} else if (rule.matchType === 'PREFIX') {
if (content.trimStart().startsWith(rule.matchValue)) return true;
} else if (rule.matchType === 'REACTION_EMOJI') {
if (content.includes(rule.matchValue)) return true;
}
}
return false;
}
async approve(tenantId: string, adminId: string, messageId: string, targetGroupIds?: string[]): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
const message = await this.prisma.message.findUnique({ const message = await this.prisma.message.findUnique({
where: { id: messageId }, where: { id: messageId },
include: { include: {
@@ -160,10 +229,14 @@ export class MessagesService {
throw new ConflictException('Message could not be approved (concurrent update)'); throw new ConflictException('Message could not be approved (concurrent update)');
} }
const validRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter( const allRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
(r: any) => r.targetGroup != null, (r: any) => r.targetGroup != null,
); );
const forwardJobs: ForwardJobData[] = validRoutes.map((route: any) => ({ const selectedRoutes = targetGroupIds
? allRoutes.filter((r: any) => targetGroupIds.includes(r.targetGroupId))
: allRoutes;
const forwardJobs: ForwardJobData[] = selectedRoutes.map((route: any) => ({
tenantId: message.tenantId, tenantId: message.tenantId,
messageId: message.id, messageId: message.id,
content: message.content, content: message.content,
@@ -204,6 +277,7 @@ export class MessagesService {
resourceId: message.id, resourceId: message.id,
payload: { payload: {
routesForwarded: forwardJobs.length, routesForwarded: forwardJobs.length,
targetGroupIds: targetGroupIds ?? null,
contentPreview: message.content.slice(0, 80), contentPreview: message.content.slice(0, 80),
}, },
}); });
@@ -1,8 +1,8 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, Param, Patch, Post, Query } from '@nestjs/common';
import { IsBoolean, IsEnum, IsOptional, IsString } from 'class-validator';
import { RoutesService } from './routes.service'; import { RoutesService } from './routes.service';
import { CurrentTenantContext } from '../auth/current-tenant.decorator'; import { CurrentTenantContext } from '../auth/current-tenant.decorator';
import { TenantContext } from '../../common/tenant-context'; import { TenantContext } from '../../common/tenant-context';
import { IsString } from 'class-validator';
class CreateRouteDto { class CreateRouteDto {
@IsString() sourceGroupId!: string; @IsString() sourceGroupId!: string;
@@ -14,6 +14,14 @@ class BatchCreateRouteDto {
@IsString({ each: true }) targetGroupIds!: string[]; @IsString({ each: true }) targetGroupIds!: string[];
} }
class UpdateRouteDto {
@IsEnum(['THREADED', 'DIGEST', 'SUMMARY']) @IsOptional() forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
@IsBoolean() @IsOptional() withAttachment?: boolean;
@IsEnum(['INSTANT', 'FIVE_MIN', 'FIFTEEN_MIN', 'ONE_HOUR', 'ONE_DAY', 'SEVEN_DAYS']) @IsOptional() frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
@IsEnum(['MANUAL', 'AUTO']) @IsOptional() approvalMode?: 'MANUAL' | 'AUTO';
@IsBoolean() @IsOptional() isActive?: boolean;
}
@Controller('routes') @Controller('routes')
export class RoutesController { export class RoutesController {
constructor(private readonly routesService: RoutesService) {} constructor(private readonly routesService: RoutesService) {}
@@ -36,6 +44,35 @@ export class RoutesController {
return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds); return this.routesService.createBatch(ctx.tenantId, body.sourceGroupId, body.targetGroupIds);
} }
@Patch(':id')
update(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string, @Body() body: UpdateRouteDto) {
return this.routesService.update(ctx.tenantId, id, body);
}
@Get(':id/rules')
listRules(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
return this.routesService.listAssignedRules(ctx.tenantId, id);
}
@Post(':id/rules/:tenantRuleId')
assignRule(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('tenantRuleId') tenantRuleId: string,
) {
return this.routesService.assignRule(ctx.tenantId, id, tenantRuleId);
}
@Delete(':id/rules/:tenantRuleId')
@HttpCode(204)
async unassignRule(
@CurrentTenantContext() ctx: TenantContext,
@Param('id') id: string,
@Param('tenantRuleId') tenantRuleId: string,
) {
await this.routesService.unassignRule(ctx.tenantId, id, tenantRuleId);
}
@Delete(':id') @Delete(':id')
@HttpCode(204) @HttpCode(204)
async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) { async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) {
@@ -9,6 +9,14 @@ export interface BatchCreateResult {
skipped: string[]; skipped: string[];
} }
export interface UpdateRouteInput {
forwardFormat?: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment?: boolean;
frequency?: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
approvalMode?: 'MANUAL' | 'AUTO';
isActive?: boolean;
}
const routeInclude = { const routeInclude = {
sourceGroup: { select: { name: true, tenantId: true } }, sourceGroup: { select: { name: true, tenantId: true } },
targetGroup: { select: { name: true, tenantId: true } }, targetGroup: { select: { name: true, tenantId: true } },
@@ -30,11 +38,70 @@ export class RoutesService {
include: { include: {
sourceGroup: { select: { name: true } }, sourceGroup: { select: { name: true } },
targetGroup: { select: { name: true } }, targetGroup: { select: { name: true } },
assignedRules: {
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true },
},
},
},
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
}); });
} }
async update(tenantId: string, id: string, dto: UpdateRouteInput) {
const existing = await this.prisma.syncRoute.findFirst({ where: { id, tenantId } });
if (!existing) throw new NotFoundException(`Route ${id} not found`);
return this.prisma.syncRoute.update({ where: { id }, data: dto as any });
}
async assignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const rule = await this.prisma.tenantRule.findFirst({ where: { id: tenantRuleId, tenantId } });
if (!rule) throw new NotFoundException(`Rule ${tenantRuleId} not found`);
try {
return await this.prisma.syncRouteRuleMap.create({
data: { id: `${routeId}_${tenantRuleId}`.slice(0, 25) + Math.random().toString(36).slice(2, 8), syncRouteId: routeId, tenantRuleId },
include: { tenantRule: { select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, isActive: true } } },
});
} catch (e) {
if (e instanceof Prisma.PrismaClientKnownRequestError && e.code === 'P2002') {
throw new ConflictException('Rule is already assigned to this route');
}
throw e;
}
}
async unassignRule(tenantId: string, routeId: string, tenantRuleId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const map = await this.prisma.syncRouteRuleMap.findUnique({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
if (!map) throw new NotFoundException('Assignment not found');
await this.prisma.syncRouteRuleMap.delete({
where: { syncRouteId_tenantRuleId: { syncRouteId: routeId, tenantRuleId } },
});
}
async listAssignedRules(tenantId: string, routeId: string) {
const route = await this.prisma.syncRoute.findFirst({ where: { id: routeId, tenantId } });
if (!route) throw new NotFoundException(`Route ${routeId} not found`);
const maps = await this.prisma.syncRouteRuleMap.findMany({
where: { syncRouteId: routeId },
include: {
tenantRule: {
select: { id: true, matchType: true, matchValue: true, action: true, ruleIntent: true, priority: true, isActive: true },
},
},
orderBy: { createdAt: 'asc' },
});
return maps.map((m: any) => ({ ...m.tenantRule, assignedAt: m.createdAt }));
}
async create(tenantId: string, sourceGroupId: string, targetGroupId: string) { async create(tenantId: string, sourceGroupId: string, targetGroupId: string) {
if (!sourceGroupId || !targetGroupId) { if (!sourceGroupId || !targetGroupId) {
throw new BadRequestException('sourceGroupId and targetGroupId are required'); throw new BadRequestException('sourceGroupId and targetGroupId are required');
+19 -32
View File
@@ -6,27 +6,32 @@ import { PrismaService } from '../../prisma/prisma.service';
export class RulesService { export class RulesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async list(tenantId: string): Promise<TenantRuleData[]> { private toData(r: any): TenantRuleData {
const rows = await this.prisma.tenantRule.findMany({ return {
where: { tenantId },
orderBy: { priority: 'asc' },
});
return rows.map((r: any) => ({
id: r.id, id: r.id,
tenantId: r.tenantId, tenantId: r.tenantId,
matchType: r.matchType, matchType: r.matchType,
matchValue: r.matchValue, matchValue: r.matchValue,
action: r.action, action: r.action,
ruleIntent: r.ruleIntent,
priority: r.priority, priority: r.priority,
isActive: r.isActive, isActive: r.isActive,
createdAt: r.createdAt.toISOString(), createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(), updatedAt: r.updatedAt.toISOString(),
})); };
}
async list(tenantId: string): Promise<TenantRuleData[]> {
const rows = await this.prisma.tenantRule.findMany({
where: { tenantId },
orderBy: { priority: 'asc' },
});
return rows.map((r: any) => this.toData(r));
} }
async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> { async create(tenantId: string, req: CreateRuleRequest): Promise<TenantRuleData> {
const existing = await this.prisma.tenantRule.findUnique({ const existing = await this.prisma.tenantRule.findUnique({
where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType, matchValue: req.matchValue } }, where: { tenantId_matchType_matchValue: { tenantId, matchType: req.matchType as any, matchValue: req.matchValue } },
}); });
if (existing) { if (existing) {
throw new ConflictException('A rule with this matchType + matchValue already exists'); throw new ConflictException('A rule with this matchType + matchValue already exists');
@@ -35,25 +40,16 @@ export class RulesService {
const row = await this.prisma.tenantRule.create({ const row = await this.prisma.tenantRule.create({
data: { data: {
tenantId, tenantId,
matchType: req.matchType, matchType: req.matchType as any,
matchValue: req.matchValue, matchValue: req.matchValue,
action: req.action, action: req.action as any,
ruleIntent: (req.ruleIntent ?? 'POSITIVE') as any,
priority: req.priority ?? 0, priority: req.priority ?? 0,
isActive: req.isActive ?? true, isActive: req.isActive ?? true,
}, },
}); });
return { return this.toData(row);
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
} }
async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> { async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise<TenantRuleData> {
@@ -64,22 +60,13 @@ export class RulesService {
if (req.matchType !== undefined) data.matchType = req.matchType; if (req.matchType !== undefined) data.matchType = req.matchType;
if (req.matchValue !== undefined) data.matchValue = req.matchValue; if (req.matchValue !== undefined) data.matchValue = req.matchValue;
if (req.action !== undefined) data.action = req.action; if (req.action !== undefined) data.action = req.action;
if (req.ruleIntent !== undefined) data.ruleIntent = req.ruleIntent;
if (req.priority !== undefined) data.priority = req.priority; if (req.priority !== undefined) data.priority = req.priority;
if (req.isActive !== undefined) data.isActive = req.isActive; if (req.isActive !== undefined) data.isActive = req.isActive;
const row = await this.prisma.tenantRule.update({ where: { id }, data }); const row = await this.prisma.tenantRule.update({ where: { id }, data });
return { return this.toData(row);
id: row.id,
tenantId: row.tenantId,
matchType: row.matchType as any,
matchValue: row.matchValue,
action: row.action as any,
priority: row.priority,
isActive: row.isActive,
createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(),
};
} }
async remove(tenantId: string, id: string): Promise<void> { async remove(tenantId: string, id: string): Promise<void> {
+142 -71
View File
@@ -1,12 +1,18 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { RouteSettingsDialog } from './RouteSettingsDialog';
import { RouteRulesDialog } from './RouteRulesDialog';
interface Group { type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
type ApprovalMode = 'MANUAL' | 'AUTO';
interface AssignedRule {
id: string; id: string;
name: string; matchType: string;
platform: string; matchValue: string;
isActive: boolean; ruleIntent: 'POSITIVE' | 'NEGATIVE';
} }
interface Route { interface Route {
@@ -15,8 +21,29 @@ interface Route {
targetGroupId: string; targetGroupId: string;
sourceGroup: { name: string }; sourceGroup: { name: string };
targetGroup: { name: string }; targetGroup: { name: string };
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
approvalMode: ApprovalMode;
isActive: boolean;
assignedRules?: Array<{ tenantRule: AssignedRule }>;
} }
interface Group {
id: string;
name: string;
platform: string;
isActive: boolean;
}
const FREQ_LABELS: Record<ForwardFrequency, string> = {
INSTANT: 'Instant', FIVE_MIN: '5 min', FIFTEEN_MIN: '15 min',
ONE_HOUR: '1h', ONE_DAY: '1d', SEVEN_DAYS: '7d',
};
const FORMAT_LABELS: Record<ForwardFormat, string> = {
THREADED: 'Threaded', DIGEST: 'Digest', SUMMARY: 'Summary',
};
export function RouteManager({ export function RouteManager({
groups, groups,
initialRoutes, initialRoutes,
@@ -29,8 +56,9 @@ export function RouteManager({
const [targetIds, setTargetIds] = useState<Set<string>>(new Set()); const [targetIds, setTargetIds] = useState<Set<string>>(new Set());
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [settingsRoute, setSettingsRoute] = useState<Route | null>(null);
const [rulesRoute, setRulesRoute] = useState<Route | null>(null);
// Group routes by source group name
const grouped = new Map<string, { sourceId: string; targets: Route[] }>(); const grouped = new Map<string, { sourceId: string; targets: Route[] }>();
for (const r of routes) { for (const r of routes) {
const key = r.sourceGroup.name; const key = r.sourceGroup.name;
@@ -41,8 +69,7 @@ export function RouteManager({
function toggleTarget(id: string) { function toggleTarget(id: string) {
setTargetIds((prev) => { setTargetIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
if (next.has(id)) next.delete(id); if (next.has(id)) next.delete(id); else next.add(id);
else next.add(id);
return next; return next;
}); });
setError(null); setError(null);
@@ -50,35 +77,24 @@ export function RouteManager({
async function addRoutes() { async function addRoutes() {
if (!sourceId || targetIds.size === 0) return; if (!sourceId || targetIds.size === 0) return;
setBusy(true); setBusy(true); setError(null);
setError(null);
try { try {
const res = await fetch('/api/routes/batch', { const res = await fetch('/api/routes/batch', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }), body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }),
}); });
if (res.status === 409) { if (res.status === 409) { setError((await res.json()).message ?? 'Routes already exist'); return; }
const errBody = await res.json(); if (!res.ok) { setError('Failed to create routes'); return; }
setError(errBody.message ?? 'Some routes already exist');
return;
}
if (!res.ok) {
setError('Failed to create routes');
return;
}
const created: Route[] = await res.json(); const created: Route[] = await res.json();
setRoutes((prev) => [...created, ...prev]); setRoutes((prev) => [...created, ...prev]);
setSourceId(''); setSourceId(''); setTargetIds(new Set());
setTargetIds(new Set()); } finally { setBusy(false); }
} finally {
setBusy(false);
}
} }
async function deleteRoute(id: string) { async function deleteRoute(id: string) {
const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' }); const res = await fetch(`/api/routes/${id}`, { method: 'DELETE' });
if (res.ok) setRoutes((prev) => prev.filter((r) => r.id !== id)); if (res.ok || res.status === 204) setRoutes((prev) => prev.filter((r) => r.id !== id));
} }
const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive); const eligibleTargets = groups.filter((g) => g.id !== sourceId && g.platform === 'whatsapp' && g.isActive);
@@ -90,29 +106,72 @@ export function RouteManager({
{routes.length === 0 ? ( {routes.length === 0 ? (
<p className="text-sm text-gray-400">No routes configured.</p> <p className="text-sm text-gray-400">No routes configured.</p>
) : ( ) : (
<ul className="flex flex-col gap-3"> <div className="overflow-x-auto rounded-lg border border-gray-200">
{[...grouped.entries()].map(([sourceName, { sourceId: sId, targets }]) => ( <table className="w-full text-sm">
<li key={sId} className="rounded-lg border border-gray-200 bg-white px-4 py-3"> <thead>
<div className="flex items-start gap-3"> <tr className="border-b border-gray-200 bg-gray-50 text-left text-xs text-gray-500">
<span className="text-sm font-semibold text-gray-700 whitespace-nowrap mt-1 min-w-[120px]">{sourceName}</span> <th className="px-4 py-2.5">Source</th>
<div className="flex flex-col gap-1.5 flex-1"> <th className="px-4 py-2.5">Target</th>
{targets.map((r) => ( <th className="px-4 py-2.5">Rules</th>
<div key={r.id} className="flex items-center justify-between group"> <th className="px-4 py-2.5">Frequency</th>
<span className="text-sm text-gray-600">{r.targetGroup.name}</span> <th className="px-4 py-2.5">Format</th>
<button <th className="px-4 py-2.5">Approval</th>
onClick={() => deleteRoute(r.id)} <th className="px-4 py-2.5" />
className="text-xs text-red-400 hover:text-red-600 opacity-0 group-hover:opacity-100 transition-opacity" </tr>
aria-label={`Delete route to ${r.targetGroup.name}`} </thead>
> <tbody>
Delete {routes.map((r) => {
</button> const ruleCount = r.assignedRules?.length ?? 0;
</div> return (
))} <tr key={r.id} className="border-b border-gray-100 last:border-0 hover:bg-gray-50">
</div> <td className="px-4 py-2.5 font-medium text-gray-700">{r.sourceGroup.name}</td>
</div> <td className="px-4 py-2.5 text-gray-600">{r.targetGroup.name}</td>
</li> <td className="px-4 py-2.5">
))} <span className={`text-xs rounded px-2 py-0.5 ${ruleCount > 0 ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-400'}`}>
</ul> {ruleCount} rule{ruleCount !== 1 ? 's' : ''}
</span>
</td>
<td className="px-4 py-2.5 text-gray-600">{FREQ_LABELS[r.frequency] ?? r.frequency}</td>
<td className="px-4 py-2.5 text-gray-600">
{FORMAT_LABELS[r.forwardFormat] ?? r.forwardFormat}
{r.withAttachment && <span className="ml-1 text-[10px] text-gray-400">+attach</span>}
</td>
<td className="px-4 py-2.5">
<span className={`text-xs rounded px-2 py-0.5 ${r.approvalMode === 'AUTO' ? 'bg-green-100 text-green-700' : 'bg-yellow-50 text-yellow-700'}`}>
{r.approvalMode === 'AUTO' ? 'Auto' : 'Manual'}
</span>
</td>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<button
onClick={() => setSettingsRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Edit settings"
>
</button>
<button
onClick={() => setRulesRoute(r)}
className="text-xs text-gray-400 hover:text-blue-600"
title="Manage rules"
>
📋
</button>
<button
onClick={() => void deleteRoute(r.id)}
className="text-xs text-gray-400 hover:text-red-600"
title="Delete route"
>
🗑
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)} )}
</section> </section>
@@ -124,7 +183,6 @@ export function RouteManager({
value={sourceId} value={sourceId}
onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }} onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }}
className="rounded-lg border border-gray-200 px-3 py-2 text-sm" className="rounded-lg border border-gray-200 px-3 py-2 text-sm"
aria-label="Source group"
> >
<option value="">Source group</option> <option value="">Source group</option>
{groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => ( {groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => (
@@ -132,32 +190,27 @@ export function RouteManager({
))} ))}
</select> </select>
<span className="text-gray-400 text-sm"></span> <span className="text-gray-400 text-sm"></span>
<span className="text-xs text-gray-500"> <span className="text-xs text-gray-500">{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected</span>
{targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected
</span>
</div> </div>
{sourceId && eligibleTargets.length > 0 && ( {sourceId && eligibleTargets.length > 0 && (
<div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto"> <div className="rounded-lg border border-gray-200 bg-white p-3 max-h-48 overflow-y-auto">
{eligibleTargets.map((g) => { {eligibleTargets.map((g) => (
const checked = targetIds.has(g.id); <label
return ( key={g.id}
<label className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${
key={g.id} targetIds.has(g.id) ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50'
className={`flex items-center gap-2 px-2 py-1.5 rounded cursor-pointer text-sm transition-colors ${ }`}
checked ? 'bg-blue-50 text-blue-700' : 'hover:bg-gray-50' >
}`} <input
> type="checkbox"
<input checked={targetIds.has(g.id)}
type="checkbox" onChange={() => toggleTarget(g.id)}
checked={checked} className="rounded border-gray-300"
onChange={() => toggleTarget(g.id)} />
className="rounded border-gray-300" {g.name}
/> </label>
{g.name} ))}
</label>
);
})}
</div> </div>
)} )}
@@ -166,7 +219,7 @@ export function RouteManager({
)} )}
<button <button
onClick={addRoutes} onClick={() => void addRoutes()}
disabled={!sourceId || targetIds.size === 0 || busy} disabled={!sourceId || targetIds.size === 0 || busy}
className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" className="self-start rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
> >
@@ -174,6 +227,24 @@ export function RouteManager({
</button> </button>
</div> </div>
</section> </section>
{settingsRoute && (
<RouteSettingsDialog
route={settingsRoute}
onSaved={(updates) => {
setRoutes((prev) => prev.map((r) => r.id === settingsRoute.id ? { ...r, ...updates } : r));
}}
onClose={() => setSettingsRoute(null)}
/>
)}
{rulesRoute && (
<RouteRulesDialog
routeId={rulesRoute.id}
targetGroupName={rulesRoute.targetGroup.name}
onClose={() => setRulesRoute(null)}
/>
)}
</div> </div>
); );
} }
@@ -0,0 +1,167 @@
'use client';
import { useEffect, useState } from 'react';
interface AssignedRule {
id: string;
matchType: string;
matchValue: string;
action: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
isActive: boolean;
assignedAt: string;
}
interface TenantRule {
id: string;
matchType: string;
matchValue: string;
action: string;
ruleIntent: 'POSITIVE' | 'NEGATIVE';
isActive: boolean;
}
export function RouteRulesDialog({
routeId,
targetGroupName,
onClose,
}: {
routeId: string;
targetGroupName: string;
onClose: () => void;
}) {
const [assigned, setAssigned] = useState<AssignedRule[]>([]);
const [allRules, setAllRules] = useState<TenantRule[]>([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
Promise.all([
fetch(`/api/routes/${routeId}/rules`).then((r) => r.json()),
fetch('/api/rules').then((r) => r.json()),
]).then(([a, all]) => {
setAssigned(Array.isArray(a) ? a : []);
setAllRules(Array.isArray(all) ? all : []);
}).catch(() => {}).finally(() => setLoading(false));
}, [routeId]);
const assignedIds = new Set(assigned.map((r) => r.id));
const available = allRules.filter((r) => r.isActive && !assignedIds.has(r.id));
async function assign(tenantRuleId: string) {
setBusy(tenantRuleId);
try {
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'POST' });
if (res.ok) {
const rule = allRules.find((r) => r.id === tenantRuleId)!;
setAssigned((prev) => [...prev, { ...rule, assignedAt: new Date().toISOString() }]);
}
} finally {
setBusy(null);
}
}
async function unassign(tenantRuleId: string) {
setBusy(tenantRuleId);
try {
const res = await fetch(`/api/routes/${routeId}/rules/${tenantRuleId}`, { method: 'DELETE' });
if (res.ok || res.status === 204) {
setAssigned((prev) => prev.filter((r) => r.id !== tenantRuleId));
}
} finally {
setBusy(null);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-md mx-4 p-5 flex flex-col gap-4 max-h-[80vh]">
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold">Rules for route</h2>
<p className="text-xs text-gray-500"> {targetGroupName}</p>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
{loading ? (
<p className="text-sm text-gray-400">Loading</p>
) : (
<div className="flex flex-col gap-4 overflow-y-auto">
<section>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Assigned ({assigned.length})
</h3>
{assigned.length === 0 ? (
<p className="text-sm text-gray-400">No rules assigned route uses approval mode only.</p>
) : (
<ul className="flex flex-col gap-1.5">
{assigned.map((rule) => (
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-gray-200 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
<span className="text-sm font-mono">{rule.matchValue}</span>
<span className="text-xs text-gray-400">{rule.matchType}</span>
</div>
<button
onClick={() => void unassign(rule.id)}
disabled={busy === rule.id}
className="text-xs text-red-500 hover:text-red-700 disabled:opacity-50"
>
Remove
</button>
</li>
))}
</ul>
)}
</section>
{available.length > 0 && (
<section>
<h3 className="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2">
Add from your rules
</h3>
<ul className="flex flex-col gap-1.5">
{available.map((rule) => (
<li key={rule.id} className="flex items-center justify-between rounded-lg border border-dashed border-gray-200 px-3 py-2">
<div className="flex items-center gap-2">
<span className={`text-[10px] font-medium rounded px-1.5 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
<span className="text-sm font-mono">{rule.matchValue}</span>
<span className="text-xs text-gray-400">{rule.matchType}</span>
</div>
<button
onClick={() => void assign(rule.id)}
disabled={busy === rule.id}
className="text-xs text-blue-600 hover:text-blue-800 disabled:opacity-50"
>
+ Add
</button>
</li>
))}
</ul>
</section>
)}
{available.length === 0 && assigned.length > 0 && (
<p className="text-xs text-gray-400">All active rules are assigned to this route.</p>
)}
</div>
)}
<div className="flex justify-end pt-1">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Done
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
type ApprovalMode = 'MANUAL' | 'AUTO';
interface Route {
id: string;
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
approvalMode: ApprovalMode;
isActive: boolean;
}
const FORMAT_OPTIONS: { value: ForwardFormat; label: string }[] = [
{ value: 'THREADED', label: 'Threaded' },
{ value: 'DIGEST', label: 'Digest' },
{ value: 'SUMMARY', label: 'Summary' },
];
const FREQ_OPTIONS: { value: ForwardFrequency; label: string }[] = [
{ value: 'INSTANT', label: 'Instant' },
{ value: 'FIVE_MIN', label: '5 min' },
{ value: 'FIFTEEN_MIN', label: '15 min' },
{ value: 'ONE_HOUR', label: '1 hour' },
{ value: 'ONE_DAY', label: '1 day' },
{ value: 'SEVEN_DAYS', label: '7 days' },
];
export function RouteSettingsDialog({
route,
onSaved,
onClose,
}: {
route: Route;
onSaved: (updated: Partial<Route>) => void;
onClose: () => void;
}) {
const [forwardFormat, setForwardFormat] = useState<ForwardFormat>(route.forwardFormat);
const [withAttachment, setWithAttachment] = useState(route.withAttachment);
const [frequency, setFrequency] = useState<ForwardFrequency>(route.frequency);
const [approvalMode, setApprovalMode] = useState<ApprovalMode>(route.approvalMode);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function save() {
setBusy(true);
setError('');
try {
const res = await fetch(`/api/routes/${route.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ forwardFormat, withAttachment, frequency, approvalMode }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
setError(err.message ?? 'Failed to save');
return;
}
onSaved({ forwardFormat, withAttachment, frequency, approvalMode });
onClose();
} finally {
setBusy(false);
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 p-5 flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold">Route settings</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Forward format</label>
<select
value={forwardFormat}
onChange={(e) => setForwardFormat(e.target.value as ForwardFormat)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
{FORMAT_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={withAttachment}
onChange={(e) => setWithAttachment(e.target.checked)}
className="rounded border-gray-300"
/>
Include attachments
</label>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Frequency</label>
<select
value={frequency}
onChange={(e) => setFrequency(e.target.value as ForwardFrequency)}
className="border border-gray-300 rounded px-3 py-2 text-sm"
>
{FREQ_OPTIONS.map((o) => <option key={o.value} value={o.value}>{o.label}</option>)}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Approval mode</label>
<div className="flex gap-2">
{(['MANUAL', 'AUTO'] as ApprovalMode[]).map((m) => (
<button
key={m}
type="button"
onClick={() => setApprovalMode(m)}
className={`flex-1 rounded px-3 py-2 text-sm font-medium border transition-colors ${
approvalMode === m
? 'bg-blue-600 text-white border-blue-600'
: 'border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{m === 'MANUAL' ? 'Manual' : 'Auto'}
</button>
))}
</div>
</div>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
<div className="flex justify-end gap-2 pt-1">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
onClick={() => void save()}
disabled={busy}
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Saving…' : 'Save'}
</button>
</div>
</div>
</div>
);
}
+6
View File
@@ -22,6 +22,12 @@ interface Route {
targetGroupId: string; targetGroupId: string;
sourceGroup: { name: string }; sourceGroup: { name: string };
targetGroup: { name: string }; targetGroup: { name: string };
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment: boolean;
frequency: 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
approvalMode: 'MANUAL' | 'AUTO';
isActive: boolean;
assignedRules?: Array<{ tenantRule: { id: string; matchType: string; matchValue: string; ruleIntent: 'POSITIVE' | 'NEGATIVE' } }>;
} }
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string }; type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string };
@@ -0,0 +1,158 @@
'use client';
import { useEffect, useState } from 'react';
interface RouteForMessage {
routeId: string;
targetGroupId: string;
targetGroupName: string;
approvalMode: 'MANUAL' | 'AUTO';
forwardFormat: 'THREADED' | 'DIGEST' | 'SUMMARY';
withAttachment: boolean;
frequency: string;
ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE';
}
export function ForwardTargetDialog({
messageId,
onApproved,
onClose,
}: {
messageId: string;
onApproved: () => void;
onClose: () => void;
}) {
const [routes, setRoutes] = useState<RouteForMessage[]>([]);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [search, setSearch] = useState('');
useEffect(() => {
fetch(`/api/messages/${messageId}/routes`)
.then((r) => r.json())
.then((data: RouteForMessage[]) => {
setRoutes(Array.isArray(data) ? data : []);
// Pre-select: FORWARD decision, or AUTO approvalMode with no BLOCK
const preSelected = new Set(
(Array.isArray(data) ? data : [])
.filter((r) => r.ruleDecision === 'FORWARD' || (r.ruleDecision === 'NONE' && r.approvalMode === 'AUTO'))
.map((r) => r.targetGroupId),
);
setSelected(preSelected);
})
.catch(() => {})
.finally(() => setLoading(false));
}, [messageId]);
function toggleGroup(groupId: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(groupId)) next.delete(groupId); else next.add(groupId);
return next;
});
}
async function confirm() {
setBusy(true);
try {
const res = await fetch(`/api/messages/${messageId}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetGroupIds: [...selected] }),
});
if (res.ok) {
onApproved();
onClose();
}
} finally {
setBusy(false);
}
}
const filtered = routes.filter((r) =>
r.targetGroupName.toLowerCase().includes(search.toLowerCase()),
);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40">
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm mx-4 flex flex-col max-h-[80vh]">
<div className="flex items-center justify-between px-5 pt-5 pb-3 border-b border-gray-100">
<h2 className="text-base font-semibold">Approve &amp; Forward</h2>
<button onClick={onClose} className="text-gray-400 hover:text-gray-600 text-lg leading-none">×</button>
</div>
<div className="px-5 py-3 border-b border-gray-100">
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search groups…"
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm"
/>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{loading ? (
<p className="text-sm text-gray-400">Loading routes</p>
) : filtered.length === 0 ? (
<p className="text-sm text-gray-400">No target groups found.</p>
) : (
<ul className="flex flex-col gap-1.5">
{filtered.map((r) => {
const isBlocked = r.ruleDecision === 'BLOCK';
const isChecked = selected.has(r.targetGroupId);
return (
<li key={r.targetGroupId}>
<label
className={`flex items-center gap-3 rounded-lg px-3 py-2.5 cursor-pointer transition-colors ${
isBlocked
? 'opacity-50 cursor-default'
: isChecked
? 'bg-blue-50'
: 'hover:bg-gray-50'
}`}
>
<input
type="checkbox"
checked={isChecked}
onChange={() => !isBlocked && toggleGroup(r.targetGroupId)}
disabled={isBlocked}
className="rounded border-gray-300"
/>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-gray-800 truncate">{r.targetGroupName}</div>
{isBlocked && (
<div className="text-[10px] text-red-500">Blocked by assigned rule</div>
)}
{r.ruleDecision === 'FORWARD' && (
<div className="text-[10px] text-green-600">Auto-forward by rule</div>
)}
</div>
</label>
</li>
);
})}
</ul>
)}
</div>
<div className="flex items-center justify-between px-5 py-4 border-t border-gray-100">
<span className="text-xs text-gray-500">{selected.size} group{selected.size !== 1 ? 's' : ''} selected</span>
<div className="flex gap-2">
<button onClick={onClose} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800">
Cancel
</button>
<button
onClick={() => void confirm()}
disabled={busy || selected.size === 0}
className="px-4 py-2 rounded bg-blue-600 text-white text-sm font-medium hover:bg-blue-700 disabled:opacity-50"
>
{busy ? 'Approving…' : 'Approve & Forward'}
</button>
</div>
</div>
</div>
</div>
);
}
@@ -1,4 +1,7 @@
import { apiFetch } from '@/app/_lib/api'; 'use client';
import { useCallback, useEffect, useState } from 'react';
import { ForwardTargetDialog } from '../ForwardTargetDialog';
interface PendingMessage { interface PendingMessage {
id: string; id: string;
@@ -12,32 +15,30 @@ interface PendingMessage {
sourceGroupPlatformId: string; sourceGroupPlatformId: string;
} }
type FetchResult<T> = { ok: true; data: T } | { ok: false; status: number; error: string }; export default function PendingMessagesPage() {
const [messages, setMessages] = useState<PendingMessage[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
async function fetchPending(): Promise<FetchResult<PendingMessage[]>> { const load = useCallback(() => {
try { setLoading(true);
const res = await apiFetch('/admin/messages/pending'); fetch('/api/messages/pending')
if (!res.ok) { .then(async (res) => {
const body = await res.text().catch(() => ''); if (!res.ok) throw new Error(`API ${res.status}`);
return { ok: false, status: res.status, error: body.slice(0, 200) || res.statusText }; return res.json() as Promise<PendingMessage[]>;
} })
return { ok: true, data: (await res.json()) as PendingMessage[] }; .then((data) => { setMessages(data); setError(null); })
} catch (err) { .catch((err: Error) => setError(err.message))
return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` }; .finally(() => setLoading(false));
} }, []);
}
export default async function PendingMessagesPage() { useEffect(() => { load(); }, [load]);
const result = await fetchPending();
const messages = result.ok ? result.data : [];
const error = !result.ok ? (result.status === 0 ? 'API unreachable' : `API ${result.status}: ${result.error}`) : null;
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
<h1 className="text-xl font-semibold mb-2">Pending messages</h1> <h1 className="text-xl font-semibold mb-2">Pending messages</h1>
<p className="text-sm text-gray-500 mb-6"> <p className="text-sm text-gray-500 mb-6">
Flagged messages waiting for an admin to approve. Approving forwards them to every active Messages waiting for admin approval. Click "Approve &amp; Forward" to choose which groups to send to.
route from the source group and indexes them in search.
</p> </p>
{error && ( {error && (
@@ -46,60 +47,69 @@ export default async function PendingMessagesPage() {
</div> </div>
)} )}
{!error && messages.length === 0 && ( {!loading && !error && messages.length === 0 && (
<div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500"> <div className="rounded-xl border border-gray-200 bg-white p-6 text-sm text-gray-500">
No pending messages right now. New flagged messages will appear here as they arrive. No pending messages right now.
</div> </div>
)} )}
<ul className="space-y-3"> <ul className="space-y-3">
{messages.map((m) => ( {messages.map((m) => (
<PendingMessageRow key={m.id} message={m} /> <PendingMessageRow
key={m.id}
message={m}
onApproved={() => setMessages((prev) => prev.filter((x) => x.id !== m.id))}
/>
))} ))}
</ul> </ul>
</div> </div>
); );
} }
function PendingMessageRow({ message }: { message: PendingMessage }) { function PendingMessageRow({
message,
onApproved,
}: {
message: PendingMessage;
onApproved: () => void;
}) {
const [dialogOpen, setDialogOpen] = useState(false);
return ( return (
<li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2"> <li className="rounded-xl border border-gray-200 bg-white p-4 flex flex-col gap-2">
<div className="flex items-baseline justify-between gap-2"> <div className="flex items-baseline justify-between gap-2">
<div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div> <div className="text-sm font-medium text-gray-900">{message.sourceGroupName}</div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">{new Date(message.createdAt).toLocaleString()}</div>
{new Date(message.createdAt).toLocaleString()}
</div>
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
From <span className="font-mono">{message.senderName ?? message.senderJid}</span> From <span className="font-mono">{message.senderName ?? message.senderJid}</span>
{message.tags.length > 0 && ( {message.tags.length > 0 && (
<span className="ml-2 inline-flex flex-wrap gap-1"> <span className="ml-2 inline-flex flex-wrap gap-1">
{message.tags.map((t) => ( {message.tags.map((t) => (
<span <span key={t} className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700">
key={t}
className="rounded bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700"
>
{t} {t}
</span> </span>
))} ))}
</span> </span>
)} )}
</div> </div>
<div className="text-sm text-gray-800 whitespace-pre-wrap break-words"> <div className="text-sm text-gray-800 whitespace-pre-wrap break-words">{message.content}</div>
{message.content} <div className="flex justify-end pt-1">
</div>
<form
action={`/api/messages/${message.id}/approve`}
method="post"
className="flex justify-end pt-1"
>
<button <button
type="submit" onClick={() => setDialogOpen(true)}
className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700" className="rounded bg-blue-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-blue-700"
> >
Approve &amp; forward Approve &amp; Forward
</button> </button>
</form> </div>
{dialogOpen && (
<ForwardTargetDialog
messageId={message.id}
onApproved={onApproved}
onClose={() => setDialogOpen(false)}
/>
)}
</li> </li>
); );
} }
@@ -2,48 +2,71 @@
import { useState } from 'react'; import { useState } from 'react';
type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
type RuleIntent = 'POSITIVE' | 'NEGATIVE';
interface RuleData { interface RuleData {
id: string; id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; matchType: RuleMatchType;
matchValue: string; matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; action: RuleAction;
ruleIntent: RuleIntent;
priority: number; priority: number;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
const MATCH_TYPE_LABELS: Record<string, string> = { const MATCH_TYPE_LABELS: Record<RuleMatchType, string> = {
HASHTAG: 'Hashtag', HASHTAG: 'Hashtag',
PREFIX: 'Prefix', PREFIX: 'Prefix',
REACTION_EMOJI: 'Reaction Emoji', REACTION_EMOJI: 'Reaction Emoji',
INTEREST: 'Interest',
}; };
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<RuleAction, string> = {
FLAG: 'Flag (Pending)', FLAG: 'Flag (Pending)',
AUTO_APPROVE: 'Auto-approve', AUTO_APPROVE: 'Auto-approve',
SKIP: 'Skip (Silent Drop)', SKIP: 'Skip (Silent Drop)',
REJECT: 'Reject (Visible)', REJECT: 'Reject (Visible)',
}; };
const REACTION_OPTIONS = [
{ value: '👍', label: '👍 Thumbs Up' },
{ value: '👎', label: '👎 Thumbs Down' },
];
function getValuePlaceholder(matchType: RuleMatchType): string {
if (matchType === 'HASHTAG') return '#important';
if (matchType === 'PREFIX') return '!!';
if (matchType === 'INTEREST') return '#education';
return '';
}
export function RuleManager({ initial }: { initial: RuleData[] }) { export function RuleManager({ initial }: { initial: RuleData[] }) {
const [rules, setRules] = useState<RuleData[]>(initial); const [rules, setRules] = useState<RuleData[]>(initial);
const [matchType, setMatchType] = useState<'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'>('HASHTAG'); const [matchType, setMatchType] = useState<RuleMatchType>('HASHTAG');
const [matchValue, setMatchValue] = useState(''); const [matchValue, setMatchValue] = useState('');
const [action, setAction] = useState<'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'>('FLAG'); const [action, setAction] = useState<RuleAction>('FLAG');
const [ruleIntent, setRuleIntent] = useState<RuleIntent>('POSITIVE');
const [priority, setPriority] = useState(0); const [priority, setPriority] = useState(0);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
function handleMatchTypeChange(type: RuleMatchType) {
setMatchType(type);
setMatchValue('');
}
async function addRule() { async function addRule() {
if (!matchValue.trim()) return; if (!matchValue.trim()) return;
setBusy(true); setBusy(true); setError('');
setError('');
try { try {
const res = await fetch('/api/rules', { const res = await fetch('/api/rules', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, priority }), body: JSON.stringify({ matchType, matchValue: matchValue.trim(), action, ruleIntent, priority }),
}); });
if (!res.ok) { if (!res.ok) {
const err = await res.json().catch(() => ({ message: 'Failed to create rule' })); const err = await res.json().catch(() => ({ message: 'Failed to create rule' }));
@@ -52,12 +75,8 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
} }
const created: RuleData = await res.json(); const created: RuleData = await res.json();
setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority)); setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority));
setMatchValue(''); setMatchValue(''); setAction('FLAG'); setRuleIntent('POSITIVE'); setPriority(0);
setAction('FLAG'); } finally { setBusy(false); }
setPriority(0);
} finally {
setBusy(false);
}
} }
async function toggleRule(rule: RuleData) { async function toggleRule(rule: RuleData) {
@@ -86,28 +105,69 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<label className="text-xs text-gray-500">Type</label> <label className="text-xs text-gray-500">Type</label>
<select <select
value={matchType} value={matchType}
onChange={(e) => setMatchType(e.target.value as any)} onChange={(e) => handleMatchTypeChange(e.target.value as RuleMatchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm" className="border border-gray-300 rounded px-3 py-2 text-sm"
> >
<option value="HASHTAG">Hashtag</option> <option value="HASHTAG">Hashtag</option>
<option value="PREFIX">Prefix</option> <option value="PREFIX">Prefix</option>
<option value="REACTION_EMOJI">Reaction Emoji</option> <option value="REACTION_EMOJI">Reaction Emoji</option>
<option value="INTEREST">Interest</option>
</select> </select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Value</label> <label className="text-xs text-gray-500">Value</label>
<input {matchType === 'REACTION_EMOJI' ? (
value={matchValue} <select
onChange={(e) => setMatchValue(e.target.value)} value={matchValue}
placeholder={matchType === 'REACTION_EMOJI' ? '⭐' : '#important'} onChange={(e) => setMatchValue(e.target.value)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-40" className="border border-gray-300 rounded px-3 py-2 text-sm w-44"
/> >
<option value="">Select emoji</option>
{REACTION_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
) : (
<input
value={matchValue}
onChange={(e) => setMatchValue(e.target.value)}
placeholder={getValuePlaceholder(matchType)}
className="border border-gray-300 rounded px-3 py-2 text-sm w-40"
/>
)}
{matchType === 'INTEREST' && (
<span className="text-[10px] text-gray-400 mt-0.5">Helps AI classify content into circles</span>
)}
</div> </div>
<div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Intent</label>
<div className="flex gap-1">
{(['POSITIVE', 'NEGATIVE'] as RuleIntent[]).map((i) => (
<button
key={i}
type="button"
onClick={() => setRuleIntent(i)}
className={`px-3 py-2 text-sm rounded border transition-colors ${
ruleIntent === i
? i === 'POSITIVE'
? 'bg-green-600 text-white border-green-600'
: 'bg-red-600 text-white border-red-600'
: 'border-gray-300 text-gray-600 hover:border-gray-400'
}`}
>
{i === 'POSITIVE' ? '+ Pos' : ' Neg'}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Action</label> <label className="text-xs text-gray-500">Action</label>
<select <select
value={action} value={action}
onChange={(e) => setAction(e.target.value as any)} onChange={(e) => setAction(e.target.value as RuleAction)}
className="border border-gray-300 rounded px-3 py-2 text-sm" className="border border-gray-300 rounded px-3 py-2 text-sm"
> >
<option value="FLAG">Flag (Pending)</option> <option value="FLAG">Flag (Pending)</option>
@@ -116,6 +176,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<option value="REJECT">Reject (Visible)</option> <option value="REJECT">Reject (Visible)</option>
</select> </select>
</div> </div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<label className="text-xs text-gray-500">Priority</label> <label className="text-xs text-gray-500">Priority</label>
<input <input
@@ -125,6 +186,7 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
className="border border-gray-300 rounded px-3 py-2 text-sm w-20" className="border border-gray-300 rounded px-3 py-2 text-sm w-20"
/> />
</div> </div>
<button <button
type="button" type="button"
onClick={() => void addRule()} onClick={() => void addRule()}
@@ -140,13 +202,14 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<section> <section>
<h2 className="text-base font-semibold mb-3">Active Rules</h2> <h2 className="text-base font-semibold mb-3">Active Rules</h2>
{rules.length === 0 ? ( {rules.length === 0 ? (
<p className="text-sm text-gray-400">No rules configured. Messages without matching rules are ignored.</p> <p className="text-sm text-gray-400">No rules configured. Assign rules to sync routes to control auto-forwarding.</p>
) : ( ) : (
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead> <thead>
<tr className="border-b border-gray-200 text-left text-gray-500"> <tr className="border-b border-gray-200 text-left text-gray-500">
<th className="pb-2 pr-3">Type</th> <th className="pb-2 pr-3">Type</th>
<th className="pb-2 pr-3">Value</th> <th className="pb-2 pr-3">Value</th>
<th className="pb-2 pr-3">Intent</th>
<th className="pb-2 pr-3">Action</th> <th className="pb-2 pr-3">Action</th>
<th className="pb-2 pr-3">Priority</th> <th className="pb-2 pr-3">Priority</th>
<th className="pb-2 pr-3">Active</th> <th className="pb-2 pr-3">Active</th>
@@ -158,6 +221,13 @@ export function RuleManager({ initial }: { initial: RuleData[] }) {
<tr key={rule.id} className="border-b border-gray-100"> <tr key={rule.id} className="border-b border-gray-100">
<td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td> <td className="py-2 pr-3">{MATCH_TYPE_LABELS[rule.matchType] ?? rule.matchType}</td>
<td className="py-2 pr-3 font-mono">{rule.matchValue}</td> <td className="py-2 pr-3 font-mono">{rule.matchValue}</td>
<td className="py-2 pr-3">
<span className={`text-xs rounded px-2 py-0.5 ${
rule.ruleIntent === 'POSITIVE' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{rule.ruleIntent}
</span>
</td>
<td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td> <td className="py-2 pr-3">{ACTION_LABELS[rule.action] ?? rule.action}</td>
<td className="py-2 pr-3">{rule.priority}</td> <td className="py-2 pr-3">{rule.priority}</td>
<td className="py-2 pr-3"> <td className="py-2 pr-3">
@@ -3,9 +3,10 @@ import { apiFetch } from '@/app/_lib/api';
interface RuleData { interface RuleData {
id: string; id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
matchValue: string; matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
ruleIntent: 'POSITIVE' | 'NEGATIVE';
priority: number; priority: number;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
@@ -3,11 +3,12 @@ import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function POST( export async function POST(
_req: Request, req: Request,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
): Promise<Response> { ): Promise<Response> {
const { id } = await params; const { id } = await params;
const res = await apiFetch(`/admin/messages/${id}/approve`, { method: 'POST' }); const reqBody = await req.text().catch(() => '');
const res = await apiFetch(`/admin/messages/${id}/approve`, { method: 'POST', body: reqBody || undefined });
const body = await res.text(); const body = await res.text();
let payload: unknown = body; let payload: unknown = body;
try { try {
@@ -0,0 +1,15 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/admin/messages/${id}/routes`);
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
+14 -1
View File
@@ -1,7 +1,20 @@
import { apiFetch } from '../../../_lib/api'; import { apiFetch, jsonResponse } from '../../../_lib/api';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const body = await req.text();
const res = await apiFetch(`/routes/${id}`, { method: 'PATCH', body });
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
export async function DELETE( export async function DELETE(
_req: Request, _req: Request,
{ params }: { params: Promise<{ id: string }> }, { params }: { params: Promise<{ id: string }> },
@@ -0,0 +1,26 @@
import { apiFetch, jsonResponse } from '../../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function POST(
_req: Request,
{ params }: { params: Promise<{ id: string; tenantRuleId: string }> },
): Promise<Response> {
const { id, tenantRuleId } = await params;
const res = await apiFetch(`/routes/${id}/rules/${tenantRuleId}`, { method: 'POST' });
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
export async function DELETE(
_req: Request,
{ params }: { params: Promise<{ id: string; tenantRuleId: string }> },
): Promise<Response> {
const { id, tenantRuleId } = await params;
const res = await apiFetch(`/routes/${id}/rules/${tenantRuleId}`, { method: 'DELETE' });
if (res.status === 204) return new Response(null, { status: 204 });
const body = await res.text();
return new Response(body, { status: res.status, headers: { 'Content-Type': 'application/json' } });
}
@@ -0,0 +1,15 @@
import { apiFetch, jsonResponse } from '../../../../_lib/api';
export const dynamic = 'force-dynamic';
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> },
): Promise<Response> {
const { id } = await params;
const res = await apiFetch(`/routes/${id}/rules`);
const text = await res.text();
let payload: unknown = text;
try { payload = JSON.parse(text); } catch { /* keep as text */ }
return jsonResponse(payload, res.status);
}
+3 -1
View File
@@ -16,8 +16,9 @@ export interface ApproveMessageResult {
export async function approveMessage( export async function approveMessage(
messageId: string, messageId: string,
tenantId: string, tenantId: string,
adminId: string, // JID of the reactor or system adminId: string,
prisma: any, prisma: any,
targetGroupIds?: string[],
): Promise<ApproveMessageResult | null> { ): Promise<ApproveMessageResult | null> {
const message = await prisma.message.findUnique({ const message = await prisma.message.findUnique({
where: { id: messageId }, where: { id: messageId },
@@ -60,6 +61,7 @@ export async function approveMessage(
const forwardJobs: ForwardJobData[] = (message.sourceGroup?.syncRoutesFrom ?? []) const forwardJobs: ForwardJobData[] = (message.sourceGroup?.syncRoutesFrom ?? [])
.filter((route: any) => route.targetGroup != null) .filter((route: any) => route.targetGroup != null)
.filter((route: any) => !targetGroupIds || targetGroupIds.includes(route.targetGroupId))
.map((route: any) => ({ .map((route: any) => ({
tenantId: message.tenantId, tenantId: message.tenantId,
messageId: message.id, messageId: message.id,
+79 -23
View File
@@ -2,8 +2,8 @@ import { Worker, Queue } from 'bullmq';
import Redis from 'ioredis'; import Redis from 'ioredis';
import { IngestJobData, ForwardJobData, IndexJobData } from '@tower/types'; import { IngestJobData, ForwardJobData, IndexJobData } from '@tower/types';
import { parseRedisUrl } from './redis-connection'; import { parseRedisUrl } from './redis-connection';
import { approveMessage } from '../core/approve-message';
import { publishToMessageStream } from '../streams/message-stream'; import { publishToMessageStream } from '../streams/message-stream';
import { matchContentRules, TenantRuleRow } from '../whatsapp/match-rules';
import { createLogger } from '@tower/logger'; import { createLogger } from '@tower/logger';
const logger = createLogger('ingest-processor'); const logger = createLogger('ingest-processor');
@@ -110,7 +110,7 @@ export async function processIngestJob(
}).catch((err: unknown) => logger.warn({ err, messageId: msg.id }, 'Failed to publish to message stream')); }).catch((err: unknown) => logger.warn({ err, messageId: msg.id }, 'Failed to publish to message stream'));
} }
// For REJECT, create an approval record so it's searchable as rejected // Global gate: REJECT means this message is not acceptable for this tenant
if (job.effectiveAction === 'REJECT') { if (job.effectiveAction === 'REJECT') {
await prisma.approval.upsert({ await prisma.approval.upsert({
where: { messageId: msg.id }, where: { messageId: msg.id },
@@ -122,31 +122,87 @@ export async function processIngestJob(
decision: 'REJECTED', decision: 'REJECTED',
}, },
}); });
logger.info({ messageId: msg.id }, 'Message rejected by rule'); logger.info({ messageId: msg.id }, 'Message rejected by tenant rule');
return; return;
} }
// For AUTO_APPROVE, immediately approve via approveMessage helper // Per-route rule evaluation — determines which routes auto-forward vs stay pending
if (job.effectiveAction === 'AUTO_APPROVE') { const syncRoutes = await prisma.syncRoute.findMany({
const result = await approveMessage(msg.id, job.tenantId, 'system', prisma); where: { sourceGroupId: job.sourceGroupId, isActive: true },
if (result) { include: {
if (forwardQueue) { targetGroup: { select: { id: true, platformId: true, accountId: true, isActive: true } },
for (const fwd of result.forwardJobs) { assignedRules: {
await forwardQueue.add('forward', fwd, { include: {
attempts: 3, tenantRule: {
backoff: { type: 'exponential', delay: 2000 }, select: { matchType: true, matchValue: true, action: true, ruleIntent: true, priority: true, isActive: true },
}); },
} },
} },
if (indexQueue) { },
await indexQueue.add('index', result.indexDoc, { });
attempts: 3,
backoff: { type: 'exponential', delay: 1000 }, const autoForwardRoutes = syncRoutes.filter((route: any) => {
}); if (!route.targetGroup?.isActive) return false;
}
logger.info({ messageId: msg.id, forwardCount: result.forwardJobs.length }, 'Message auto-approved'); const activeRules: TenantRuleRow[] = route.assignedRules
.filter((m: any) => m.tenantRule.isActive)
.map((m: any) => m.tenantRule);
if (activeRules.length === 0) {
return route.approvalMode === 'AUTO';
} }
return;
const negativeRules = activeRules.filter((r) => (r as any).ruleIntent === 'NEGATIVE');
const positiveRules = activeRules.filter((r) => (r as any).ruleIntent === 'POSITIVE');
if (negativeRules.length > 0) {
const { effectiveAction } = matchContentRules(job.content, negativeRules);
if (effectiveAction) return false;
}
if (positiveRules.length > 0) {
const { effectiveAction } = matchContentRules(job.content, positiveRules);
if (effectiveAction) return true;
}
return route.approvalMode === 'AUTO';
});
if (autoForwardRoutes.length > 0 && forwardQueue) {
for (const route of autoForwardRoutes) {
await forwardQueue.add('forward', {
tenantId: job.tenantId,
messageId: msg.id,
content: job.content,
sourceGroupName: group.name,
senderName: job.senderName,
toGroupJid: route.targetGroup.platformId,
fromAccountId: route.targetGroup.accountId ?? '',
} as ForwardJobData, { attempts: 3, backoff: { type: 'exponential', delay: 2000 } });
}
await prisma.message.update({ where: { id: msg.id }, data: { status: 'APPROVED' } });
await prisma.approval.upsert({
where: { messageId: msg.id },
update: {},
create: { tenantId: job.tenantId, messageId: msg.id, adminId: 'system', decision: 'APPROVED' },
});
if (indexQueue) {
await indexQueue.add('index', {
tenantId: job.tenantId,
messageId: msg.id,
content: job.content,
senderName: job.senderName ?? null,
sourceGroupId: job.sourceGroupId,
sourceGroupName: group.name,
tags: job.tags,
platform: job.platform,
approvedAt: new Date().toISOString(),
}, { attempts: 3, backoff: { type: 'exponential', delay: 1000 } });
}
logger.info({ messageId: msg.id, routeCount: autoForwardRoutes.length }, 'Message auto-forwarded via route rules');
} }
} }
+1 -1
View File
@@ -6,7 +6,7 @@
export interface TenantRuleRow { export interface TenantRuleRow {
id: string; id: string;
matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
matchValue: string; matchValue: string;
action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT' | 'P1'; action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT' | 'P1';
priority: number; priority: number;
+23 -1
View File
@@ -1,13 +1,22 @@
export type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; export type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST';
export type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; export type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT';
export type RuleIntent = 'POSITIVE' | 'NEGATIVE';
export type ForwardFormat = 'THREADED' | 'DIGEST' | 'SUMMARY';
export type ForwardFrequency = 'INSTANT' | 'FIVE_MIN' | 'FIFTEEN_MIN' | 'ONE_HOUR' | 'ONE_DAY' | 'SEVEN_DAYS';
export type ApprovalMode = 'MANUAL' | 'AUTO';
export interface TenantRuleData { export interface TenantRuleData {
id: string; id: string;
tenantId: string; tenantId: string;
matchType: RuleMatchType; matchType: RuleMatchType;
matchValue: string; matchValue: string;
action: RuleAction; action: RuleAction;
ruleIntent: RuleIntent;
priority: number; priority: number;
isActive: boolean; isActive: boolean;
createdAt: string; createdAt: string;
@@ -18,6 +27,7 @@ export interface CreateRuleRequest {
matchType: RuleMatchType; matchType: RuleMatchType;
matchValue: string; matchValue: string;
action: RuleAction; action: RuleAction;
ruleIntent?: RuleIntent;
priority?: number; priority?: number;
isActive?: boolean; isActive?: boolean;
} }
@@ -26,6 +36,18 @@ export interface UpdateRuleRequest {
matchType?: RuleMatchType; matchType?: RuleMatchType;
matchValue?: string; matchValue?: string;
action?: RuleAction; action?: RuleAction;
ruleIntent?: RuleIntent;
priority?: number; priority?: number;
isActive?: boolean; isActive?: boolean;
} }
export interface RouteForMessage {
routeId: string;
targetGroupId: string;
targetGroupName: string;
approvalMode: ApprovalMode;
forwardFormat: ForwardFormat;
withAttachment: boolean;
frequency: ForwardFrequency;
ruleDecision: 'FORWARD' | 'BLOCK' | 'NONE';
}