Add global branding and implement member ID system
Build And Push Image / docker (push) Successful in 3m2s Details

- Add MonacoUSA logo component with global header placement
- Implement member ID generation and migration system
- Create profile page and improve dashboard navigation
- Add member ID as payment reference in dues banner
- Enable support contact functionality with pre-filled email
This commit is contained in:
Matt 2025-08-10 16:49:23 +02:00
parent e33f32e15a
commit ecae3795ee
9 changed files with 986 additions and 9 deletions

75
app.vue
View File

@ -1,7 +1,19 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
<div class="app-container">
<!-- Global MonacoUSA Logo - appears on all pages -->
<div class="global-logo">
<MonacoUSALogo
size="small"
variant="white"
clickable
@click="handleLogoClick"
/>
</div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<script setup lang="ts">
@ -11,4 +23,61 @@ useHead({
return titleChunk ? `${titleChunk} • MonacoUSA Portal` : 'MonacoUSA Portal';
},
});
// Handle logo click - navigate to home/dashboard
const handleLogoClick = () => {
navigateTo('/');
};
</script>
<style>
.app-container {
position: relative;
min-height: 100vh;
}
.global-logo {
position: fixed;
top: 16px;
right: 16px;
z-index: 2000; /* Higher than dashboard drawer and other elements */
pointer-events: auto;
}
/* Adjust positioning on mobile */
@media (max-width: 768px) {
.global-logo {
top: 12px;
right: 12px;
}
}
/* Ensure logo doesn't interfere with dashboard layout */
@media (min-width: 1280px) {
.global-logo {
top: 20px;
right: 20px;
}
}
/* Hide logo on very small screens if needed */
@media (max-width: 320px) {
.global-logo {
display: none;
}
}
/* Print styles - hide logo when printing */
@media print {
.global-logo {
display: none !important;
}
}
/* Accessibility - ensure logo doesn't block important content */
@media (prefers-reduced-motion: reduce) {
.global-logo {
transition: none !important;
}
}
</style>

View File

@ -44,6 +44,21 @@
</v-col>
</v-row>
<v-divider class="my-2" />
<v-row dense>
<v-col cols="12">
<div class="text-caption font-weight-bold">Payment Reference:</div>
<div class="text-body-2 font-family-monospace" style="background-color: rgba(163, 21, 21, 0.1); padding: 8px; border-radius: 4px; border-left: 4px solid #a31515;">
{{ memberData?.member_id || 'Member ID pending' }}
</div>
<div class="text-caption text-medium-emphasis mt-1">
<v-icon size="small" class="mr-1">mdi-information-outline</v-icon>
Please include your member ID in the wire transfer reference for identification
</div>
</v-col>
</v-row>
<v-divider class="my-2" />
<div class="text-caption d-flex align-center">

View File

@ -0,0 +1,159 @@
<template>
<div class="monaco-logo" :class="sizeClass">
<v-img
:src="logoSrc"
:width="logoWidth"
:height="logoHeight"
class="logo-img"
alt="MonacoUSA Logo"
:style="logoStyle"
/>
</div>
</template>
<script setup lang="ts">
interface Props {
size?: 'small' | 'medium' | 'large'
variant?: 'default' | 'white' | 'dark'
clickable?: boolean
}
const props = withDefaults(defineProps<Props>(), {
size: 'medium',
variant: 'default',
clickable: false
});
const emit = defineEmits<{
click: []
}>();
// Computed properties for responsive sizing
const sizeClass = computed(() => `monaco-logo--${props.size}`);
const logoSrc = computed(() => {
// Use the high-res Monaco flag image
return '/MONACOUSA-Flags_376x376.png';
});
const logoWidth = computed(() => {
switch (props.size) {
case 'small': return 32;
case 'medium': return 48;
case 'large': return 80;
default: return 48;
}
});
const logoHeight = computed(() => {
switch (props.size) {
case 'small': return 32;
case 'medium': return 48;
case 'large': return 80;
default: return 48;
}
});
const logoStyle = computed(() => {
const baseStyle: Record<string, string> = {
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.1)'
};
if (props.clickable) {
baseStyle.cursor = 'pointer';
baseStyle.transition = 'transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out';
}
if (props.variant === 'white') {
baseStyle.backgroundColor = 'white';
baseStyle.padding = '4px';
} else if (props.variant === 'dark') {
baseStyle.backgroundColor = 'rgba(0, 0, 0, 0.1)';
baseStyle.padding = '4px';
}
return baseStyle;
});
// Handle click events
const handleClick = () => {
if (props.clickable) {
emit('click');
}
};
</script>
<style scoped>
.monaco-logo {
display: inline-flex;
align-items: center;
justify-content: center;
}
.monaco-logo--small {
min-width: 32px;
min-height: 32px;
}
.monaco-logo--medium {
min-width: 48px;
min-height: 48px;
}
.monaco-logo--large {
min-width: 80px;
min-height: 80px;
}
.logo-img {
object-fit: cover;
border-radius: inherit;
}
.monaco-logo:hover .logo-img {
transform: scale(1.05);
box-shadow: 0 4px 16px rgba(163, 21, 21, 0.2);
}
/* Ensure the logo maintains aspect ratio */
.v-img {
flex-shrink: 0;
}
/* Animation for clickable logos */
.monaco-logo[style*="cursor: pointer"]:hover {
transform: translateY(-2px);
}
.monaco-logo[style*="cursor: pointer"]:active {
transform: translateY(0);
}
/* Accessibility improvements */
@media (prefers-reduced-motion: reduce) {
.logo-img,
.monaco-logo {
transition: none !important;
transform: none !important;
}
}
/* High contrast mode support */
@media (prefers-contrast: high) {
.logo-img {
border: 2px solid currentColor;
}
}
/* Print styles */
@media print {
.monaco-logo {
box-shadow: none !important;
}
.logo-img {
transform: none !important;
}
}
</style>

