diff --git a/apps/api/prisma/migrations/20260625000000_route_rule_mapping/migration.sql b/apps/api/prisma/migrations/20260625000000_route_rule_mapping/migration.sql new file mode 100644 index 0000000..4e52172 --- /dev/null +++ b/apps/api/prisma/migrations/20260625000000_route_rule_mapping/migration.sql @@ -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; diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index c1988e3..f217238 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -337,20 +337,40 @@ enum ApprovalDecision { } model SyncRoute { - id String @id @default(cuid()) - tenantId String - tenant Tenant @relation(fields: [tenantId], references: [id]) - sourceGroupId String - sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id]) - targetGroupId String - targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id]) - isActive Boolean @default(true) - createdAt DateTime @default(now()) + id String @id @default(cuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + sourceGroupId String + sourceGroup Group @relation("sourceGroup", fields: [sourceGroupId], references: [id]) + targetGroupId String + targetGroup Group @relation("targetGroup", fields: [targetGroupId], references: [id]) + isActive Boolean @default(true) + 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]) @@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 // ============================================================================ @@ -515,6 +535,7 @@ enum RuleMatchType { HASHTAG PREFIX REACTION_EMOJI + INTEREST } enum RuleAction { @@ -524,17 +545,45 @@ enum RuleAction { 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 { - id String @id @default(cuid()) - tenantId String - tenant Tenant @relation(fields: [tenantId], references: [id]) - matchType RuleMatchType - matchValue String - action RuleAction - priority Int @default(0) - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + tenantId String + tenant Tenant @relation(fields: [tenantId], references: [id]) + matchType RuleMatchType + matchValue String + action RuleAction + ruleIntent RuleIntent @default(POSITIVE) + priority Int @default(0) + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + syncRouteRuleMaps SyncRouteRuleMap[] @@unique([tenantId, matchType, matchValue]) @@index([tenantId, isActive]) diff --git a/apps/api/src/modules/messages/messages.controller.ts b/apps/api/src/modules/messages/messages.controller.ts index 371461a..9cd4faa 100644 --- a/apps/api/src/modules/messages/messages.controller.ts +++ b/apps/api/src/modules/messages/messages.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common'; +import { IsArray, IsOptional, IsString } from 'class-validator'; import { MessagesService } from './messages.service'; import { JwtAuthGuard } from '../auth/jwt-auth.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 { TenantContext } from '../../common/tenant-context'; +class ApproveMessageDto { + @IsArray() @IsString({ each: true }) @IsOptional() targetGroupIds?: string[]; +} + @Controller('admin/messages') @UseGuards(JwtAuthGuard, RolesGuard) @Roles('OWNER', 'ADMIN') @@ -22,6 +27,11 @@ export class MessagesController { 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( @CurrentTenantContext() ctx: TenantContext, @@ -34,8 +44,9 @@ export class MessagesController { approve( @CurrentTenantContext() ctx: TenantContext, @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') diff --git a/apps/api/src/modules/messages/messages.service.ts b/apps/api/src/modules/messages/messages.service.ts index c4727c3..e0047a6 100644 --- a/apps/api/src/modules/messages/messages.service.ts +++ b/apps/api/src/modules/messages/messages.service.ts @@ -1,7 +1,7 @@ import { ConflictException, Inject, Injectable, NotFoundException, forwardRef } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Queue } from 'bullmq'; -import { ForwardJobData, IndexJobData } from '@tower/types'; +import { ForwardJobData, IndexJobData, RouteForMessage } from '@tower/types'; import { PrismaService } from '../../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; 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 { + 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({ where: { id: messageId }, include: { @@ -160,10 +229,14 @@ export class MessagesService { 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, ); - 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, messageId: message.id, content: message.content, @@ -204,6 +277,7 @@ export class MessagesService { resourceId: message.id, payload: { routesForwarded: forwardJobs.length, + targetGroupIds: targetGroupIds ?? null, contentPreview: message.content.slice(0, 80), }, }); diff --git a/apps/api/src/modules/routes/routes.controller.ts b/apps/api/src/modules/routes/routes.controller.ts index 678d7bf..5cbdd5c 100644 --- a/apps/api/src/modules/routes/routes.controller.ts +++ b/apps/api/src/modules/routes/routes.controller.ts @@ -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 { CurrentTenantContext } from '../auth/current-tenant.decorator'; import { TenantContext } from '../../common/tenant-context'; -import { IsString } from 'class-validator'; class CreateRouteDto { @IsString() sourceGroupId!: string; @@ -14,6 +14,14 @@ class BatchCreateRouteDto { @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') export class RoutesController { constructor(private readonly routesService: RoutesService) {} @@ -36,6 +44,35 @@ export class RoutesController { 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') @HttpCode(204) async remove(@CurrentTenantContext() ctx: TenantContext, @Param('id') id: string) { diff --git a/apps/api/src/modules/routes/routes.service.ts b/apps/api/src/modules/routes/routes.service.ts index 4aecd49..9b47f90 100644 --- a/apps/api/src/modules/routes/routes.service.ts +++ b/apps/api/src/modules/routes/routes.service.ts @@ -9,6 +9,14 @@ export interface BatchCreateResult { 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 = { sourceGroup: { select: { name: true, tenantId: true } }, targetGroup: { select: { name: true, tenantId: true } }, @@ -30,11 +38,70 @@ export class RoutesService { include: { sourceGroup: { 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' }, }); } + 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) { if (!sourceGroupId || !targetGroupId) { throw new BadRequestException('sourceGroupId and targetGroupId are required'); diff --git a/apps/api/src/modules/rules/rules.service.ts b/apps/api/src/modules/rules/rules.service.ts index d0d2d39..7b9f4ee 100644 --- a/apps/api/src/modules/rules/rules.service.ts +++ b/apps/api/src/modules/rules/rules.service.ts @@ -6,27 +6,32 @@ import { PrismaService } from '../../prisma/prisma.service'; export class RulesService { constructor(private readonly prisma: PrismaService) {} - async list(tenantId: string): Promise { - const rows = await this.prisma.tenantRule.findMany({ - where: { tenantId }, - orderBy: { priority: 'asc' }, - }); - return rows.map((r: any) => ({ + private toData(r: any): TenantRuleData { + return { id: r.id, tenantId: r.tenantId, matchType: r.matchType, matchValue: r.matchValue, action: r.action, + ruleIntent: r.ruleIntent, priority: r.priority, isActive: r.isActive, createdAt: r.createdAt.toISOString(), updatedAt: r.updatedAt.toISOString(), - })); + }; + } + + async list(tenantId: string): Promise { + 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 { 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) { 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({ data: { tenantId, - matchType: req.matchType, + matchType: req.matchType as any, matchValue: req.matchValue, - action: req.action, + action: req.action as any, + ruleIntent: (req.ruleIntent ?? 'POSITIVE') as any, priority: req.priority ?? 0, isActive: req.isActive ?? true, }, }); - return { - 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(), - }; + return this.toData(row); } async update(tenantId: string, id: string, req: UpdateRuleRequest): Promise { @@ -64,22 +60,13 @@ export class RulesService { if (req.matchType !== undefined) data.matchType = req.matchType; if (req.matchValue !== undefined) data.matchValue = req.matchValue; 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.isActive !== undefined) data.isActive = req.isActive; const row = await this.prisma.tenantRule.update({ where: { id }, data }); - return { - 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(), - }; + return this.toData(row); } async remove(tenantId: string, id: string): Promise { diff --git a/apps/web/app/(chapter)/groups/RouteManager.tsx b/apps/web/app/(chapter)/groups/RouteManager.tsx index fd303ba..a44a37f 100644 --- a/apps/web/app/(chapter)/groups/RouteManager.tsx +++ b/apps/web/app/(chapter)/groups/RouteManager.tsx @@ -1,12 +1,18 @@ 'use client'; 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; - name: string; - platform: string; - isActive: boolean; + matchType: string; + matchValue: string; + ruleIntent: 'POSITIVE' | 'NEGATIVE'; } interface Route { @@ -15,8 +21,29 @@ interface Route { targetGroupId: string; sourceGroup: { 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 = { + INSTANT: 'Instant', FIVE_MIN: '5 min', FIFTEEN_MIN: '15 min', + ONE_HOUR: '1h', ONE_DAY: '1d', SEVEN_DAYS: '7d', +}; +const FORMAT_LABELS: Record = { + THREADED: 'Threaded', DIGEST: 'Digest', SUMMARY: 'Summary', +}; + export function RouteManager({ groups, initialRoutes, @@ -29,8 +56,9 @@ export function RouteManager({ const [targetIds, setTargetIds] = useState>(new Set()); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); + const [settingsRoute, setSettingsRoute] = useState(null); + const [rulesRoute, setRulesRoute] = useState(null); - // Group routes by source group name const grouped = new Map(); for (const r of routes) { const key = r.sourceGroup.name; @@ -41,8 +69,7 @@ export function RouteManager({ function toggleTarget(id: string) { setTargetIds((prev) => { const next = new Set(prev); - if (next.has(id)) next.delete(id); - else next.add(id); + if (next.has(id)) next.delete(id); else next.add(id); return next; }); setError(null); @@ -50,35 +77,24 @@ export function RouteManager({ async function addRoutes() { if (!sourceId || targetIds.size === 0) return; - setBusy(true); - setError(null); + setBusy(true); setError(null); try { const res = await fetch('/api/routes/batch', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceGroupId: sourceId, targetGroupIds: [...targetIds] }), }); - if (res.status === 409) { - const errBody = await res.json(); - setError(errBody.message ?? 'Some routes already exist'); - return; - } - if (!res.ok) { - setError('Failed to create routes'); - return; - } + if (res.status === 409) { setError((await res.json()).message ?? 'Routes already exist'); return; } + if (!res.ok) { setError('Failed to create routes'); return; } const created: Route[] = await res.json(); setRoutes((prev) => [...created, ...prev]); - setSourceId(''); - setTargetIds(new Set()); - } finally { - setBusy(false); - } + setSourceId(''); setTargetIds(new Set()); + } finally { setBusy(false); } } async function deleteRoute(id: string) { 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); @@ -90,29 +106,72 @@ export function RouteManager({ {routes.length === 0 ? (

