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:
@@ -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;
|
||||||
@@ -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');
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 & 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 & 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 & forward
|
Approve & 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user