477
pages/dashboard/profile.vue Normal file
View File

@ -0,0 +1,477 @@
<template>
<v-container>
<!-- Page Header -->
<v-row class="mb-6">
<v-col>
<div class="d-flex align-center">
<v-btn
icon="mdi-arrow-left"
variant="text"
@click="$router.back()"
class="mr-3"
/>
<div>
<h1 class="text-h3 font-weight-bold" style="color: #a31515;">
My Profile
</h1>
<p class="text-h6 text-medium-emphasis">
View and manage your membership information
</p>
</div>
</div>
</v-col>
</v-row>
<!-- Loading State -->
<v-row v-if="loading" class="justify-center">
<v-col cols="12" class="text-center">
<v-progress-circular
indeterminate
color="primary"
size="64"
/>
<p class="mt-4">Loading your profile...</p>
</v-col>
</v-row>
<!-- Profile Content -->
<div v-else>
<!-- Member ID Card -->
<v-row class="mb-6">
<v-col cols="12">
<v-card elevation="2" class="member-id-card">
<v-card-text class="pa-6">
<div class="d-flex align-center">
<v-icon size="48" color="primary" class="mr-4">mdi-badge-account</v-icon>
<div>
<h2 class="text-h4 font-weight-bold" style="color: #a31515;">
{{ memberData?.member_id || 'Member ID Pending' }}
</h2>
<p class="text-body-1 text-medium-emphasis">
Your unique MonacoUSA member identifier
</p>
</div>
<v-spacer />
<v-btn
v-if="memberData?.member_id"
icon="mdi-content-copy"
variant="outlined"
@click="copyMemberID"
:title="`Copy member ID: ${memberData.member_id}`"
/>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Profile Information -->
<v-row>
<!-- Personal Information -->
<v-col cols="12" md="6">
<v-card elevation="2" class="h-100">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-account-details</v-icon>
Personal Information
</v-card-title>
<v-card-text class="pa-4">
<v-list>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Full Name</v-list-item-title>
<v-list-item-subtitle>{{ fullName || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Email Address</v-list-item-title>
<v-list-item-subtitle>{{ memberData?.email || user?.email || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Phone Number</v-list-item-title>
<v-list-item-subtitle>{{ memberData?.phone || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Date of Birth</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(memberData?.date_of_birth) || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Nationality</v-list-item-title>
<v-list-item-subtitle>
<div v-if="memberData?.nationality">
<CountryFlag
v-for="country in parseNationalities(memberData.nationality)"
:key="country"
:country="country"
size="small"
class="mr-1"
/>
</div>
<span v-else>Not provided</span>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Address</v-list-item-title>
<v-list-item-subtitle>{{ memberData?.address || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
<!-- Membership Details -->
<v-col cols="12" md="6">
<v-card elevation="2" class="h-100">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-card-membership</v-icon>
Membership Details
</v-card-title>
<v-card-text class="pa-4">
<v-list>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Member Since</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(memberData?.member_since) || 'Not provided' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Membership Status</v-list-item-title>
<v-list-item-subtitle>
<v-chip
:color="getStatusColor(memberData?.membership_status)"
size="small"
variant="flat"
>
{{ memberData?.membership_status || 'Pending' }}
</v-chip>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Account Tier</v-list-item-title>
<v-list-item-subtitle>
<v-chip
:color="getTierColor(userTier)"
size="small"
variant="flat"
>
{{ userTier?.toUpperCase() || 'USER' }}
</v-chip>
</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
<v-list-item>
<v-list-item-content>
<v-list-item-title class="font-weight-bold">Registration Date</v-list-item-title>
<v-list-item-subtitle>{{ formatDate(memberData?.registration_date) || 'Not available' }}</v-list-item-subtitle>
</v-list-item-content>
</v-list-item>
</v-list>
</v-card-text>
</v-card>
</v-col>
</v-row>
<!-- Dues and Payment Information -->
<v-row class="mt-6">
<v-col cols="12">
<v-card elevation="2">
<v-card-title class="pa-4" style="background-color: #f5f5f5;">
<v-icon class="mr-2" color="primary">mdi-credit-card-outline</v-icon>
Dues and Payment Information
</v-card-title>
<v-card-text class="pa-4">
<v-row>
<v-col cols="12" sm="6" md="3">
<div class="text-center">
<v-icon
:color="memberData?.current_year_dues_paid === 'true' ? 'success' : 'warning'"
size="48"
class="mb-2"
>
{{ memberData?.current_year_dues_paid === 'true' ? 'mdi-check-circle' : 'mdi-alert-circle' }}
</v-icon>
<h4 class="font-weight-bold">Current Year Dues</h4>
<v-chip
:color="memberData?.current_year_dues_paid === 'true' ? 'success' : 'warning'"
size="small"
variant="flat"
>
{{ memberData?.current_year_dues_paid === 'true' ? 'PAID' : 'UNPAID' }}
</v-chip>
</div>
</v-col>
<v-col cols="12" sm="6" md="3">
<div>
<h4 class="font-weight-bold mb-2">Last Payment Date</h4>
<p>{{ formatDate(memberData?.membership_date_paid) || 'No payment recorded' }}</p>
</div>
</v-col>
<v-col cols="12" sm="6" md="3">
<div>
<h4 class="font-weight-bold mb-2">Payment Due Date</h4>
<p>{{ formatDate(memberData?.payment_due_date) || 'Not set' }}</p>
</div>
</v-col>
<v-col cols="12" sm="6" md="3">
<div>
<h4 class="font-weight-bold mb-2">Days Remaining</h4>
<p :class="getDaysRemainingColor(daysRemaining)">
{{ daysRemaining >= 0 ? `${daysRemaining} days` : `${Math.abs(daysRemaining)} days overdue` }}
</p>
</div>
</v-col>
</v-row>
<!-- Payment Reference Info -->
<v-divider class="my-4" />
<div v-if="memberData?.member_id">
<h4 class="font-weight-bold mb-2">Payment Reference for Wire Transfers</h4>
<div class="d-flex align-center">
<v-text-field
:value="memberData.member_id"
readonly
variant="outlined"
density="compact"
class="mr-2"
style="max-width: 200px;"
/>
<v-btn
icon="mdi-content-copy"
variant="outlined"
@click="copyMemberID"
title="Copy member ID"
/>
</div>
<p class="text-body-2 text-medium-emphasis mt-2">
Include this member ID in your wire transfer reference for payment identification.
</p>
</div>
</v-card-text>
</v-card>
</v-col>
</v-row>
</div>
<!-- Snackbar for notifications -->
<v-snackbar
v-model="snackbar.show"
:color="snackbar.color"
:timeout="3000"
>
{{ snackbar.message }}
<template #actions>
<v-btn
variant="text"
@click="snackbar.show = false"
>
Close
</v-btn>
</template>
</v-snackbar>
</v-container>
</template>
<script setup lang="ts">
import type { Member } from '~/utils/types';
definePageMeta({
layout: 'dashboard',
middleware: 'auth'
});
const { user, userTier } = useAuth();
// Reactive state
const loading = ref(true);
const memberData = ref<Member | null>(null);
const snackbar = ref({
show: false,
message: '',
color: 'success'
});
// Computed properties
const fullName = computed(() => {
if (memberData.value) {
return `${memberData.value.first_name || ''} ${memberData.value.last_name || ''}`.trim();
}
return user.value?.name || '';
});
const daysRemaining = computed(() => {
if (!memberData.value?.payment_due_date) return 0;
const dueDate = new Date(memberData.value.payment_due_date);
const today = new Date();
const diffTime = dueDate.getTime() - today.getTime();
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return diffDays;
});
// Methods
const loadMemberData = async () => {
if (!user.value?.email) return;
try {
loading.value = true;
const response = await $fetch('/api/members') as any;
const members = response?.data || response?.list || [];
// Find member by email
const member = members.find((m: any) => m.email === user.value?.email);
if (member) {
memberData.value = member;
}
} catch (error) {
console.error('Failed to load member data:', error);
snackbar.value = {
show: true,
message: 'Failed to load profile data. Please try refreshing the page.',
color: 'error'
};
} finally {
loading.value = false;
}
};
const copyMemberID = async () => {
if (!memberData.value?.member_id) return;
try {
await navigator.clipboard.writeText(memberData.value.member_id);
snackbar.value = {
show: true,
message: `Member ID ${memberData.value.member_id} copied to clipboard!`,
color: 'success'
};
} catch (error) {
console.error('Failed to copy to clipboard:', error);
snackbar.value = {
show: true,
message: 'Failed to copy member ID to clipboard.',
color: 'error'
};
}
};
const formatDate = (dateString: string | undefined): string => {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric'
});
} catch (error) {
return dateString;
}
};
const parseNationalities = (nationalityString: string): string[] => {
return nationalityString ? nationalityString.split(',').map(n => n.trim()).filter(n => n.length > 0) : [];
};
const getStatusColor = (status: string | undefined): string => {
switch (status?.toLowerCase()) {
case 'active': return 'success';
case 'inactive': return 'error';
case 'pending': return 'warning';
case 'expired': return 'error';
default: return 'grey';
}
};
const getTierColor = (tier: string | undefined): string => {
switch (tier?.toLowerCase()) {
case 'admin': return 'error';
case 'board': return 'primary';
case 'user': return 'info';
default: return 'grey';
}
};
const getDaysRemainingColor = (days: number): string => {
if (days < 0) return 'text-error font-weight-bold';
if (days < 30) return 'text-warning font-weight-bold';
return 'text-success';
};
// Initialize
onMounted(() => {
loadMemberData();
});
// Watch for user changes
watch(user, () => {
if (user.value) {
loadMemberData();
}
});
</script>
<style scoped>
.member-id-card {
background: linear-gradient(135deg, rgba(163, 21, 21, 0.05) 0%, rgba(163, 21, 21, 0.1) 100%);
border-left: 4px solid #a31515;
}
.v-card {
border-radius: 12px !important;
}
.v-card:hover {
transform: translateY(-2px);
transition: transform 0.2s ease-in-out;
}
.v-btn {
text-transform: none !important;
}
.v-icon {
color: #a31515 !important;
}
.h-100 {
height: 100%;
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.member-id-card .d-flex {
flex-direction: column;
text-align: center;
}
.member-id-card .d-flex > * {
margin-bottom: 16px;
}
}
</style>

View File

@ -162,8 +162,7 @@ const { firstName, user, userTier } = useAuth();
// Navigation methods (placeholder implementations)
const navigateToProfile = () => {
// TODO: Implement profile navigation
console.log('Navigate to profile');
navigateTo('/dashboard/profile');
};
const navigateToEvents = () => {
@ -177,8 +176,19 @@ const navigateToResources = () => {
};
const contactSupport = () => {
// TODO: Implement support contact
console.log('Contact support');
const subject = encodeURIComponent('MonacoUSA Portal Support Request');
const body = encodeURIComponent(`Hello,
I need assistance with:
[Please describe your issue]
Member: ${user.value?.name || 'Not provided'}
Email: ${user.value?.email || 'Not provided'}
Thank you!`);
window.open(`mailto:support@monacousa.org?subject=${subject}&body=${body}`, '_self');
};
</script>

View File

@ -0,0 +1,117 @@
import { createSessionManager } from '~/server/utils/session';
import { findMembersWithoutMemberID, generateMemberID } from '~/server/utils/member-id';
import { updateMember, handleNocoDbError } from '~/server/utils/nocodb';
import type { Member } from '~/utils/types';
export default defineEventHandler(async (event) => {
console.log('[api/admin/migrate-member-ids.post] =========================');
console.log('[api/admin/migrate-member-ids.post] POST /api/admin/migrate-member-ids - Migrate existing member IDs');
console.log('[api/admin/migrate-member-ids.post] Request from:', getClientIP(event));
try {
// Validate session and require Admin privileges
const sessionManager = createSessionManager();
const cookieHeader = getCookie(event, 'monacousa-session') ? getHeader(event, 'cookie') : undefined;
const session = sessionManager.getSession(cookieHeader);
if (!session?.user) {
throw createError({
statusCode: 401,
statusMessage: 'Authentication required'
});
}
const userTier = session.user.tier;
if (userTier !== 'admin') {
throw createError({
statusCode: 403,
statusMessage: 'Administrator privileges required for member ID migration'
});
}
console.log('[api/admin/migrate-member-ids.post] Authorized admin user:', session.user.email);
// Find all members without member IDs
console.log('[api/admin/migrate-member-ids.post] Finding members without member IDs...');
const membersWithoutId = await findMembersWithoutMemberID();
if (membersWithoutId.length === 0) {
console.log('[api/admin/migrate-member-ids.post] No members found without member IDs');
return {
success: true,
message: 'All members already have member IDs assigned',
migrated: 0,
total: 0
};
}
console.log(`[api/admin/migrate-member-ids.post] Found ${membersWithoutId.length} members without member IDs`);
const migrationResults = [];
let successCount = 0;
let errorCount = 0;
// Migrate each member
for (let i = 0; i < membersWithoutId.length; i++) {
const member = membersWithoutId[i];
console.log(`[api/admin/migrate-member-ids.post] Migrating member ${i + 1}/${membersWithoutId.length}: ${member.first_name} ${member.last_name} (ID: ${member.Id})`);
try {
// Generate unique member ID
const memberID = await generateMemberID();
console.log(`[api/admin/migrate-member-ids.post] Generated ID ${memberID} for member ${member.Id}`);
// Update member with new member ID
await updateMember(member.Id, { member_id: memberID });
migrationResults.push({
memberId: member.Id,
memberName: `${member.first_name} ${member.last_name}`,
generatedId: memberID,
success: true
});
successCount++;
console.log(`[api/admin/migrate-member-ids.post] ✅ Successfully migrated member ${member.Id} with ID ${memberID}`);
// Add a small delay to avoid overwhelming the database
if (i < membersWithoutId.length - 1) {
await new Promise(resolve => setTimeout(resolve, 100));
}
} catch (error: any) {
console.error(`[api/admin/migrate-member-ids.post] ❌ Failed to migrate member ${member.Id}:`, error);
migrationResults.push({
memberId: member.Id,
memberName: `${member.first_name} ${member.last_name}`,
generatedId: null,
success: false,
error: error.message || 'Unknown error'
});
errorCount++;
}
}
console.log('[api/admin/migrate-member-ids.post] =========================');
console.log('[api/admin/migrate-member-ids.post] Migration completed');
console.log('[api/admin/migrate-member-ids.post] Total members processed:', membersWithoutId.length);
console.log('[api/admin/migrate-member-ids.post] Successful migrations:', successCount);
console.log('[api/admin/migrate-member-ids.post] Failed migrations:', errorCount);
console.log('[api/admin/migrate-member-ids.post] =========================');
return {
success: errorCount === 0,
message: `Migration completed. ${successCount} members successfully migrated, ${errorCount} errors.`,
migrated: successCount,
errors: errorCount,
total: membersWithoutId.length,
results: migrationResults
};
} catch (error: any) {
console.error('[api/admin/migrate-member-ids.post] ❌ Migration failed:', error);
handleNocoDbError(error, 'migrateMemberIDs', 'Member ID Migration');
}
});

View File

@ -1,5 +1,6 @@
import { createMember, handleNocoDbError } from '~/server/utils/nocodb';
import { createSessionManager } from '~/server/utils/session';
import { generateMemberID } from '~/server/utils/member-id';
import type { Member, MembershipStatus } from '~/utils/types';
export default defineEventHandler(async (event) => {
@ -50,7 +51,7 @@ export default defineEventHandler(async (event) => {
}
// Sanitize and prepare data
const memberData = sanitizeMemberData(normalizedBody);
const memberData = await sanitizeMemberData(normalizedBody);
console.log('[api/members.post] Sanitized data fields:', Object.keys(memberData));
// Create member in NocoDB
@ -109,9 +110,14 @@ function validateMemberData(data: any): string[] {
return errors;
}
function sanitizeMemberData(data: any): Partial<Member> {
async function sanitizeMemberData(data: any): Promise<Partial<Member>> {
const sanitized: any = {};
// Generate unique member ID
console.log('[api/members.post] Generating member ID for new member...');
sanitized.member_id = await generateMemberID();
console.log('[api/members.post] Generated member ID:', sanitized.member_id);
// Required fields
sanitized.first_name = data.first_name.trim();
sanitized.last_name = data.last_name.trim();

123
server/utils/member-id.ts Normal file
View File

@ -0,0 +1,123 @@
import { getMembers, updateMember } from './nocodb';
import type { Member } from '~/utils/types';
/**
* Generates a unique member ID in the format MUSA-{unique 6-digit number}
* Checks against existing member IDs to ensure uniqueness
* @returns Promise<string> - The unique member ID
*/
export async function generateMemberID(): Promise<string> {
console.log('[member-id] Generating new member ID...');
let memberID: string;
let isUnique = false;
let attempts = 0;
const maxAttempts = 100; // Prevent infinite loops
while (!isUnique && attempts < maxAttempts) {
attempts++;
// Generate a 6-digit number (100000 to 999999)
const uniqueNumber = Math.floor(Math.random() * 900000) + 100000;
memberID = `MUSA-${uniqueNumber}`;
console.log(`[member-id] Attempt ${attempts}: Checking uniqueness of ${memberID}`);
// Check if ID already exists in database
const existingMember = await checkMemberIDExists(memberID);
isUnique = !existingMember;
if (!isUnique) {
console.log(`[member-id] ID ${memberID} already exists, generating new one...`);
}
}
if (attempts >= maxAttempts) {
console.error('[member-id] Failed to generate unique member ID after maximum attempts');
throw new Error('Failed to generate unique member ID after maximum attempts');
}
console.log(`[member-id] ✅ Generated unique member ID: ${memberID!} (attempts: ${attempts})`);
return memberID!;
}
/**
* Checks if a member ID already exists in the database
* @param memberID - The member ID to check
* @returns Promise<boolean> - True if the member ID exists, false otherwise
*/
export async function checkMemberIDExists(memberID: string): Promise<boolean> {
try {
console.log(`[member-id] Checking if member ID exists: ${memberID}`);
// Get all members and check for duplicate member_id
const members = await getMembers();
const memberList = Array.isArray(members) ? members : members?.list || [];
const existingMember = memberList.find((member: Member) => member.member_id === memberID);
const exists = !!existingMember;
console.log(`[member-id] Member ID ${memberID} exists: ${exists}`);
return exists;
} catch (error: any) {
console.error('[member-id] Error checking member ID existence:', error);
// In case of error, assume it doesn't exist to allow generation to continue
// The actual creation will fail if there's a real database issue
return false;
}
}
/**
* Finds all members without a member_id field
* Used for migration purposes
* @returns Promise<Member[]> - Array of members without member IDs
*/
export async function findMembersWithoutMemberID(): Promise<Member[]> {
try {
console.log('[member-id] Finding members without member IDs for migration...');
const members = await getMembers();
const memberList = Array.isArray(members) ? members : members?.list || [];
const membersWithoutId = memberList.filter((member: Member) =>
!member.member_id || member.member_id.trim() === ''
);
console.log(`[member-id] Found ${membersWithoutId.length} members without member IDs`);
return membersWithoutId;
} catch (error: any) {
console.error('[member-id] Error finding members without member IDs:', error);
throw error;
}
}
/**
* Validates a member ID format
* @param memberID - The member ID to validate
* @returns boolean - True if valid format, false otherwise
*/
export function isValidMemberIDFormat(memberID: string): boolean {
if (!memberID || typeof memberID !== 'string') {
return false;
}
// Check format: MUSA-{6 digits}
const memberIDRegex = /^MUSA-\d{6}$/;
return memberIDRegex.test(memberID);
}
/**
* Extracts the numeric part from a member ID
* @param memberID - The member ID (e.g., "MUSA-123456")
* @returns number - The numeric part or null if invalid
*/
export function extractMemberIDNumber(memberID: string): number | null {
if (!isValidMemberIDFormat(memberID)) {
return null;
}
const numericPart = memberID.replace('MUSA-', '');
return parseInt(numericPart, 10);
}

View File

@ -118,6 +118,7 @@ export enum MembershipStatus {
export interface Member {
Id: string;
member_id?: string; // MUSA-{unique number} - Member identification number
first_name: string;
last_name: string;
email: string;