feat: Refactor duplicate handling in InterestDuplicateNotificationBanner and update merge API for better access control

This commit is contained in:
Matt 2025-07-09 16:11:37 -04:00
parent 85ec5100f3
commit bf24dc9103
4 changed files with 223 additions and 22 deletions

View File

@ -1,6 +1,6 @@
<template> <template>
<!-- Debug: Always show banner for testing -->
<v-alert <v-alert
v-if="showBanner && duplicateCount > 0"
type="warning" type="warning"
variant="tonal" variant="tonal"
closable closable
@ -38,7 +38,7 @@
const { hasRole } = useAuthorization(); const { hasRole } = useAuthorization();
const showBanner = ref(true); const showBanner = ref(true);
const duplicateCount = ref(3); // Test value - should show obvious duplicates const duplicateCount = ref(0);
const loading = ref(false); const loading = ref(false);
// Check for duplicates on mount (sales/admin users) // Check for duplicates on mount (sales/admin users)
@ -72,8 +72,7 @@ const checkForDuplicates = async () => {
} }
} catch (error) { } catch (error) {
console.error('[InterestDuplicateNotification] Failed to check for duplicates:', error); console.error('[InterestDuplicateNotification] Failed to check for duplicates:', error);
// For debugging, let's set a test count to see if banner shows // Silently fail - this is just a notification banner
duplicateCount.value = 2; // Test value to see if banner appears
} finally { } finally {
loading.value = false; loading.value = false;
} }

View File

@ -4,7 +4,7 @@
v-model="drawer" v-model="drawer"
:location="mdAndDown ? 'bottom' : undefined" :location="mdAndDown ? 'bottom' : undefined"
> >
<v-img v-if="!mdAndDown" src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="75" class="my-6" /> <v-img v-if="!mdAndDown" src="/Port_Nimara_Logo_2_Colour_New_Transparent.png" height="110" class="my-6" contain />
<v-list color="primary" lines="two"> <v-list color="primary" lines="two">
<v-list-item <v-list-item

View File

