diff --git a/apps/worker/src/core/approval.test.ts b/apps/worker/src/core/approval.test.ts new file mode 100644 index 0000000..d407499 --- /dev/null +++ b/apps/worker/src/core/approval.test.ts @@ -0,0 +1,148 @@ +import { handleStarReaction } from './approval'; +import { NormalizedReaction } from '@tower/types'; + +function makeReaction(overrides: Partial = {}): NormalizedReaction { + return { + reactorJid: '919876543210@s.whatsapp.net', + targetMsgId: 'TARGET_MSG_123', + sourceGroupJid: '120363043312345678@g.us', + emoji: '⭐', + accountId: 'acc_1', + ...overrides, + }; +} + +const adminJids = ['919876543210@s.whatsapp.net']; + +describe('handleStarReaction', () => { + it('returns null for non-star emoji', async () => { + const result = await handleStarReaction(makeReaction({ emoji: '👍' }), adminJids, {} as any); + expect(result).toBeNull(); + }); + + it('returns null when reactor is not an admin', async () => { + const result = await handleStarReaction( + makeReaction({ reactorJid: 'stranger@s.whatsapp.net' }), + adminJids, + {} as any, + ); + expect(result).toBeNull(); + }); + + it('returns null when message not found', async () => { + const prisma = { message: { findUnique: jest.fn().mockResolvedValue(null) } } as any; + const result = await handleStarReaction(makeReaction(), adminJids, prisma); + expect(result).toBeNull(); + expect(prisma.message.findUnique).toHaveBeenCalledWith({ + where: { + platform_platformMsgId: { platform: 'whatsapp', platformMsgId: 'TARGET_MSG_123' }, + }, + include: { + approval: true, + sourceGroup: { + include: { + syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } }, + }, + }, + }, + }); + }); + + it('returns null when message status is not PENDING', async () => { + const prisma = { + message: { + findUnique: jest.fn().mockResolvedValue({ + id: 'msg_1', + status: 'REJECTED', + approval: null, + sourceGroup: { name: 'Test Group', syncRoutesFrom: [] }, + }), + }, + } as any; + expect(await handleStarReaction(makeReaction(), adminJids, prisma)).toBeNull(); + }); + + it('returns null when message is already approved (approval record exists)', async () => { + const prisma = { + message: { + findUnique: jest.fn().mockResolvedValue({ + id: 'msg_1', + status: 'APPROVED', + approval: { id: 'appr_1' }, + sourceGroup: { name: 'Test Group', syncRoutesFrom: [] }, + }), + }, + } as any; + expect(await handleStarReaction(makeReaction(), adminJids, prisma)).toBeNull(); + }); + + it('approves message and returns empty array when no sync routes', async () => { + const prisma = { + message: { + findUnique: jest.fn().mockResolvedValue({ + id: 'msg_1', + status: 'PENDING', + approval: null, + content: 'hello', + senderName: 'Alice', + sourceGroup: { name: 'UP Parivar Dallas', syncRoutesFrom: [] }, + }), + update: jest.fn().mockResolvedValue({}), + }, + approval: { create: jest.fn().mockResolvedValue({}) }, + } as any; + + const result = await handleStarReaction(makeReaction(), adminJids, prisma); + expect(result).toEqual([]); + expect(prisma.message.update).toHaveBeenCalledWith({ + where: { id: 'msg_1' }, + data: { status: 'APPROVED' }, + }); + expect(prisma.approval.create).toHaveBeenCalledWith({ + data: { + messageId: 'msg_1', + adminId: '919876543210@s.whatsapp.net', + decision: 'APPROVED', + }, + }); + }); + + it('returns ForwardJobData for each active sync route', async () => { + const prisma = { + message: { + findUnique: jest.fn().mockResolvedValue({ + id: 'msg_1', + status: 'PENDING', + approval: null, + content: 'important announcement', + senderName: 'Bob', + sourceGroup: { + name: 'Source Group', + syncRoutesFrom: [ + { targetGroup: { platformId: '999@g.us', accountId: 'acc_2' } }, + { targetGroup: { platformId: '888@g.us', accountId: null } }, + ], + }, + }), + update: jest.fn().mockResolvedValue({}), + }, + approval: { create: jest.fn().mockResolvedValue({}) }, + } as any; + + const result = await handleStarReaction(makeReaction(), adminJids, prisma); + expect(result).toHaveLength(2); + expect(result![0]).toMatchObject({ + messageId: 'msg_1', + content: 'important announcement', + sourceGroupName: 'Source Group', + senderName: 'Bob', + toGroupJid: '999@g.us', + fromAccountId: 'acc_2', + }); + // falls back to reaction.accountId when targetGroup.accountId is null + expect(result![1]).toMatchObject({ + toGroupJid: '888@g.us', + fromAccountId: 'acc_1', + }); + }); +}); diff --git a/apps/worker/src/core/approval.ts b/apps/worker/src/core/approval.ts new file mode 100644 index 0000000..fb553c7 --- /dev/null +++ b/apps/worker/src/core/approval.ts @@ -0,0 +1,55 @@ +import { NormalizedReaction, ForwardJobData } from '@tower/types'; + +export async function handleStarReaction( + reaction: NormalizedReaction, + adminJids: string[], + prisma: any, +): Promise { + if (reaction.emoji !== '⭐') return null; + if (!adminJids.includes(reaction.reactorJid)) return null; + + const message = await prisma.message.findUnique({ + where: { + platform_platformMsgId: { + platform: 'whatsapp', + platformMsgId: reaction.targetMsgId, + }, + }, + include: { + approval: true, + sourceGroup: { + include: { + syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } }, + }, + }, + }, + }); + + if (!message) return null; + if (message.status !== 'PENDING') return null; + if (message.approval) return null; + + await prisma.message.update({ + where: { id: message.id }, + data: { status: 'APPROVED' }, + }); + + await prisma.approval.create({ + data: { + messageId: message.id, + adminId: reaction.reactorJid, + decision: 'APPROVED', + }, + }); + + const jobs: ForwardJobData[] = message.sourceGroup.syncRoutesFrom.map((route: any) => ({ + messageId: message.id, + content: message.content, + sourceGroupName: message.sourceGroup.name, + senderName: message.senderName ?? undefined, + toGroupJid: route.targetGroup.platformId, + fromAccountId: route.targetGroup.accountId ?? reaction.accountId, + })); + + return jobs; +}