No routes configured.

) : ( -
    - {[...grouped.entries()].map(([sourceName, { sourceId: sId, targets }]) => ( -
  • -
    - {sourceName} -
    - {targets.map((r) => ( -
    - {r.targetGroup.name} - -
    - ))} -
    -
    -
  • - ))} -
+
+ + + + + + + + + + + + + {routes.map((r) => { + const ruleCount = r.assignedRules?.length ?? 0; + return ( + + + + + + + + + + ); + })} + +
SourceTargetRulesFrequencyFormatApproval +
{r.sourceGroup.name}{r.targetGroup.name} + 0 ? 'bg-blue-50 text-blue-700' : 'bg-gray-100 text-gray-400'}`}> + {ruleCount} rule{ruleCount !== 1 ? 's' : ''} + + {FREQ_LABELS[r.frequency] ?? r.frequency} + {FORMAT_LABELS[r.forwardFormat] ?? r.forwardFormat} + {r.withAttachment && +attach} + + + {r.approvalMode === 'AUTO' ? 'Auto' : 'Manual'} + + +
+ + + +
+
+
)} @@ -124,7 +183,6 @@ export function RouteManager({ value={sourceId} onChange={(e) => { setSourceId(e.target.value); setTargetIds(new Set()); setError(null); }} className="rounded-lg border border-gray-200 px-3 py-2 text-sm" - aria-label="Source group" > {groups.filter((g) => g.platform === 'whatsapp' && g.isActive).map((g) => ( @@ -132,32 +190,27 @@ export function RouteManager({ ))} - - {targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected - + {targetIds.size} target{targetIds.size !== 1 ? 's' : ''} selected {sourceId && eligibleTargets.length > 0 && (
- {eligibleTargets.map((g) => { - const checked = targetIds.has(g.id); - return ( - - ); - })} + {eligibleTargets.map((g) => ( + + ))}
)} @@ -166,7 +219,7 @@ export function RouteManager({ )} + + {settingsRoute && ( + { + setRoutes((prev) => prev.map((r) => r.id === settingsRoute.id ? { ...r, ...updates } : r)); + }} + onClose={() => setSettingsRoute(null)} + /> + )} + + {rulesRoute && ( + setRulesRoute(null)} + /> + )} ); } diff --git a/apps/web/app/(chapter)/groups/RouteRulesDialog.tsx b/apps/web/app/(chapter)/groups/RouteRulesDialog.tsx new file mode 100644 index 0000000..308b946 --- /dev/null +++ b/apps/web/app/(chapter)/groups/RouteRulesDialog.tsx @@ -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([]); + const [allRules, setAllRules] = useState([]); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(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 ( +
+
+
+
+

Rules for route

+

→ {targetGroupName}

+
+ +
+ + {loading ? ( +

Loading…

+ ) : ( +
+
+

+ Assigned ({assigned.length}) +

+ {assigned.length === 0 ? ( +

No rules assigned — route uses approval mode only.

+ ) : ( +
    + {assigned.map((rule) => ( +
  • +
    + + {rule.ruleIntent} + + {rule.matchValue} + {rule.matchType} +
    + +
  • + ))} +
+ )} +
+ + {available.length > 0 && ( +
+

+ Add from your rules +

+
    + {available.map((rule) => ( +
  • +
    + + {rule.ruleIntent} + + {rule.matchValue} + {rule.matchType} +
    + +
  • + ))} +
+
+ )} + + {available.length === 0 && assigned.length > 0 && ( +

All active rules are assigned to this route.

+ )} +
+ )} + +
+ +
+
+
+ ); +} diff --git a/apps/web/app/(chapter)/groups/RouteSettingsDialog.tsx b/apps/web/app/(chapter)/groups/RouteSettingsDialog.tsx new file mode 100644 index 0000000..6812620 --- /dev/null +++ b/apps/web/app/(chapter)/groups/RouteSettingsDialog.tsx @@ -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) => void; + onClose: () => void; +}) { + const [forwardFormat, setForwardFormat] = useState(route.forwardFormat); + const [withAttachment, setWithAttachment] = useState(route.withAttachment); + const [frequency, setFrequency] = useState(route.frequency); + const [approvalMode, setApprovalMode] = useState(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 ( +
+
+
+

Route settings

+ +
+ +
+
+ + +
+ + + +
+ + +
+ +
+ +
+ {(['MANUAL', 'AUTO'] as ApprovalMode[]).map((m) => ( + + ))} +
+
+
+ + {error &&

{error}

} + +
+ + +
+
+
+ ); +} diff --git a/apps/web/app/(chapter)/groups/page.tsx b/apps/web/app/(chapter)/groups/page.tsx index 7773387..c219e74 100644 --- a/apps/web/app/(chapter)/groups/page.tsx +++ b/apps/web/app/(chapter)/groups/page.tsx @@ -22,6 +22,12 @@ interface Route { targetGroupId: string; sourceGroup: { 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 = { ok: true; data: T } | { ok: false; status: number; error: string }; diff --git a/apps/web/app/(chapter)/messages/ForwardTargetDialog.tsx b/apps/web/app/(chapter)/messages/ForwardTargetDialog.tsx new file mode 100644 index 0000000..2a9ad96 --- /dev/null +++ b/apps/web/app/(chapter)/messages/ForwardTargetDialog.tsx @@ -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([]); + const [selected, setSelected] = useState>(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 ( +
+
+
+

Approve & Forward

+ +
+ +
+ setSearch(e.target.value)} + placeholder="Search groups…" + className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm" + /> +
+ +
+ {loading ? ( +

Loading routes…

+ ) : filtered.length === 0 ? ( +

No target groups found.

+ ) : ( +
    + {filtered.map((r) => { + const isBlocked = r.ruleDecision === 'BLOCK'; + const isChecked = selected.has(r.targetGroupId); + return ( +
  • + +
  • + ); + })} +
+ )} +
+ +
+ {selected.size} group{selected.size !== 1 ? 's' : ''} selected +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/app/(chapter)/messages/pending/page.tsx b/apps/web/app/(chapter)/messages/pending/page.tsx index 314c744..ef83de3 100644 --- a/apps/web/app/(chapter)/messages/pending/page.tsx +++ b/apps/web/app/(chapter)/messages/pending/page.tsx @@ -1,4 +1,7 @@ -import { apiFetch } from '@/app/_lib/api'; +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { ForwardTargetDialog } from '../ForwardTargetDialog'; interface PendingMessage { id: string; @@ -12,32 +15,30 @@ interface PendingMessage { sourceGroupPlatformId: string; } -type FetchResult = { ok: true; data: T } | { ok: false; status: number; error: string }; +export default function PendingMessagesPage() { + const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); -async function fetchPending(): Promise> { - try { - const res = await apiFetch('/admin/messages/pending'); - if (!res.ok) { - const body = await res.text().catch(() => ''); - return { ok: false, status: res.status, error: body.slice(0, 200) || res.statusText }; - } - return { ok: true, data: (await res.json()) as PendingMessage[] }; - } catch (err) { - return { ok: false, status: 0, error: `API unreachable: ${(err as Error).message}` }; - } -} + const load = useCallback(() => { + setLoading(true); + fetch('/api/messages/pending') + .then(async (res) => { + if (!res.ok) throw new Error(`API ${res.status}`); + return res.json() as Promise; + }) + .then((data) => { setMessages(data); setError(null); }) + .catch((err: Error) => setError(err.message)) + .finally(() => setLoading(false)); + }, []); -export default async function PendingMessagesPage() { - 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; + useEffect(() => { load(); }, [load]); return (

Pending messages

- Flagged messages waiting for an admin to approve. Approving forwards them to every active - route from the source group and indexes them in search. + Messages waiting for admin approval. Click "Approve & Forward" to choose which groups to send to.

{error && ( @@ -46,60 +47,69 @@ export default async function PendingMessagesPage() {
)} - {!error && messages.length === 0 && ( + {!loading && !error && messages.length === 0 && (
- No pending messages right now. New flagged messages will appear here as they arrive. + No pending messages right now.
)}
    {messages.map((m) => ( - + setMessages((prev) => prev.filter((x) => x.id !== m.id))} + /> ))}
); } -function PendingMessageRow({ message }: { message: PendingMessage }) { +function PendingMessageRow({ + message, + onApproved, +}: { + message: PendingMessage; + onApproved: () => void; +}) { + const [dialogOpen, setDialogOpen] = useState(false); + return (
  • {message.sourceGroupName}
    -
    - {new Date(message.createdAt).toLocaleString()} -
    +
    {new Date(message.createdAt).toLocaleString()}
    From {message.senderName ?? message.senderJid} {message.tags.length > 0 && ( {message.tags.map((t) => ( - + {t} ))} )}
    -
    - {message.content} -
    -
    +
    {message.content}
    +
    - +
    + + {dialogOpen && ( + setDialogOpen(false)} + /> + )}
  • ); } diff --git a/apps/web/app/(chapter)/settings/rules/RuleManager.tsx b/apps/web/app/(chapter)/settings/rules/RuleManager.tsx index 08abac9..144a4a1 100644 --- a/apps/web/app/(chapter)/settings/rules/RuleManager.tsx +++ b/apps/web/app/(chapter)/settings/rules/RuleManager.tsx @@ -2,48 +2,71 @@ import { useState } from 'react'; +type RuleMatchType = 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI' | 'INTEREST'; +type RuleAction = 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; +type RuleIntent = 'POSITIVE' | 'NEGATIVE'; + interface RuleData { id: string; - matchType: 'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'; + matchType: RuleMatchType; matchValue: string; - action: 'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'; + action: RuleAction; + ruleIntent: RuleIntent; priority: number; isActive: boolean; createdAt: string; updatedAt: string; } -const MATCH_TYPE_LABELS: Record = { +const MATCH_TYPE_LABELS: Record = { HASHTAG: 'Hashtag', PREFIX: 'Prefix', REACTION_EMOJI: 'Reaction Emoji', + INTEREST: 'Interest', }; -const ACTION_LABELS: Record = { +const ACTION_LABELS: Record = { FLAG: 'Flag (Pending)', AUTO_APPROVE: 'Auto-approve', SKIP: 'Skip (Silent Drop)', 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[] }) { const [rules, setRules] = useState(initial); - const [matchType, setMatchType] = useState<'HASHTAG' | 'PREFIX' | 'REACTION_EMOJI'>('HASHTAG'); + const [matchType, setMatchType] = useState('HASHTAG'); const [matchValue, setMatchValue] = useState(''); - const [action, setAction] = useState<'FLAG' | 'AUTO_APPROVE' | 'SKIP' | 'REJECT'>('FLAG'); + const [action, setAction] = useState('FLAG'); + const [ruleIntent, setRuleIntent] = useState('POSITIVE'); const [priority, setPriority] = useState(0); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); + function handleMatchTypeChange(type: RuleMatchType) { + setMatchType(type); + setMatchValue(''); + } + async function addRule() { if (!matchValue.trim()) return; - setBusy(true); - setError(''); + setBusy(true); setError(''); try { const res = await fetch('/api/rules', { method: 'POST', 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) { 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(); setRules((prev) => [...prev, created].sort((a, b) => a.priority - b.priority)); - setMatchValue(''); - setAction('FLAG'); - setPriority(0); - } finally { - setBusy(false); - } + setMatchValue(''); setAction('FLAG'); setRuleIntent('POSITIVE'); setPriority(0); + } finally { setBusy(false); } } async function toggleRule(rule: RuleData) { @@ -86,28 +105,69 @@ export function RuleManager({ initial }: { initial: RuleData[] }) { +
    - setMatchValue(e.target.value)} - placeholder={matchType === 'REACTION_EMOJI' ? '⭐' : '#important'} - className="border border-gray-300 rounded px-3 py-2 text-sm w-40" - /> + {matchType === 'REACTION_EMOJI' ? ( + + ) : ( + setMatchValue(e.target.value)} + placeholder={getValuePlaceholder(matchType)} + className="border border-gray-300 rounded px-3 py-2 text-sm w-40" + /> + )} + {matchType === 'INTEREST' && ( + Helps AI classify content into circles + )}
    + +
    + +
    + {(['POSITIVE', 'NEGATIVE'] as RuleIntent[]).map((i) => ( + + ))} +
    +
    +
    +
    +