@ -63,12 +63,12 @@
<v-row> <v-row>
<v-col cols="6"> <v-col cols="6">
<strong>Name:</strong> {{ group.master['Full Name'] }}<br> <strong>Name:</strong> {{ group.master['Full Name'] }}<br>
<strong>Email:</strong> {{ group.master['Email'] || 'N/A' }}<br> <strong>Email:</strong> {{ group.master['Email Address'] || 'N/A' }}<br>
<strong>Phone:</strong> {{ group.master['Phone'] || 'N/A' }} <strong>Phone:</strong> {{ group.master['Phone Number'] || 'N/A' }}
</v-col> </v-col>
<v-col cols="6"> <v-col cols="6">
<strong>Address:</strong> {{ group.master['Address'] || 'N/A' }}<br> <strong>Address:</strong> {{ group.master['Address'] || 'N/A' }}<br>
<strong>Created:</strong> {{ group.master['CreatedAt'] || 'N/A' }}<br> <strong>Created:</strong> {{ group.master['Created At'] || 'N/A' }}<br>
<strong>ID:</strong> {{ group.master.Id }} <strong>ID:</strong> {{ group.master.Id }}
</v-col> </v-col>
</v-row> </v-row>
@ -87,12 +87,12 @@
<v-row> <v-row>
<v-col cols="6"> <v-col cols="6">
<strong>Name:</strong> {{ duplicate.record['Full Name'] }}<br> <strong>Name:</strong> {{ duplicate.record['Full Name'] }}<br>
<strong>Email:</strong> {{ duplicate.record['Email'] || 'N/A' }}<br> <strong>Email:</strong> {{ duplicate.record['Email Address'] || 'N/A' }}<br>
<strong>Phone:</strong> {{ duplicate.record['Phone'] || 'N/A' }} <strong>Phone:</strong> {{ duplicate.record['Phone Number'] || 'N/A' }}
</v-col> </v-col>
<v-col cols="6"> <v-col cols="6">
<strong>Address:</strong> {{ duplicate.record['Address'] || 'N/A' }}<br> <strong>Address:</strong> {{ duplicate.record['Address'] || 'N/A' }}<br>
<strong>Created:</strong> {{ duplicate.record['CreatedAt'] || 'N/A' }}<br> <strong>Created:</strong> {{ duplicate.record['Created At'] || 'N/A' }}<br>
<strong>Similarity:</strong> {{ Math.round(duplicate.similarity) }}% <strong>Similarity:</strong> {{ Math.round(duplicate.similarity) }}%
</v-col> </v-col>
</v-row> </v-row>
@ -134,14 +134,15 @@ const error = ref('');
const hasScanned = ref(false); const hasScanned = ref(false);
const threshold = ref(80); const threshold = ref(80);
const { isAdmin } = useAuthorization(); const { hasRole } = useAuthorization();
// Check admin access // Check sales or admin access
onMounted(() => { onMounted(async () => {
if (!isAdmin()) { const canAccess = await hasRole(['sales', 'admin']);
if (!canAccess) {
throw createError({ throw createError({
statusCode: 403, statusCode: 403,
statusMessage: 'Access denied. Admin privileges required.' statusMessage: 'Access denied. Sales or admin privileges required.'
}); });
} }
}); });
@ -152,15 +153,27 @@ const findDuplicates = async () => {
duplicateGroups.value = []; duplicateGroups.value = [];
try { try {
const response = await $fetch('/api/admin/duplicates/find', { const response = await $fetch('/api/interests/duplicates/find', {
method: 'POST', method: 'GET',
body: { query: {
threshold: threshold.value threshold: threshold.value / 100, // Convert percentage to decimal
dateRange: 365
} }
}); });
if (response.success) { if (response.success) {
duplicateGroups.value = response.data.duplicateGroups || []; // Convert the new API format to the format expected by the UI
const convertedGroups = response.data.duplicateGroups.map(group => ({
master: group.masterCandidate,
duplicates: group.interests.filter(interest => interest.Id !== group.masterCandidate.Id).map(interest => ({
record: interest,
similarity: group.confidence * 100 // Convert back to percentage
})),
similarity: group.confidence * 100,
matchReason: group.matchReason
}));
duplicateGroups.value = convertedGroups;
hasScanned.value = true; hasScanned.value = true;
} else { } else {
error.value = response.error || 'Failed to find duplicates'; error.value = response.error || 'Failed to find duplicates';
@ -179,7 +192,7 @@ const mergeDuplicates = async (group, index) => {
try { try {
const duplicateIds = group.duplicates.map(d => d.record.Id); const duplicateIds = group.duplicates.map(d => d.record.Id);
const response = await $fetch('/api/admin/duplicates/merge', { const response = await $fetch('/api/interests/duplicates/merge', {
method: 'POST', method: 'POST',
body: { body: {
masterId: group.master.Id, masterId: group.master.Id,

View File

@ -0,0 +1,189 @@
import { requireSalesOrAdmin } from '~/server/utils/auth';
import { getNocoDbConfiguration } from '~/server/utils/nocodb';
import { logAuditEvent } from '~/server/utils/audit-logger';
export default defineEventHandler(async (event) => {
console.log('[INTERESTS] Merge duplicates request');
let body: any;
try {
// Require sales or admin access
const session = await requireSalesOrAdmin(event);
const userId = session.user?.id || 'unknown';
const userEmail = session.user?.email || 'unknown';
body = await readBody(event);
const { masterId, duplicateIds, mergeData } = body;
if (!masterId || !duplicateIds || !Array.isArray(duplicateIds) || duplicateIds.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'Invalid merge request. Master ID and duplicate IDs required.'
});
}
console.log('[INTERESTS] Merging', duplicateIds.length, 'duplicates into master interest', masterId);
// Get NocoDB configuration
const config = getNocoDbConfiguration();
const interestTableId = "mbs9hjauug4eseo"; // Interest table ID
// First, get all interests involved
const allIds = [masterId, ...duplicateIds];
const interestsToMerge: any[] = [];
for (const id of allIds) {
try {
const interest = await $fetch(`${config.url}/api/v2/tables/${interestTableId}/records/${id}`, {
headers: {
'xc-token': config.token
}
});
interestsToMerge.push(interest);
} catch (error) {
console.error(`[INTERESTS] Failed to fetch interest ${id}:`, error);
}
}
if (interestsToMerge.length < 2) {
throw createError({
statusCode: 404,
statusMessage: 'Could not find enough interests to merge'
});
}
const masterInterest = interestsToMerge.find(i => i.Id === parseInt(masterId));
if (!masterInterest) {
throw createError({
statusCode: 404,
statusMessage: 'Master interest not found'
});
}
// Log the action before making changes
await logAuditEvent(event, 'MERGE_INTEREST_DUPLICATES', 'interest', {
resourceId: masterId,
changes: {
duplicateIds,
mergeData,
originalInterests: interestsToMerge
}
});
// Update master interest with merged data if provided
if (mergeData && Object.keys(mergeData).length > 0) {
const updateData: any = { Id: parseInt(masterId) };
// Copy allowed fields
const allowedFields = [
'Full Name',
'Email Address',
'Phone Number',
'Address',
'Extra Comments',
'Berth Size Desired',
'Sales Process Level',
'EOI Status',
'Contract Status',
'Lead Category'
];
allowedFields.forEach(field => {
if (mergeData[field] !== undefined) {
updateData[field] = mergeData[field];
}
});
if (Object.keys(updateData).length > 1) { // More than just Id
console.log('[INTERESTS] Updating master interest with:', updateData);
await $fetch(`${config.url}/api/v2/tables/${interestTableId}/records`, {
method: 'PATCH',
headers: {
'xc-token': config.token,
'Content-Type': 'application/json'
},
body: updateData
});
}
}
// Delete duplicate interests
const deleteResults = [];
for (const duplicateId of duplicateIds) {
try {
console.log('[INTERESTS] Deleting duplicate interest:', duplicateId);
await $fetch(`${config.url}/api/v2/tables/${interestTableId}/records`, {
method: 'DELETE',
headers: {
'xc-token': config.token,
'Content-Type': 'application/json'
},
body: { Id: parseInt(duplicateId) }
});
deleteResults.push({ id: duplicateId, success: true });
} catch (error: any) {
console.error('[INTERESTS] Failed to delete duplicate:', duplicateId, error);
deleteResults.push({
id: duplicateId,
success: false,
error: error.message || 'Delete failed'
});
}
}
// Check if all deletes were successful
const failedDeletes = deleteResults.filter(r => !r.success);
if (failedDeletes.length > 0) {
console.warn('[INTERESTS] Some duplicates could not be deleted:', failedDeletes);
}
// Log successful completion
await logAuditEvent(event, 'MERGE_INTEREST_DUPLICATES_COMPLETE', 'interest', {
resourceId: masterId,
changes: {
deletedCount: deleteResults.filter(r => r.success).length,
failedDeletes,
mergeData
},
status: 'success'
});
return {
success: true,
data: {
masterId,
mergedData: mergeData,
deletedCount: deleteResults.filter(r => r.success).length,
deleteResults,
message: `Successfully merged ${deleteResults.filter(r => r.success).length} duplicates into interest ${masterId}`
}
};
} catch (error: any) {
console.error('[INTERESTS] Failed to merge duplicates:', error);
// Log the failure
await logAuditEvent(event, 'MERGE_INTEREST_DUPLICATES_FAILED', 'interest', {
resourceId: body?.masterId || 'unknown',
changes: {
error: error.message || 'Unknown error',
requestBody: body
},
status: 'failure',
errorMessage: error.message || 'Unknown error'
});
if (error.statusCode) {
throw error;
}
throw createError({
statusCode: 500,
statusMessage: 'Failed to merge duplicate interests'
});
}
});