good forst commit
This commit is contained in:
@@ -0,0 +1,45 @@
|
||||
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||
import { RolesGuard } from '../auth/roles.guard';
|
||||
import { Roles } from '../auth/roles.decorator';
|
||||
import { CurrentTenantContext } from '../auth/current-tenant.decorator';
|
||||
import { TenantContext } from '../../common/tenant-context';
|
||||
|
||||
@Controller('admin/messages')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles('OWNER', 'ADMIN')
|
||||
export class MessagesController {
|
||||
constructor(private readonly messagesService: MessagesService) {}
|
||||
|
||||
@Get('pending')
|
||||
listPending(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.messagesService.listPending(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Get('pending/count')
|
||||
pendingCount(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.messagesService.pendingCount(ctx.tenantId);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.messagesService.get(ctx.tenantId, id);
|
||||
}
|
||||
|
||||
@Post(':id/approve')
|
||||
approve(
|
||||
@CurrentTenantContext() ctx: TenantContext,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
return this.messagesService.approve(ctx.tenantId, ctx.adminId ?? '', id);
|
||||
}
|
||||
|
||||
@Post('reindex')
|
||||
reindex(@CurrentTenantContext() ctx: TenantContext) {
|
||||
return this.messagesService.reindexApproved(ctx.tenantId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { MessagesController } from './messages.controller';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { forwardQueueProvider, indexQueueProvider, FORWARD_QUEUE, INDEX_QUEUE } from '../../queues/forward.queue';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [MessagesController],
|
||||
providers: [
|
||||
MessagesService,
|
||||
forwardQueueProvider,
|
||||
indexQueueProvider,
|
||||
],
|
||||
exports: [FORWARD_QUEUE, INDEX_QUEUE],
|
||||
})
|
||||
export class MessagesModule {}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { ConflictException, NotFoundException } from '@nestjs/common';
|
||||
import { MessagesService } from './messages.service';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { FORWARD_QUEUE, INDEX_QUEUE } from '../../queues/forward.queue';
|
||||
|
||||
describe('MessagesService', () => {
|
||||
let service: MessagesService;
|
||||
const mockPrisma: any = {
|
||||
message: { findMany: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() },
|
||||
groupAccess: { findUnique: jest.fn() },
|
||||
approval: { create: jest.fn() },
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
const mockAudit = { log: jest.fn() };
|
||||
const mockForwardQueue = { add: jest.fn().mockResolvedValue({}) };
|
||||
const mockIndexQueue = { add: jest.fn().mockResolvedValue({}) };
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
MessagesService,
|
||||
{ provide: PrismaService, useValue: mockPrisma },
|
||||
{ provide: AuditService, useValue: mockAudit },
|
||||
{ provide: FORWARD_QUEUE, useValue: mockForwardQueue },
|
||||
{ provide: INDEX_QUEUE, useValue: mockIndexQueue },
|
||||
],
|
||||
}).compile();
|
||||
service = module.get<MessagesService>(MessagesService);
|
||||
});
|
||||
|
||||
describe('listPending', () => {
|
||||
it('returns PENDING messages with source group info', async () => {
|
||||
mockPrisma.message.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'msg-1',
|
||||
content: 'hello #important',
|
||||
senderJid: '111@s.whatsapp.net',
|
||||
senderName: 'Alice',
|
||||
tags: ['#important'],
|
||||
createdAt: new Date('2026-01-01T00:00:00Z'),
|
||||
sourceGroupId: 'grp-1',
|
||||
sourceGroup: { name: 'Notes', platformId: '111@g.us' },
|
||||
},
|
||||
]);
|
||||
const res = await service.listPending('tnt-1');
|
||||
expect(res).toHaveLength(1);
|
||||
expect(res[0].sourceGroupName).toBe('Notes');
|
||||
expect(mockPrisma.message.findMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
OR: [
|
||||
{ tenantId: 'tnt-1' },
|
||||
{ sourceGroup: { groupAccesses: { some: { tenantId: 'tnt-1' } } } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('approve', () => {
|
||||
const baseMessage = {
|
||||
id: 'msg-1',
|
||||
tenantId: 'tnt-1',
|
||||
content: 'hello #important',
|
||||
senderJid: '111@s.whatsapp.net',
|
||||
senderName: 'Alice',
|
||||
platform: 'whatsapp',
|
||||
tags: ['#important'],
|
||||
status: 'PENDING',
|
||||
sourceGroupId: 'grp-1',
|
||||
approval: null,
|
||||
sourceGroup: {
|
||||
name: 'Notes',
|
||||
accountId: 'acc-1',
|
||||
syncRoutesFrom: [
|
||||
{
|
||||
targetGroup: { platformId: '222@g.us', accountId: 'acc-1' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
it('marks APPROVED, enqueues forward + index, writes audit', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue(baseMessage);
|
||||
mockPrisma.message.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockPrisma.approval.create.mockResolvedValue({});
|
||||
mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma));
|
||||
|
||||
const res = await service.approve('tnt-1', 'adm-1', 'msg-1');
|
||||
expect(res.status).toBe('APPROVED');
|
||||
expect(res.routesForwarded).toBe(1);
|
||||
expect(res.indexEnqueued).toBe(true);
|
||||
expect(mockForwardQueue.add).toHaveBeenCalledWith(
|
||||
'forward',
|
||||
expect.objectContaining({ toGroupJid: '222@g.us', content: 'hello #important' }),
|
||||
expect.objectContaining({ attempts: 3 }),
|
||||
);
|
||||
expect(mockIndexQueue.add).toHaveBeenCalledWith('index', expect.any(Object), expect.any(Object));
|
||||
expect(mockAudit.log).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ action: 'MESSAGE_APPROVED' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects non-existent message', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue(null);
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'missing')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects message from a different tenant without group access', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue({ ...baseMessage, tenantId: 'tnt-other' });
|
||||
mockPrisma.groupAccess.findUnique.mockResolvedValue(null);
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(NotFoundException);
|
||||
});
|
||||
|
||||
it('rejects already-approved message', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue({ ...baseMessage, status: 'APPROVED' });
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('rejects when message has an existing Approval row', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue({ ...baseMessage, approval: { id: 'apr-1' } });
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(/already been approved/);
|
||||
});
|
||||
|
||||
it('returns routesForwarded=0 when no routes configured', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue({
|
||||
...baseMessage,
|
||||
sourceGroup: { ...baseMessage.sourceGroup, syncRoutesFrom: [] },
|
||||
});
|
||||
mockPrisma.message.updateMany.mockResolvedValue({ count: 1 });
|
||||
mockPrisma.approval.create.mockResolvedValue({});
|
||||
mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma));
|
||||
|
||||
const res = await service.approve('tnt-1', 'adm-1', 'msg-1');
|
||||
expect(res.routesForwarded).toBe(0);
|
||||
expect(mockForwardQueue.add).not.toHaveBeenCalled();
|
||||
expect(mockIndexQueue.add).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles concurrent approval (updateMany.count=0) as conflict', async () => {
|
||||
mockPrisma.message.findUnique.mockResolvedValue(baseMessage);
|
||||
mockPrisma.message.updateMany.mockResolvedValue({ count: 0 });
|
||||
mockPrisma.$transaction.mockImplementation(async (cb: any) => cb(mockPrisma));
|
||||
|
||||
await expect(service.approve('tnt-1', 'adm-1', 'msg-1')).rejects.toThrow(/concurrent update/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,254 @@
|
||||
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 { PrismaService } from '../../prisma/prisma.service';
|
||||
import { AuditService } from '../audit/audit.service';
|
||||
import { AuditAction } from '../audit/audit.types';
|
||||
import { createForwardQueue, createIndexQueue, FORWARD_QUEUE, INDEX_QUEUE } from '../../queues/forward.queue';
|
||||
|
||||
export interface PendingMessage {
|
||||
id: string;
|
||||
content: string;
|
||||
senderJid: string;
|
||||
senderName: string | null;
|
||||
tags: string[];
|
||||
createdAt: string;
|
||||
sourceGroupId: string;
|
||||
sourceGroupName: string;
|
||||
sourceGroupPlatformId: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MessagesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly audit: AuditService,
|
||||
@Inject(FORWARD_QUEUE) private readonly forwardQueue: Queue<ForwardJobData>,
|
||||
@Inject(INDEX_QUEUE) private readonly indexQueue: Queue<IndexJobData>,
|
||||
) {}
|
||||
|
||||
async get(tenantId: string, id: string): Promise<any> {
|
||||
const msg = await this.prisma.message.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
sourceGroup: true,
|
||||
senderTowerUser: true,
|
||||
approval: true,
|
||||
},
|
||||
});
|
||||
if (!msg) throw new NotFoundException('Message not found');
|
||||
if (msg.tenantId !== tenantId) {
|
||||
const access = await this.prisma.groupAccess.findUnique({
|
||||
where: { groupId_tenantId: { groupId: msg.sourceGroupId, tenantId } },
|
||||
});
|
||||
if (!access) throw new NotFoundException('Message not found');
|
||||
}
|
||||
return {
|
||||
id: msg.id,
|
||||
tenantId: msg.tenantId,
|
||||
platform: msg.platform,
|
||||
platformMsgId: msg.platformMsgId,
|
||||
sourceGroupId: msg.sourceGroupId,
|
||||
sourceGroup: msg.sourceGroup,
|
||||
senderJid: msg.senderJid,
|
||||
senderName: msg.senderName,
|
||||
senderTowerUser: msg.senderTowerUser,
|
||||
content: msg.content,
|
||||
mediaUrl: msg.mediaUrl,
|
||||
tags: msg.tags,
|
||||
status: msg.status,
|
||||
createdAt: msg.createdAt.toISOString(),
|
||||
updatedAt: msg.updatedAt.toISOString(),
|
||||
approval: msg.approval
|
||||
? {
|
||||
id: msg.approval.id,
|
||||
adminId: msg.approval.adminId,
|
||||
decision: msg.approval.decision,
|
||||
notes: msg.approval.notes,
|
||||
decidedAt: msg.approval.decidedAt.toISOString(),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
async pendingCount(tenantId: string): Promise<{ count: number }> {
|
||||
const count = await this.prisma.message.count({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{ sourceGroup: { groupAccesses: { some: { tenantId } } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
return { count };
|
||||
}
|
||||
|
||||
async listPending(tenantId: string): Promise<PendingMessage[]> {
|
||||
const rows = await this.prisma.message.findMany({
|
||||
where: {
|
||||
status: 'PENDING',
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{ sourceGroup: { groupAccesses: { some: { tenantId } } } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: {
|
||||
sourceGroup: { select: { name: true, platformId: true } },
|
||||
},
|
||||
});
|
||||
return rows.map((m: any) => ({
|
||||
id: m.id,
|
||||
content: m.content,
|
||||
senderJid: m.senderJid,
|
||||
senderName: m.senderName,
|
||||
tags: m.tags ?? [],
|
||||
createdAt: m.createdAt.toISOString(),
|
||||
sourceGroupId: m.sourceGroupId,
|
||||
sourceGroupName: m.sourceGroup?.name ?? '(unknown group)',
|
||||
sourceGroupPlatformId: m.sourceGroup?.platformId ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
async approve(tenantId: string, adminId: string, messageId: string): Promise<{ id: string; status: string; routesForwarded: number; indexEnqueued: boolean }> {
|
||||
const message = await this.prisma.message.findUnique({
|
||||
where: { id: messageId },
|
||||
include: {
|
||||
approval: true,
|
||||
sourceGroup: {
|
||||
include: {
|
||||
syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!message) throw new NotFoundException('Message not found');
|
||||
if (message.tenantId !== tenantId) {
|
||||
const access = await this.prisma.groupAccess.findUnique({
|
||||
where: { groupId_tenantId: { groupId: message.sourceGroupId, tenantId } },
|
||||
});
|
||||
if (!access) throw new NotFoundException('Message not found');
|
||||
}
|
||||
if (message.status !== 'PENDING') {
|
||||
throw new ConflictException(`Message is already ${message.status}`);
|
||||
}
|
||||
if (message.approval) {
|
||||
throw new ConflictException('Message has already been approved');
|
||||
}
|
||||
|
||||
let approved = false;
|
||||
await this.prisma.$transaction(async (tx: any) => {
|
||||
const updated = await tx.message.updateMany({
|
||||
where: { id: message.id, status: 'PENDING' },
|
||||
data: { status: 'APPROVED' },
|
||||
});
|
||||
if (updated.count === 0) return;
|
||||
approved = true;
|
||||
await tx.approval.create({
|
||||
data: {
|
||||
tenantId: message.tenantId,
|
||||
messageId: message.id,
|
||||
adminId,
|
||||
decision: 'APPROVED',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
if (!approved) {
|
||||
throw new ConflictException('Message could not be approved (concurrent update)');
|
||||
}
|
||||
|
||||
const validRoutes = (message.sourceGroup?.syncRoutesFrom ?? []).filter(
|
||||
(r: any) => r.targetGroup != null,
|
||||
);
|
||||
const forwardJobs: ForwardJobData[] = validRoutes.map((route: any) => ({
|
||||
tenantId: message.tenantId,
|
||||
messageId: message.id,
|
||||
content: message.content,
|
||||
sourceGroupName: message.sourceGroup.name,
|
||||
senderName: message.senderName ?? undefined,
|
||||
toGroupJid: route.targetGroup.platformId,
|
||||
fromAccountId: route.targetGroup.accountId ?? message.sourceGroup.accountId ?? '',
|
||||
}));
|
||||
|
||||
for (const job of forwardJobs) {
|
||||
await this.forwardQueue.add('forward', job, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 2000 },
|
||||
});
|
||||
}
|
||||
|
||||
const indexDoc: IndexJobData = {
|
||||
tenantId: message.tenantId,
|
||||
messageId: message.id,
|
||||
content: message.content,
|
||||
senderName: message.senderName ?? null,
|
||||
sourceGroupId: message.sourceGroupId,
|
||||
sourceGroupName: message.sourceGroup.name,
|
||||
tags: message.tags ?? [],
|
||||
platform: message.platform,
|
||||
approvedAt: new Date().toISOString(),
|
||||
};
|
||||
await this.indexQueue.add('index', indexDoc, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 1000 },
|
||||
});
|
||||
|
||||
await this.audit.log({
|
||||
tenantId,
|
||||
actorId: adminId,
|
||||
action: AuditAction.MESSAGE_APPROVED,
|
||||
resourceType: 'Message',
|
||||
resourceId: message.id,
|
||||
payload: {
|
||||
routesForwarded: forwardJobs.length,
|
||||
contentPreview: message.content.slice(0, 80),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: message.id,
|
||||
status: 'APPROVED',
|
||||
routesForwarded: forwardJobs.length,
|
||||
indexEnqueued: true,
|
||||
};
|
||||
}
|
||||
|
||||
async reindexApproved(tenantId: string): Promise<{ reindexed: number }> {
|
||||
const messages = await this.prisma.message.findMany({
|
||||
where: {
|
||||
status: 'APPROVED',
|
||||
OR: [
|
||||
{ tenantId },
|
||||
{ sourceGroup: { groupAccesses: { some: { tenantId } } } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
sourceGroup: { select: { name: true } },
|
||||
approval: { select: { decidedAt: true } },
|
||||
},
|
||||
});
|
||||
|
||||
for (const msg of messages) {
|
||||
const indexDoc: IndexJobData = {
|
||||
tenantId: msg.tenantId,
|
||||
messageId: msg.id,
|
||||
content: msg.content,
|
||||
senderName: msg.senderName ?? null,
|
||||
sourceGroupId: msg.sourceGroupId,
|
||||
sourceGroupName: msg.sourceGroup?.name ?? '(unknown)',
|
||||
tags: msg.tags ?? [],
|
||||
platform: msg.platform,
|
||||
approvedAt: (msg.approval?.decidedAt ?? msg.updatedAt).toISOString(),
|
||||
};
|
||||
await this.indexQueue.add('index', indexDoc, {
|
||||
attempts: 3,
|
||||
backoff: { type: 'exponential', delay: 1000 },
|
||||
});
|
||||
}
|
||||
|
||||
return { reindexed: messages.length };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user