fix(worker): atomic approval via transaction, guard null targetGroup
Wrap message.update + approval.create in a $transaction using updateMany with a PENDING status guard to prevent duplicate approvals and audit gaps. Filter out null targetGroup routes to prevent runtime errors on DB inconsistency. Add TODO comment for multi-platform support and fallback accountId comment. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -87,24 +87,17 @@ describe('handleStarReaction', () => {
|
|||||||
senderName: 'Alice',
|
senderName: 'Alice',
|
||||||
sourceGroup: { name: 'UP Parivar Dallas', syncRoutesFrom: [] },
|
sourceGroup: { name: 'UP Parivar Dallas', syncRoutesFrom: [] },
|
||||||
}),
|
}),
|
||||||
update: jest.fn().mockResolvedValue({}),
|
|
||||||
},
|
},
|
||||||
approval: { create: jest.fn().mockResolvedValue({}) },
|
approval: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
$transaction: jest.fn().mockImplementation(async (fn: any) => fn({
|
||||||
|
message: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||||
|
approval: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
})),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await handleStarReaction(makeReaction(), adminJids, prisma);
|
const result = await handleStarReaction(makeReaction(), adminJids, prisma);
|
||||||
expect(result).toEqual([]);
|
expect(result).toEqual([]);
|
||||||
expect(prisma.message.update).toHaveBeenCalledWith({
|
expect(prisma.$transaction).toHaveBeenCalled();
|
||||||
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 () => {
|
it('returns ForwardJobData for each active sync route', async () => {
|
||||||
@@ -124,9 +117,12 @@ describe('handleStarReaction', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
update: jest.fn().mockResolvedValue({}),
|
|
||||||
},
|
},
|
||||||
approval: { create: jest.fn().mockResolvedValue({}) },
|
approval: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
$transaction: jest.fn().mockImplementation(async (fn: any) => fn({
|
||||||
|
message: { updateMany: jest.fn().mockResolvedValue({ count: 1 }) },
|
||||||
|
approval: { create: jest.fn().mockResolvedValue({}) },
|
||||||
|
})),
|
||||||
} as any;
|
} as any;
|
||||||
|
|
||||||
const result = await handleStarReaction(makeReaction(), adminJids, prisma);
|
const result = await handleStarReaction(makeReaction(), adminJids, prisma);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export async function handleStarReaction(
|
|||||||
const message = await prisma.message.findUnique({
|
const message = await prisma.message.findUnique({
|
||||||
where: {
|
where: {
|
||||||
platform_platformMsgId: {
|
platform_platformMsgId: {
|
||||||
|
// TODO: derive platform from NormalizedReaction when multi-platform support is added
|
||||||
platform: 'whatsapp',
|
platform: 'whatsapp',
|
||||||
platformMsgId: reaction.targetMsgId,
|
platformMsgId: reaction.targetMsgId,
|
||||||
},
|
},
|
||||||
@@ -29,27 +30,32 @@ export async function handleStarReaction(
|
|||||||
if (message.status !== 'PENDING') return null;
|
if (message.status !== 'PENDING') return null;
|
||||||
if (message.approval) return null;
|
if (message.approval) return null;
|
||||||
|
|
||||||
await prisma.message.update({
|
await prisma.$transaction(async (tx: any) => {
|
||||||
where: { id: message.id },
|
const updated = await tx.message.updateMany({
|
||||||
data: { status: 'APPROVED' },
|
where: { id: message.id, status: 'PENDING' },
|
||||||
|
data: { status: 'APPROVED' },
|
||||||
|
});
|
||||||
|
if (updated.count === 0) return; // another admin approved first — idempotent
|
||||||
|
await tx.approval.create({
|
||||||
|
data: {
|
||||||
|
messageId: message.id,
|
||||||
|
adminId: reaction.reactorJid,
|
||||||
|
decision: 'APPROVED',
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await prisma.approval.create({
|
const jobs: ForwardJobData[] = message.sourceGroup.syncRoutesFrom
|
||||||
data: {
|
.filter((route: any) => route.targetGroup != null)
|
||||||
|
.map((route: any) => ({
|
||||||
messageId: message.id,
|
messageId: message.id,
|
||||||
adminId: reaction.reactorJid,
|
content: message.content,
|
||||||
decision: 'APPROVED',
|
sourceGroupName: message.sourceGroup.name,
|
||||||
},
|
senderName: message.senderName ?? undefined,
|
||||||
});
|
toGroupJid: route.targetGroup.platformId,
|
||||||
|
// fallback: use the account that received the reaction when target group has no assigned account
|
||||||
const jobs: ForwardJobData[] = message.sourceGroup.syncRoutesFrom.map((route: any) => ({
|
fromAccountId: route.targetGroup.accountId ?? reaction.accountId,
|
||||||
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;
|
return jobs;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user