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
This commit is contained in:
Matt 2025-07-09 11:59:06 -04:00
parent f8d5e4d7e2
commit 280a27cc2f
3 changed files with 457 additions and 128 deletions

View File

@ -72,71 +72,97 @@ definePageMeta({
const { mdAndDown } = useDisplay(); const { mdAndDown } = useDisplay();
const { user, logout, authSource } = useUnifiedAuth(); const { user, logout, authSource } = useUnifiedAuth();
const { isAdmin } = useAuthorization();
const tags = usePortalTags(); const tags = usePortalTags();
const drawer = ref(false); const drawer = ref(false);
const interestMenu = [ const interestMenu = computed(() => {
//{ const baseMenu = [
// to: "/dashboard/interest-eoi-queue", //{
// icon: "mdi-tray-full", // to: "/dashboard/interest-eoi-queue",
// title: "EOI Queue", // icon: "mdi-tray-full",
//}, // title: "EOI Queue",
{ //},
to: "/dashboard/interest-analytics", {
icon: "mdi-view-dashboard", to: "/dashboard/interest-analytics",
title: "Analytics", icon: "mdi-view-dashboard",
}, title: "Analytics",
{ },
to: "/dashboard/interest-berth-list", {
icon: "mdi-table", to: "/dashboard/interest-berth-list",
title: "Berth List", icon: "mdi-table",
}, title: "Berth List",
{ },
to: "/dashboard/interest-berth-status", {
icon: "mdi-sail-boat", to: "/dashboard/interest-berth-status",
title: "Berth Status", icon: "mdi-sail-boat",
}, title: "Berth Status",
{ },
to: "/dashboard/interest-list", {
icon: "mdi-view-list", to: "/dashboard/interest-list",
title: "Interest List", icon: "mdi-view-list",
}, title: "Interest List",
{ },
to: "/dashboard/interest-status", {
icon: "mdi-account-check", to: "/dashboard/interest-status",
title: "Interest Status", icon: "mdi-account-check",
}, title: "Interest Status",
{ },
to: "/dashboard/expenses", {
icon: "mdi-receipt", to: "/dashboard/expenses",
title: "Expenses", icon: "mdi-receipt",
}, title: "Expenses",
{ },
to: "/dashboard/file-browser", {
icon: "mdi-folder", to: "/dashboard/file-browser",
title: "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 = [ return baseMenu;
{ });
to: "/dashboard/site",
icon: "mdi-view-dashboard", const defaultMenu = computed(() => {
title: "Site Analytics", const baseMenu = [
}, {
{ to: "/dashboard/site",
to: "/dashboard/data", icon: "mdi-view-dashboard",
icon: "mdi-finance", title: "Site Analytics",
title: "Data Analytics", },
}, {
{ to: "/dashboard/data",
to: "/dashboard/file-browser", icon: "mdi-finance",
icon: "mdi-folder", title: "Data Analytics",
title: "File Browser", },
}, {
]; 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(() => const menu = computed(() =>
toValue(tags).interest ? interestMenu : defaultMenu toValue(tags).interest ? interestMenu : defaultMenu

View File

@ -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<number>();
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;
}

View File

@ -1,78 +1,122 @@
import { requireAuth } from '@/server/utils/auth'; import { requireSalesOrAdmin } from '@/server/utils/auth';
import { getExpenses, getCurrentMonthExpenses } from '@/server/utils/nocodb'; import { getExpenses, getCurrentMonthExpenses } from '@/server/utils/nocodb';
import { processExpenseWithCurrency } from '@/server/utils/currency'; import { processExpenseWithCurrency } from '@/server/utils/currency';
import type { ExpenseFilters } from '@/utils/types'; import type { ExpenseFilters } from '@/utils/types';
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
await requireAuth(event); try {
await requireSalesOrAdmin(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();
// Process expenses with currency conversion const query = getQuery(event);
const processedExpenses = await Promise.all(
result.list.map(expense => processExpenseWithCurrency(expense))
);
return { // If no date filters provided, default to current month
...result, if (!query.startDate && !query.endDate) {
list: processedExpenses console.log('[get-expenses] No date filters provided, defaulting to current month');
};
} try {
const result = await getCurrentMonthExpenses();
// Build filters from query parameters
const filters: ExpenseFilters = {}; // Process expenses with currency conversion
const processedExpenses = await Promise.all(
if (query.startDate && typeof query.startDate === 'string') { result.list.map(expense => processExpenseWithCurrency(expense))
filters.startDate = query.startDate; );
}
return {
if (query.endDate && typeof query.endDate === 'string') { ...result,
filters.endDate = query.endDate; list: processedExpenses
} };
} catch (dbError: any) {
if (query.payer && typeof query.payer === 'string') { console.error('[get-expenses] Database error (current month):', dbError);
filters.payer = query.payer;
} if (dbError.statusCode === 403) {
throw createError({
if (query.category && typeof query.category === 'string') { statusCode: 503,
filters.category = query.category as any; // Cast to ExpenseCategory statusMessage: 'Expense database is currently unavailable. Please contact your administrator or try again later.'
} });
}
console.log('[get-expenses] Fetching expenses with filters:', filters);
throw createError({
const result = await getExpenses(filters); statusCode: 500,
statusMessage: 'Unable to fetch expense data. Please try again later.'
// 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)
} }
};
// 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;
}
}); });