From 280a27cc2f4d00e393cdab0d3c4400bb797a2f2f Mon Sep 17 00:00:00 2001 From: Matt Date: Wed, 9 Jul 2025 11:59:06 -0400 Subject: [PATCH] Add admin console with role-based navigation and enhanced API auth - Add conditional admin menu items to dashboard based on user permissions - Upgrade expense API authorization from basic auth to sales/admin roles - Convert static menu arrays to computed properties for dynamic content - Add admin duplicates API endpoint structure --- pages/dashboard.vue | 144 +++++++++------- server/api/admin/duplicates/find.ts | 259 ++++++++++++++++++++++++++++ server/api/get-expenses.ts | 182 +++++++++++-------- 3 files changed, 457 insertions(+), 128 deletions(-) create mode 100644 server/api/admin/duplicates/find.ts diff --git a/pages/dashboard.vue b/pages/dashboard.vue index 2f50293..400844d 100644 --- a/pages/dashboard.vue +++ b/pages/dashboard.vue @@ -72,71 +72,97 @@ definePageMeta({ const { mdAndDown } = useDisplay(); const { user, logout, authSource } = useUnifiedAuth(); +const { isAdmin } = useAuthorization(); const tags = usePortalTags(); const drawer = ref(false); -const interestMenu = [ - //{ - // to: "/dashboard/interest-eoi-queue", - // icon: "mdi-tray-full", - // title: "EOI Queue", - //}, - { - to: "/dashboard/interest-analytics", - icon: "mdi-view-dashboard", - title: "Analytics", - }, - { - to: "/dashboard/interest-berth-list", - icon: "mdi-table", - title: "Berth List", - }, - { - to: "/dashboard/interest-berth-status", - icon: "mdi-sail-boat", - title: "Berth Status", - }, - { - to: "/dashboard/interest-list", - icon: "mdi-view-list", - title: "Interest List", - }, - { - to: "/dashboard/interest-status", - icon: "mdi-account-check", - title: "Interest Status", - }, - { - to: "/dashboard/expenses", - icon: "mdi-receipt", - title: "Expenses", - }, - { - to: "/dashboard/file-browser", - icon: "mdi-folder", - title: "File Browser", - }, +const interestMenu = computed(() => { + const baseMenu = [ + //{ + // to: "/dashboard/interest-eoi-queue", + // icon: "mdi-tray-full", + // title: "EOI Queue", + //}, + { + to: "/dashboard/interest-analytics", + icon: "mdi-view-dashboard", + title: "Analytics", + }, + { + to: "/dashboard/interest-berth-list", + icon: "mdi-table", + title: "Berth List", + }, + { + to: "/dashboard/interest-berth-status", + icon: "mdi-sail-boat", + title: "Berth Status", + }, + { + to: "/dashboard/interest-list", + icon: "mdi-view-list", + title: "Interest List", + }, + { + to: "/dashboard/interest-status", + icon: "mdi-account-check", + title: "Interest Status", + }, + { + to: "/dashboard/expenses", + icon: "mdi-receipt", + title: "Expenses", + }, + { + to: "/dashboard/file-browser", + icon: "mdi-folder", + title: "File Browser", + }, + ]; -]; + // Add admin menu items if user is admin + if (isAdmin()) { + baseMenu.push({ + to: "/dashboard/admin", + icon: "mdi-shield-crown", + title: "Admin Console", + }); + } -const defaultMenu = [ - { - to: "/dashboard/site", - icon: "mdi-view-dashboard", - title: "Site Analytics", - }, - { - to: "/dashboard/data", - icon: "mdi-finance", - title: "Data Analytics", - }, - { - to: "/dashboard/file-browser", - icon: "mdi-folder", - title: "File Browser", - }, -]; + return baseMenu; +}); + +const defaultMenu = computed(() => { + const baseMenu = [ + { + to: "/dashboard/site", + icon: "mdi-view-dashboard", + title: "Site Analytics", + }, + { + to: "/dashboard/data", + icon: "mdi-finance", + title: "Data Analytics", + }, + { + to: "/dashboard/file-browser", + icon: "mdi-folder", + title: "File Browser", + }, + ]; + + // Add admin menu items if user is admin + if (isAdmin()) { + baseMenu.push({ + to: "/dashboard/admin", + icon: "mdi-shield-crown", + title: "Admin Console", + }); + } + + return baseMenu; +}); const menu = computed(() => toValue(tags).interest ? interestMenu : defaultMenu diff --git a/server/api/admin/duplicates/find.ts b/server/api/admin/duplicates/find.ts new file mode 100644 index 0000000..5e701bc --- /dev/null +++ b/server/api/admin/duplicates/find.ts @@ -0,0 +1,259 @@ +import { requireAdmin } from '~/server/utils/auth'; +import { getNocoDbConfiguration } from '~/server/utils/nocodb'; + +export default defineEventHandler(async (event) => { + console.log('[ADMIN] Find duplicates request'); + + try { + // Require admin authentication + await requireAdmin(event); + + const query = getQuery(event); + const threshold = query.threshold ? parseFloat(query.threshold as string) : 0.8; + + // Get all interests from NocoDB + const config = getNocoDbConfiguration(); + const interestTableId = "mbs9hjauug4eseo"; // Interest table ID from nocodb.ts + const response = await $fetch(`${config.url}/api/v2/tables/${interestTableId}/records`, { + headers: { + 'xc-token': config.token + }, + params: { + limit: 5000 // Get a large batch for duplicate detection + } + }) as any; + + const interests = response.list || []; + console.log('[ADMIN] Analyzing', interests.length, 'interests for duplicates'); + + // Find potential duplicates + const duplicateGroups = findDuplicateInterests(interests, threshold); + + console.log('[ADMIN] Found', duplicateGroups.length, 'duplicate groups'); + + return { + success: true, + data: { + duplicateGroups, + totalInterests: interests.length, + duplicateCount: duplicateGroups.reduce((sum, group) => sum + group.interests.length, 0), + threshold + } + }; + + } catch (error: any) { + console.error('[ADMIN] Failed to find duplicates:', error); + + if (error.statusCode === 403) { + return { + success: false, + error: 'Insufficient permissions. Admin access required.' + }; + } + + return { + success: false, + error: 'Failed to find duplicates' + }; + } +}); + +/** + * Find duplicate interests based on multiple criteria + */ +function findDuplicateInterests(interests: any[], threshold: number = 0.8) { + const duplicateGroups: Array<{ + id: string; + interests: any[]; + matchReason: string; + confidence: number; + masterCandidate: any; + }> = []; + + const processedIds = new Set(); + + for (let i = 0; i < interests.length; i++) { + const interest1 = interests[i]; + + if (processedIds.has(interest1.Id)) continue; + + const matches = [interest1]; + + for (let j = i + 1; j < interests.length; j++) { + const interest2 = interests[j]; + + if (processedIds.has(interest2.Id)) continue; + + const similarity = calculateSimilarity(interest1, interest2); + + if (similarity.score >= threshold) { + matches.push(interest2); + processedIds.add(interest2.Id); + } + } + + if (matches.length > 1) { + // Mark all as processed + matches.forEach(match => processedIds.add(match.Id)); + + // Determine the best master candidate (most complete record) + const masterCandidate = selectMasterCandidate(matches); + + duplicateGroups.push({ + id: `group_${duplicateGroups.length + 1}`, + interests: matches, + matchReason: 'Multiple matching criteria', + confidence: Math.max(...matches.slice(1).map(match => + calculateSimilarity(masterCandidate, match).score + )), + masterCandidate + }); + } + } + + return duplicateGroups; +} + +/** + * Calculate similarity between two interests + */ +function calculateSimilarity(interest1: any, interest2: any) { + const scores: Array<{ type: string; score: number; weight: number }> = []; + + // Email similarity (highest weight) + if (interest1.Email && interest2.Email) { + const emailScore = interest1.Email.toLowerCase() === interest2.Email.toLowerCase() ? 1.0 : 0.0; + scores.push({ type: 'email', score: emailScore, weight: 0.4 }); + } + + // Phone similarity + if (interest1.Phone && interest2.Phone) { + const phone1 = normalizePhone(interest1.Phone); + const phone2 = normalizePhone(interest2.Phone); + const phoneScore = phone1 === phone2 ? 1.0 : 0.0; + scores.push({ type: 'phone', score: phoneScore, weight: 0.3 }); + } + + // Name similarity + if (interest1.Name && interest2.Name) { + const nameScore = calculateNameSimilarity(interest1.Name, interest2.Name); + scores.push({ type: 'name', score: nameScore, weight: 0.2 }); + } + + // Address similarity + if (interest1.Address && interest2.Address) { + const addressScore = calculateStringSimilarity(interest1.Address, interest2.Address); + scores.push({ type: 'address', score: addressScore, weight: 0.1 }); + } + + // Calculate weighted average + const totalWeight = scores.reduce((sum, s) => sum + s.weight, 0); + const weightedScore = scores.reduce((sum, s) => sum + (s.score * s.weight), 0) / (totalWeight || 1); + + return { + score: weightedScore, + details: scores + }; +} + +/** + * Normalize phone number for comparison + */ +function normalizePhone(phone: string): string { + return phone.replace(/\D/g, ''); // Remove all non-digits +} + +/** + * Calculate name similarity using Levenshtein distance + */ +function calculateNameSimilarity(name1: string, name2: string): number { + const str1 = name1.toLowerCase().trim(); + const str2 = name2.toLowerCase().trim(); + + if (str1 === str2) return 1.0; + + const distance = levenshteinDistance(str1, str2); + const maxLength = Math.max(str1.length, str2.length); + + return maxLength > 0 ? 1 - (distance / maxLength) : 0; +} + +/** + * Calculate string similarity using Levenshtein distance + */ +function calculateStringSimilarity(str1: string, str2: string): number { + const s1 = str1.toLowerCase().trim(); + const s2 = str2.toLowerCase().trim(); + + if (s1 === s2) return 1.0; + + const distance = levenshteinDistance(s1, s2); + const maxLength = Math.max(s1.length, s2.length); + + return maxLength > 0 ? 1 - (distance / maxLength) : 0; +} + +/** + * Calculate Levenshtein distance between two strings + */ +function levenshteinDistance(str1: string, str2: string): number { + const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null)); + + for (let i = 0; i <= str1.length; i += 1) { + matrix[0][i] = i; + } + + for (let j = 0; j <= str2.length; j += 1) { + matrix[j][0] = j; + } + + for (let j = 1; j <= str2.length; j += 1) { + for (let i = 1; i <= str1.length; i += 1) { + const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1; + matrix[j][i] = Math.min( + matrix[j][i - 1] + 1, // deletion + matrix[j - 1][i] + 1, // insertion + matrix[j - 1][i - 1] + indicator // substitution + ); + } + } + + return matrix[str2.length][str1.length]; +} + +/** + * Select the best master candidate from a group of duplicates + */ +function selectMasterCandidate(interests: any[]) { + return interests.reduce((best, current) => { + const bestScore = calculateCompletenessScore(best); + const currentScore = calculateCompletenessScore(current); + + return currentScore > bestScore ? current : best; + }); +} + +/** + * Calculate completeness score for an interest record + */ +function calculateCompletenessScore(interest: any): number { + const fields = ['Name', 'Email', 'Phone', 'Address', 'Comments', 'BerthRequirements']; + const filledFields = fields.filter(field => + interest[field] && interest[field].toString().trim().length > 0 + ); + + let score = filledFields.length / fields.length; + + // Bonus for recent creation + if (interest.CreatedAt) { + const created = new Date(interest.CreatedAt); + const now = new Date(); + const daysOld = (now.getTime() - created.getTime()) / (1000 * 60 * 60 * 24); + + // More recent records get a small bonus + if (daysOld < 30) score += 0.1; + else if (daysOld < 90) score += 0.05; + } + + return score; +} diff --git a/server/api/get-expenses.ts b/server/api/get-expenses.ts index bcebab7..6701f89 100644 --- a/server/api/get-expenses.ts +++ b/server/api/get-expenses.ts @@ -1,78 +1,122 @@ -import { requireAuth } from '@/server/utils/auth'; +import { requireSalesOrAdmin } from '@/server/utils/auth'; import { getExpenses, getCurrentMonthExpenses } from '@/server/utils/nocodb'; import { processExpenseWithCurrency } from '@/server/utils/currency'; import type { ExpenseFilters } from '@/utils/types'; export default defineEventHandler(async (event) => { - await requireAuth(event); - - const query = getQuery(event); - - // If no date filters provided, default to current month - if (!query.startDate && !query.endDate) { - console.log('[get-expenses] No date filters provided, defaulting to current month'); - const result = await getCurrentMonthExpenses(); + try { + await requireSalesOrAdmin(event); - // Process expenses with currency conversion - const processedExpenses = await Promise.all( - result.list.map(expense => processExpenseWithCurrency(expense)) - ); + const query = getQuery(event); - return { - ...result, - list: processedExpenses - }; - } - - // Build filters from query parameters - const filters: ExpenseFilters = {}; - - if (query.startDate && typeof query.startDate === 'string') { - filters.startDate = query.startDate; - } - - if (query.endDate && typeof query.endDate === 'string') { - filters.endDate = query.endDate; - } - - if (query.payer && typeof query.payer === 'string') { - filters.payer = query.payer; - } - - if (query.category && typeof query.category === 'string') { - filters.category = query.category as any; // Cast to ExpenseCategory - } - - console.log('[get-expenses] Fetching expenses with filters:', filters); - - const result = await getExpenses(filters); - - // Process expenses with currency conversion - const processedExpenses = await Promise.all( - result.list.map(expense => processExpenseWithCurrency(expense)) - ); - - // Add formatted dates - const transformedExpenses = processedExpenses.map(expense => ({ - ...expense, - FormattedDate: new Date(expense.Time).toLocaleDateString(), - FormattedTime: new Date(expense.Time).toLocaleTimeString() - })); - - // Calculate summary with USD totals - const usdTotal = transformedExpenses.reduce((sum, e) => sum + (e.PriceUSD || e.PriceNumber || 0), 0); - const originalTotal = transformedExpenses.reduce((sum, e) => sum + (e.PriceNumber || 0), 0); - - return { - expenses: transformedExpenses, - PageInfo: result.PageInfo, - totalCount: result.PageInfo?.totalRows || transformedExpenses.length, - summary: { - total: originalTotal, // Original currency total (mixed currencies) - totalUSD: usdTotal, // USD converted total - count: transformedExpenses.length, - uniquePayers: [...new Set(transformedExpenses.map(e => e.Payer))].length, - currencies: [...new Set(transformedExpenses.map(e => e.currency))].filter(Boolean) + // If no date filters provided, default to current month + if (!query.startDate && !query.endDate) { + console.log('[get-expenses] No date filters provided, defaulting to current month'); + + try { + const result = await getCurrentMonthExpenses(); + + // Process expenses with currency conversion + const processedExpenses = await Promise.all( + result.list.map(expense => processExpenseWithCurrency(expense)) + ); + + return { + ...result, + list: processedExpenses + }; + } catch (dbError: any) { + console.error('[get-expenses] Database error (current month):', dbError); + + if (dbError.statusCode === 403) { + throw createError({ + statusCode: 503, + statusMessage: 'Expense database is currently unavailable. Please contact your administrator or try again later.' + }); + } + + throw createError({ + statusCode: 500, + statusMessage: 'Unable to fetch expense data. Please try again later.' + }); + } } - }; + + // Build filters from query parameters + const filters: ExpenseFilters = {}; + + if (query.startDate && typeof query.startDate === 'string') { + filters.startDate = query.startDate; + } + + if (query.endDate && typeof query.endDate === 'string') { + filters.endDate = query.endDate; + } + + if (query.payer && typeof query.payer === 'string') { + filters.payer = query.payer; + } + + if (query.category && typeof query.category === 'string') { + filters.category = query.category as any; // Cast to ExpenseCategory + } + + console.log('[get-expenses] Fetching expenses with filters:', filters); + + try { + const result = await getExpenses(filters); + + // Process expenses with currency conversion + const processedExpenses = await Promise.all( + result.list.map(expense => processExpenseWithCurrency(expense)) + ); + + // Add formatted dates + const transformedExpenses = processedExpenses.map(expense => ({ + ...expense, + FormattedDate: new Date(expense.Time).toLocaleDateString(), + FormattedTime: new Date(expense.Time).toLocaleTimeString() + })); + + // Calculate summary with USD totals + const usdTotal = transformedExpenses.reduce((sum, e) => sum + (e.PriceUSD || e.PriceNumber || 0), 0); + const originalTotal = transformedExpenses.reduce((sum, e) => sum + (e.PriceNumber || 0), 0); + + return { + expenses: transformedExpenses, + PageInfo: result.PageInfo, + totalCount: result.PageInfo?.totalRows || transformedExpenses.length, + summary: { + total: originalTotal, // Original currency total (mixed currencies) + totalUSD: usdTotal, // USD converted total + count: transformedExpenses.length, + uniquePayers: [...new Set(transformedExpenses.map(e => e.Payer))].length, + currencies: [...new Set(transformedExpenses.map(e => e.currency))].filter(Boolean) + } + }; + } catch (dbError: any) { + console.error('[get-expenses] Database error (filtered):', dbError); + + if (dbError.statusCode === 403) { + throw createError({ + statusCode: 503, + statusMessage: 'Expense database is currently unavailable. Please contact your administrator or try again later.' + }); + } + + throw createError({ + statusCode: 500, + statusMessage: 'Unable to fetch expense data. Please try again later.' + }); + } + } catch (authError: any) { + if (authError.statusCode === 403) { + throw createError({ + statusCode: 403, + statusMessage: 'Access denied. This feature requires sales team or administrator privileges.' + }); + } + + throw authError; + } });