good forst commit

This commit is contained in:
2026-06-09 02:02:40 +05:30
parent 801c1d7121
commit 249d759e6a
215 changed files with 15425 additions and 1240 deletions
@@ -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 };
}
}