v2: MongoDB backend + /manage dashboard

- Replace Express 4 backend with Express 5 + Mongoose 8 + TypeScript 5.8
- Add MongoDB 8.0 container via docker-compose
- Add JWT auth (jose), argon2 password hashing, zod validation, multer uploads
- Models: BoardMember, Event, GalleryItem, Sponsor, Minute, Member, User
- Full CRUD REST API for all content types
- Seed script to populate MongoDB from hardcoded data
- Wire all frontend components to fetch from API (no JSX changes)
- Add /manage dashboard: login + tabs for Board, Events, Gallery, Sponsors, Minutes, Members
- Uploads volume for persistent file storage across deploys
This commit is contained in:
friday-bot 2026-05-27 16:23:12 -06:00
parent addd4858ae
commit ef8d281b2b
34 changed files with 1478 additions and 281 deletions

View file

@ -1,71 +1,62 @@
# Build stage
FROM node:14 AS build
FROM node:22-alpine AS build
# Add a cache busting argument
ARG CACHEBUST=1
RUN echo "Cache bust: $CACHEBUST"
# Build tools needed for argon2 native compilation
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy package.json and package-lock.json
# Install dependencies first (cache layer)
COPY .env ./.env
COPY package*.json ./
COPY client/package*.json ./client/
COPY server/package*.json ./server/
# Install dependencies
RUN cd client && npm cache clean --force && rm -rf node_modules && npm install
RUN cd client && npm install
RUN cd server && npm install
# Copy the entire client directory structure
# Copy static assets
COPY client/public/headshots ./client/public/headshots
COPY client/public/logos ./client/public/logos
COPY client/public/photos ./client/public/photos
COPY client/public/sponsors ./client/public/sponsors
COPY client/public/videos ./client/public/videos
COPY client/public/minutes ./client/public/minutes
COPY client/public/logos ./client/public/logos
COPY client/public/photos ./client/public/photos
COPY client/public/sponsors ./client/public/sponsors
COPY client/public/videos ./client/public/videos
COPY client/public/minutes ./client/public/minutes
COPY client/public/*.* ./client/public/
# Copy source code
COPY client/src ./client/src
COPY client/*.js ./client/
# Copy source
COPY client/src ./client/src
COPY client/*.js ./client/
COPY client/*.json ./client/
COPY server ./server
# Install required packages
RUN npm install nodemailer dotenv date-fns react-icons
COPY server/src ./server/src
COPY server/tsconfig.json ./server/
# Build client
RUN cd client && npm run build
# Install all dependencies including devDependencies
RUN cd server && npm install
# Build server
RUN cd server && npm run build
# Remove devDependencies
RUN cd server && npm prune --production
RUN cd server && npm run build && npm prune --production
# Production stage
FROM node:14-alpine
FROM node:22-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app
# Copy built assets and static files from build stage
COPY --from=build /app/client/build ./client/build
COPY --from=build /app/server ./server
COPY --from=build /app/server/dist ./server/dist
COPY --from=build /app/server/node_modules ./server/node_modules
COPY --from=build /app/server/package.json ./server/package.json
COPY --from=build /app/.env ./.env
# Set working directory to server
RUN mkdir -p uploads/headshots uploads/photos uploads/sponsors uploads/minutes
WORKDIR /app/server
# Install production dependencies
RUN npm install --only=production
# Expose port
EXPOSE 801
# Start the server
CMD ["npm", "start"]
CMD ["node", "dist/index.js"]

View file

@ -14,29 +14,35 @@ import VideoGallery from './components/VideoGallery';
import Resources from './components/Resources';
/* import ComingSoon from './components/ComingSoon'; */
import JoinMCDi from './components/JoinMCDi';
import Manage from './components/Manage';
const App: React.FC = () => {
return (
<Router>
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow px-4 sm:px-6 lg:px-8 py-8">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/minutes" element={<Minutes />} />
<Route path="/about" element={<AboutUs />} />
<Route path="/bylaws" element={<Bylaws />} />
<Route path="/sponsors" element={<Sponsors />} />
<Route path="/meet-board" element={<MeetBoard />} />
<Route path="/gallery" element={<Gallery />} />
<Route path="/videos" element={<VideoGallery />} />
<Route path="/resources" element={<Resources />} />
<Route path="/joinmcdi" element={<JoinMCDi />} />
</Routes>
</main>
<Footer />
</div>
<Routes>
<Route path="/manage/*" element={<Manage />} />
<Route path="/*" element={
<div className="flex flex-col min-h-screen">
<Header />
<main className="flex-grow px-4 sm:px-6 lg:px-8 py-8">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/calendar" element={<Calendar />} />
<Route path="/minutes" element={<Minutes />} />
<Route path="/about" element={<AboutUs />} />
<Route path="/bylaws" element={<Bylaws />} />
<Route path="/sponsors" element={<Sponsors />} />
<Route path="/meet-board" element={<MeetBoard />} />
<Route path="/gallery" element={<Gallery />} />
<Route path="/videos" element={<VideoGallery />} />
<Route path="/resources" element={<Resources />} />
<Route path="/joinmcdi" element={<JoinMCDi />} />
</Routes>
</main>
<Footer />
</div>
} />
</Routes>
</Router>
);
};

View file

@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import EventCard from './EventCard';
import { parseISO, startOfDay, isSameDay, isAfter, format, startOfMonth } from 'date-fns';
import { events, Event } from '../eventData';
import { Event } from '../eventData';
export const formatTime = (dateString: string, time: string): string => {
const date = parseISO(dateString);
@ -60,6 +60,13 @@ const Calendar: React.FC = () => {
});
const [selectedEvent, setSelectedEvent] = useState<Event | null>(null);
const calendarRef = useRef<HTMLDivElement>(null);
const [events, setEvents] = useState<Event[]>([]);
useEffect(() => {
fetch('/api/events')
.then(r => r.json())
.then(setEvents);
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent | TouchEvent) => {

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
interface GalleryItem {
@ -7,62 +7,18 @@ interface GalleryItem {
alt: string;
}
// Gallery photos from community events
const galleryData: GalleryItem[] = [
{
id: 1,
imageUrl: '/photos/0001.jpg',
alt: 'MCDi community gathering',
},
{
id: 2,
imageUrl: '/photos/0002.jpg',
alt: 'MCDi community event photo',
},
{
id: 3,
imageUrl: '/photos/0003.jpg',
alt: 'MCDi community event photo',
},
{
id: 4,
imageUrl: '/photos/0004.jpg',
alt: 'MCDi community event photo',
},
{
id: 5,
imageUrl: '/photos/0005.jpg',
alt: 'MCDi community event photo',
},
{
id: 6,
imageUrl: '/photos/0006.jpg',
alt: 'MCDi community event photo',
},
{
id: 7,
imageUrl: '/photos/0007.jpg',
alt: 'MCDi community event photo',
},
{
id: 8,
imageUrl: '/photos/0008.jpg',
alt: 'MCDi community event photo',
},
{
id: 9,
imageUrl: '/photos/0009.jpg',
alt: 'MCDi community event photo',
},
{
id: 10,
imageUrl: '/photos/0010.jpg',
alt: 'MCDi community event photo',
},
];
const Gallery: React.FC = () => {
const [selectedImage, setSelectedImage] = useState<GalleryItem | null>(null);
const [galleryData, setGalleryData] = useState<GalleryItem[]>([]);
useEffect(() => {
fetch('/api/gallery')
.then(r => r.json())
.then((items: Array<GalleryItem & { _id: string }>) =>
setGalleryData(items.map((item, i) => ({ ...item, id: i + 1 })))
);
}, []);
const openImage = (item: GalleryItem) => {
setSelectedImage(item);

View file

@ -3,7 +3,7 @@ import { motion } from 'framer-motion';
import ImageCarousel from './ImageCarousel';
import EventCard from './EventCard';
import { parseISO, startOfDay, isSameDay, isAfter } from 'date-fns';
import { events, Event } from '../eventData';
import { Event } from '../eventData';
import { formatDateRange } from './Calendar';
const isEventUpcoming = (eventDate: string) => {
@ -16,11 +16,15 @@ const Home: React.FC = () => {
const [upcomingEvents, setUpcomingEvents] = useState<Event[]>([]);
useEffect(() => {
const filteredEvents = events
.filter(event => isEventUpcoming(event.date))
.sort((a, b) => parseISO(a.date).getTime() - parseISO(b.date).getTime())
.slice(0, 6);
setUpcomingEvents(filteredEvents);
fetch('/api/events')
.then(r => r.json())
.then((allEvents: Event[]) => {
const filteredEvents = allEvents
.filter(event => isEventUpcoming(event.date))
.sort((a, b) => parseISO(a.date).getTime() - parseISO(b.date).getTime())
.slice(0, 6);
setUpcomingEvents(filteredEvents);
});
}, []);
return (

View file

@ -0,0 +1,590 @@
import React, { useState, useEffect, useCallback } from 'react';
// ── Types ────────────────────────────────────────────────────────────────────
interface User { id: string; username: string; name: string; role: string; }
interface BoardMember { _id: string; name: string; position: string; email: string; image: string; bio: string; order: number; }
interface Event { _id: string; date: string; title: string; description: string; startTime: string; endTime: string; pointOfContact: string; email: string; address: string; }
interface GalleryItem { _id: string; imageUrl: string; alt: string; order: number; }
interface Sponsor { _id: string; name: string; logoPath: string; description: string; websiteUrl?: string; facebookUrl?: string; instagramUrl?: string; order: number; }
interface Minute { _id: string; date: string; meetingType: string; location: string; fileUrl: string; }
interface Member { _id: string; firstName: string; lastName: string; email: string; phone?: string; address?: string; membershipType: string; status: string; joinDate: string; notes?: string; }
type Tab = 'board' | 'events' | 'gallery' | 'sponsors' | 'minutes' | 'members';
// ── Auth helpers ─────────────────────────────────────────────────────────────
const getToken = () => localStorage.getItem('mcdi_token');
const authHeaders = () => ({ Authorization: `Bearer ${getToken()}`, 'Content-Type': 'application/json' });
async function api<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`/api${path}`, { headers: authHeaders(), ...opts });
if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
if (res.status === 204) return undefined as T;
return res.json();
}
// ── Login screen ─────────────────────────────────────────────────────────────
const Login: React.FC<{ onLogin: (token: string, user: User) => void }> = ({ onLogin }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const submit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true); setError('');
try {
const data = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
}).then(r => { if (!r.ok) throw new Error('Invalid credentials'); return r.json(); });
onLogin(data.token, data.user);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Login failed');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-gray-100 flex items-center justify-center">
<div className="bg-white rounded-lg shadow-md p-8 w-full max-w-sm">
<h1 className="text-2xl font-bold text-[#8C1D40] mb-6 text-center">MCDi Management</h1>
<form onSubmit={submit} className="space-y-4">
<input className="w-full border rounded px-3 py-2" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required />
<input className="w-full border rounded px-3 py-2" type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required />
{error && <p className="text-red-600 text-sm">{error}</p>}
<button className="w-full bg-[#8C1D40] text-white py-2 rounded hover:bg-[#6B1631] transition" disabled={loading}>
{loading ? 'Signing in…' : 'Sign In'}
</button>
</form>
</div>
</div>
);
};
// ── Shared UI ─────────────────────────────────────────────────────────────────
const Btn: React.FC<{ onClick?: () => void; type?: 'button'|'submit'; color?: string; disabled?: boolean; children: React.ReactNode }> = ({ onClick, type = 'button', color = 'bg-[#8C1D40] hover:bg-[#6B1631]', disabled, children }) => (
<button type={type} onClick={onClick} disabled={disabled} className={`${color} text-white text-sm px-3 py-1.5 rounded transition disabled:opacity-50`}>{children}</button>
);
const Field: React.FC<{ label: string; children: React.ReactNode }> = ({ label, children }) => (
<label className="block text-sm">
<span className="text-gray-700 font-medium">{label}</span>
<div className="mt-1">{children}</div>
</label>
);
const Input: React.FC<React.InputHTMLAttributes<HTMLInputElement>> = (props) => (
<input {...props} className={`w-full border rounded px-2 py-1.5 text-sm ${props.className ?? ''}`} />
);
const Textarea: React.FC<React.TextareaHTMLAttributes<HTMLTextAreaElement>> = (props) => (
<textarea {...props} className={`w-full border rounded px-2 py-1.5 text-sm ${props.className ?? ''}`} />
);
// ── Board tab ─────────────────────────────────────────────────────────────────
const BoardTab: React.FC = () => {
const [members, setMembers] = useState<BoardMember[]>([]);
const [editing, setEditing] = useState<Partial<BoardMember> | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const load = useCallback(() => api<BoardMember[]>('/board').then(setMembers), []);
useEffect(() => { load(); }, [load]);
const save = async (e: React.FormEvent) => {
e.preventDefault();
if (!editing) return;
const form = new FormData();
Object.entries(editing).forEach(([k, v]) => { if (k !== '_id' && v !== undefined) form.append(k, String(v)); });
if (imageFile) form.append('image', imageFile);
const token = getToken();
const url = editing._id ? `/api/board/${editing._id}` : '/api/board';
await fetch(url, { method: editing._id ? 'PUT' : 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });
setEditing(null); setImageFile(null); load();
};
const del = async (id: string) => {
if (!window.confirm('Remove this board member?')) return;
await api(`/board/${id}`, { method: 'DELETE' });
load();
};
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#8C1D40]">Board Members</h2>
<Btn onClick={() => setEditing({ name:'', position:'', email:'', image:'', bio:'', order:0 })}>+ Add Member</Btn>
</div>
{editing && (
<form onSubmit={save} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Name"><Input required value={editing.name ?? ''} onChange={e => setEditing(p => ({ ...p, name: e.target.value }))} /></Field>
<Field label="Position"><Input required value={editing.position ?? ''} onChange={e => setEditing(p => ({ ...p, position: e.target.value }))} /></Field>
<Field label="Email"><Input required type="email" value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} /></Field>
<Field label="Display Order"><Input type="number" value={editing.order ?? 0} onChange={e => setEditing(p => ({ ...p, order: +e.target.value }))} /></Field>
<Field label="Photo (upload new or keep existing)">
<Input type="file" accept="image/*" onChange={e => setImageFile(e.target.files?.[0] ?? null)} />
{editing.image && !imageFile && <p className="text-xs text-gray-500 mt-1">Current: {editing.image}</p>}
</Field>
<Field label="Bio"><Textarea required rows={4} value={editing.bio ?? ''} onChange={e => setEditing(p => ({ ...p, bio: e.target.value }))} /></Field>
<div className="md:col-span-2 flex gap-2">
<Btn type="submit">Save</Btn>
<Btn color="bg-gray-400 hover:bg-gray-500" onClick={() => { setEditing(null); setImageFile(null); }}>Cancel</Btn>
</div>
</form>
)}
<div className="space-y-2">
{members.map(m => (
<div key={m._id} className="flex items-center gap-3 bg-white border rounded p-3">
<img src={m.image} alt={m.name} className="w-12 h-12 rounded-full object-cover flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{m.name}</p>
<p className="text-sm text-gray-500">{m.position}</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<Btn onClick={() => setEditing(m)}>Edit</Btn>
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(m._id)}>Delete</Btn>
</div>
</div>
))}
</div>
</div>
);
};
// ── Events tab ────────────────────────────────────────────────────────────────
const EventsTab: React.FC = () => {
const [events, setEvents] = useState<Event[]>([]);
const [editing, setEditing] = useState<Partial<Event> | null>(null);
const load = useCallback(() => api<Event[]>('/events').then(setEvents), []);
useEffect(() => { load(); }, [load]);
const blank: Partial<Event> = { date:'', title:'', description:'', startTime:'09:00', endTime:'10:00', pointOfContact:'', email:'', address:'' };
const save = async (e: React.FormEvent) => {
e.preventDefault();
if (!editing) return;
const { _id, ...body } = editing as Event;
await api(_id ? `/events/${_id}` : '/events', { method: _id ? 'PUT' : 'POST', body: JSON.stringify(body) });
setEditing(null); load();
};
const del = async (id: string) => {
if (!window.confirm('Delete this event?')) return;
await api(`/events/${id}`, { method: 'DELETE' });
load();
};
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#8C1D40]">Events</h2>
<Btn onClick={() => setEditing(blank)}>+ Add Event</Btn>
</div>
{editing && (
<form onSubmit={save} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Title"><Input required value={editing.title ?? ''} onChange={e => setEditing(p => ({ ...p, title: e.target.value }))} /></Field>
<Field label="Date"><Input required type="date" value={editing.date ?? ''} onChange={e => setEditing(p => ({ ...p, date: e.target.value }))} /></Field>
<Field label="Start Time"><Input required type="time" value={editing.startTime ?? ''} onChange={e => setEditing(p => ({ ...p, startTime: e.target.value }))} /></Field>
<Field label="End Time"><Input required type="time" value={editing.endTime ?? ''} onChange={e => setEditing(p => ({ ...p, endTime: e.target.value }))} /></Field>
<Field label="Contact Name"><Input required value={editing.pointOfContact ?? ''} onChange={e => setEditing(p => ({ ...p, pointOfContact: e.target.value }))} /></Field>
<Field label="Contact Email"><Input required type="email" value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} /></Field>
<Field label="Address"><Input required value={editing.address ?? ''} onChange={e => setEditing(p => ({ ...p, address: e.target.value }))} /></Field>
<Field label="Description"><Textarea required rows={2} value={editing.description ?? ''} onChange={e => setEditing(p => ({ ...p, description: e.target.value }))} /></Field>
<div className="md:col-span-2 flex gap-2">
<Btn type="submit">Save</Btn>
<Btn color="bg-gray-400 hover:bg-gray-500" onClick={() => setEditing(null)}>Cancel</Btn>
</div>
</form>
)}
<div className="space-y-2">
{events.map(ev => (
<div key={ev._id} className="flex items-center justify-between bg-white border rounded p-3">
<div>
<p className="font-medium">{ev.title}</p>
<p className="text-sm text-gray-500">{ev.date} · {ev.startTime}{ev.endTime} · {ev.address}</p>
</div>
<div className="flex gap-2">
<Btn onClick={() => setEditing(ev)}>Edit</Btn>
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(ev._id)}>Delete</Btn>
</div>
</div>
))}
</div>
</div>
);
};
// ── Gallery tab ───────────────────────────────────────────────────────────────
const GalleryTab: React.FC = () => {
const [items, setItems] = useState<GalleryItem[]>([]);
const [imageFile, setImageFile] = useState<File | null>(null);
const [alt, setAlt] = useState('MCDi community event photo');
const load = useCallback(() => api<GalleryItem[]>('/gallery').then(setItems), []);
useEffect(() => { load(); }, [load]);
const add = async (e: React.FormEvent) => {
e.preventDefault();
if (!imageFile) return;
const form = new FormData();
form.append('image', imageFile);
form.append('alt', alt);
const token = getToken();
await fetch('/api/gallery', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });
setImageFile(null); setAlt('MCDi community event photo'); load();
};
const del = async (id: string) => {
if (!window.confirm('Remove this photo?')) return;
await api(`/gallery/${id}`, { method: 'DELETE' });
load();
};
return (
<div>
<h2 className="text-xl font-bold text-[#8C1D40] mb-4">Gallery</h2>
<form onSubmit={add} className="bg-gray-50 border rounded p-4 mb-4 flex flex-wrap gap-3 items-end">
<Field label="Photo"><Input required type="file" accept="image/*" onChange={e => setImageFile(e.target.files?.[0] ?? null)} /></Field>
<Field label="Alt text"><Input value={alt} onChange={e => setAlt(e.target.value)} /></Field>
<Btn type="submit" disabled={!imageFile}>Upload Photo</Btn>
</form>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-3">
{items.map(item => (
<div key={item._id} className="relative group">
<img src={item.imageUrl} alt={item.alt} className="w-full aspect-square object-cover rounded" />
<button onClick={() => del(item._id)} className="absolute top-1 right-1 bg-red-600 text-white rounded-full w-6 h-6 text-xs opacity-0 group-hover:opacity-100 transition"></button>
</div>
))}
</div>
</div>
);
};
// ── Sponsors tab ──────────────────────────────────────────────────────────────
const SponsorsTab: React.FC = () => {
const [sponsors, setSponsors] = useState<Sponsor[]>([]);
const [editing, setEditing] = useState<Partial<Sponsor> | null>(null);
const [logoFile, setLogoFile] = useState<File | null>(null);
const load = useCallback(() => api<Sponsor[]>('/sponsors').then(setSponsors), []);
useEffect(() => { load(); }, [load]);
const save = async (e: React.FormEvent) => {
e.preventDefault();
if (!editing) return;
const form = new FormData();
Object.entries(editing).forEach(([k, v]) => { if (k !== '_id' && v !== undefined) form.append(k, String(v)); });
if (logoFile) form.append('logo', logoFile);
const token = getToken();
const url = editing._id ? `/api/sponsors/${editing._id}` : '/api/sponsors';
await fetch(url, { method: editing._id ? 'PUT' : 'POST', headers: { Authorization: `Bearer ${token}` }, body: form });
setEditing(null); setLogoFile(null); load();
};
const del = async (id: string) => {
if (!window.confirm('Remove this sponsor?')) return;
await api(`/sponsors/${id}`, { method: 'DELETE' });
load();
};
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#8C1D40]">Sponsors</h2>
<Btn onClick={() => setEditing({ name:'', logoPath:'', description:'', websiteUrl:'', facebookUrl:'', instagramUrl:'', order:0 })}>+ Add Sponsor</Btn>
</div>
{editing && (
<form onSubmit={save} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Name"><Input required value={editing.name ?? ''} onChange={e => setEditing(p => ({ ...p, name: e.target.value }))} /></Field>
<Field label="Display Order"><Input type="number" value={editing.order ?? 0} onChange={e => setEditing(p => ({ ...p, order: +e.target.value }))} /></Field>
<Field label="Logo (upload or keep existing)">
<Input type="file" accept="image/*" onChange={e => setLogoFile(e.target.files?.[0] ?? null)} />
{editing.logoPath && !logoFile && <p className="text-xs text-gray-500 mt-1">Current: {editing.logoPath}</p>}
</Field>
<Field label="Description"><Textarea required rows={3} value={editing.description ?? ''} onChange={e => setEditing(p => ({ ...p, description: e.target.value }))} /></Field>
<Field label="Website URL"><Input type="url" value={editing.websiteUrl ?? ''} onChange={e => setEditing(p => ({ ...p, websiteUrl: e.target.value }))} /></Field>
<Field label="Facebook URL"><Input type="url" value={editing.facebookUrl ?? ''} onChange={e => setEditing(p => ({ ...p, facebookUrl: e.target.value }))} /></Field>
<Field label="Instagram URL"><Input type="url" value={editing.instagramUrl ?? ''} onChange={e => setEditing(p => ({ ...p, instagramUrl: e.target.value }))} /></Field>
<div className="md:col-span-2 flex gap-2">
<Btn type="submit">Save</Btn>
<Btn color="bg-gray-400 hover:bg-gray-500" onClick={() => { setEditing(null); setLogoFile(null); }}>Cancel</Btn>
</div>
</form>
)}
<div className="space-y-2">
{sponsors.map(s => (
<div key={s._id} className="flex items-center gap-3 bg-white border rounded p-3">
<img src={s.logoPath} alt={s.name} className="w-16 h-10 object-contain flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="font-medium truncate">{s.name}</p>
<p className="text-sm text-gray-500 truncate">{s.websiteUrl}</p>
</div>
<div className="flex gap-2 flex-shrink-0">
<Btn onClick={() => setEditing(s)}>Edit</Btn>
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(s._id)}>Delete</Btn>
</div>
</div>
))}
</div>
</div>
);
};
// ── Minutes tab ───────────────────────────────────────────────────────────────
const MinutesTab: React.FC = () => {
const [minutes, setMinutes] = useState<Minute[]>([]);
const [pdfFile, setPdfFile] = useState<File | null>(null);
const [form, setForm] = useState({ date: '', meetingType: 'Board Meeting', location: '' });
const load = useCallback(() => api<Minute[]>('/minutes').then(setMinutes), []);
useEffect(() => { load(); }, [load]);
const add = async (e: React.FormEvent) => {
e.preventDefault();
if (!pdfFile) return;
const fd = new FormData();
Object.entries(form).forEach(([k, v]) => fd.append(k, v));
fd.append('file', pdfFile);
const token = getToken();
await fetch('/api/minutes', { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: fd });
setPdfFile(null); setForm({ date:'', meetingType:'Board Meeting', location:'' }); load();
};
const del = async (id: string) => {
if (!window.confirm('Delete these minutes?')) return;
await api(`/minutes/${id}`, { method: 'DELETE' });
load();
};
return (
<div>
<h2 className="text-xl font-bold text-[#8C1D40] mb-4">Meeting Minutes</h2>
<form onSubmit={add} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="Date (MM/DD/YYYY)"><Input required value={form.date} onChange={e => setForm(p => ({ ...p, date: e.target.value }))} placeholder="01/15/2026" /></Field>
<Field label="Meeting Type">
<select className="w-full border rounded px-2 py-1.5 text-sm" value={form.meetingType} onChange={e => setForm(p => ({ ...p, meetingType: e.target.value }))}>
<option>Board Meeting</option>
<option>Membership Meeting</option>
<option>Special Meeting</option>
</select>
</Field>
<Field label="Location"><Input required value={form.location} onChange={e => setForm(p => ({ ...p, location: e.target.value }))} /></Field>
<Field label="PDF File"><Input required type="file" accept=".pdf" onChange={e => setPdfFile(e.target.files?.[0] ?? null)} /></Field>
<div className="md:col-span-2">
<Btn type="submit" disabled={!pdfFile}>Upload Minutes</Btn>
</div>
</form>
<div className="space-y-2">
{minutes.map(m => (
<div key={m._id} className="flex items-center justify-between bg-white border rounded p-3">
<div>
<p className="font-medium">{m.meetingType} {m.date}</p>
<p className="text-sm text-gray-500">{m.location}</p>
</div>
<div className="flex gap-2">
<a href={m.fileUrl} target="_blank" rel="noreferrer" className="bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 rounded transition">View</a>
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(m._id)}>Delete</Btn>
</div>
</div>
))}
</div>
</div>
);
};
// ── Members tab ───────────────────────────────────────────────────────────────
const MembersTab: React.FC = () => {
const [members, setMembers] = useState<Member[]>([]);
const [editing, setEditing] = useState<Partial<Member> | null>(null);
const [search, setSearch] = useState('');
const load = useCallback(() => api<Member[]>('/members').then(setMembers), []);
useEffect(() => { load(); }, [load]);
const blank: Partial<Member> = { firstName:'', lastName:'', email:'', phone:'', address:'', membershipType:'regular', status:'active', joinDate: new Date().toISOString().split('T')[0], notes:'' };
const save = async (e: React.FormEvent) => {
e.preventDefault();
if (!editing) return;
const { _id, ...body } = editing as Member;
await api(_id ? `/members/${_id}` : '/members', { method: _id ? 'PUT' : 'POST', body: JSON.stringify(body) });
setEditing(null); load();
};
const del = async (id: string) => {
if (!window.confirm('Remove this member?')) return;
await api(`/members/${id}`, { method: 'DELETE' });
load();
};
const filtered = members.filter(m =>
`${m.firstName} ${m.lastName} ${m.email}`.toLowerCase().includes(search.toLowerCase())
);
return (
<div>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-bold text-[#8C1D40]">Members ({filtered.length})</h2>
<Btn onClick={() => setEditing(blank)}>+ Add Member</Btn>
</div>
<Input placeholder="Search by name or email…" value={search} onChange={e => setSearch(e.target.value)} className="mb-4" />
{editing && (
<form onSubmit={save} className="bg-gray-50 border rounded p-4 mb-4 grid grid-cols-1 md:grid-cols-2 gap-3">
<Field label="First Name"><Input required value={editing.firstName ?? ''} onChange={e => setEditing(p => ({ ...p, firstName: e.target.value }))} /></Field>
<Field label="Last Name"><Input required value={editing.lastName ?? ''} onChange={e => setEditing(p => ({ ...p, lastName: e.target.value }))} /></Field>
<Field label="Email"><Input required type="email" value={editing.email ?? ''} onChange={e => setEditing(p => ({ ...p, email: e.target.value }))} /></Field>
<Field label="Phone"><Input value={editing.phone ?? ''} onChange={e => setEditing(p => ({ ...p, phone: e.target.value }))} /></Field>
<Field label="Address"><Input value={editing.address ?? ''} onChange={e => setEditing(p => ({ ...p, address: e.target.value }))} /></Field>
<Field label="Join Date"><Input type="date" value={typeof editing.joinDate === 'string' ? editing.joinDate.split('T')[0] : ''} onChange={e => setEditing(p => ({ ...p, joinDate: e.target.value }))} /></Field>
<Field label="Membership Type">
<select className="w-full border rounded px-2 py-1.5 text-sm" value={editing.membershipType ?? 'regular'} onChange={e => setEditing(p => ({ ...p, membershipType: e.target.value }))}>
<option value="regular">Regular</option>
<option value="honorary">Honorary</option>
<option value="board">Board</option>
</select>
</Field>
<Field label="Status">
<select className="w-full border rounded px-2 py-1.5 text-sm" value={editing.status ?? 'active'} onChange={e => setEditing(p => ({ ...p, status: e.target.value }))}>
<option value="active">Active</option>
<option value="inactive">Inactive</option>
</select>
</Field>
<Field label="Notes"><Textarea rows={2} value={editing.notes ?? ''} onChange={e => setEditing(p => ({ ...p, notes: e.target.value }))} /></Field>
<div className="md:col-span-2 flex gap-2">
<Btn type="submit">Save</Btn>
<Btn color="bg-gray-400 hover:bg-gray-500" onClick={() => setEditing(null)}>Cancel</Btn>
</div>
</form>
)}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-[#8C1D40] text-white">
<tr>
{['Name','Email','Type','Status','Joined',''].map(h => (
<th key={h} className="py-2 px-3 text-left">{h}</th>
))}
</tr>
</thead>
<tbody>
{filtered.map(m => (
<tr key={m._id} className="border-b hover:bg-gray-50">
<td className="py-2 px-3">{m.firstName} {m.lastName}</td>
<td className="py-2 px-3">{m.email}</td>
<td className="py-2 px-3 capitalize">{m.membershipType}</td>
<td className="py-2 px-3"><span className={`px-2 py-0.5 rounded-full text-xs ${m.status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-600'}`}>{m.status}</span></td>
<td className="py-2 px-3">{m.joinDate ? new Date(m.joinDate).toLocaleDateString() : ''}</td>
<td className="py-2 px-3">
<div className="flex gap-2">
<Btn onClick={() => setEditing(m)}>Edit</Btn>
<Btn color="bg-red-600 hover:bg-red-700" onClick={() => del(m._id)}>Delete</Btn>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
};
// ── Main dashboard ────────────────────────────────────────────────────────────
const TABS: { id: Tab; label: string }[] = [
{ id: 'board', label: 'Board' },
{ id: 'events', label: 'Events' },
{ id: 'gallery', label: 'Gallery' },
{ id: 'sponsors',label: 'Sponsors' },
{ id: 'minutes', label: 'Minutes' },
{ id: 'members', label: 'Members' },
];
const Dashboard: React.FC<{ user: User; onLogout: () => void }> = ({ user, onLogout }) => {
const [tab, setTab] = useState<Tab>('board');
return (
<div className="min-h-screen bg-gray-100">
<header className="bg-[#8C1D40] text-white px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="font-bold text-lg">MCDi Management</span>
<span className="text-sm opacity-75"> {user.name}</span>
</div>
<div className="flex items-center gap-3">
<a href="/" className="text-sm underline opacity-75 hover:opacity-100"> Public Site</a>
<button onClick={onLogout} className="text-sm bg-white/20 hover:bg-white/30 px-3 py-1 rounded transition">Sign Out</button>
</div>
</header>
<div className="flex border-b bg-white px-4 gap-1 overflow-x-auto">
{TABS.map(t => (
<button
key={t.id}
onClick={() => setTab(t.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition whitespace-nowrap ${tab === t.id ? 'border-[#8C1D40] text-[#8C1D40]' : 'border-transparent text-gray-600 hover:text-gray-900'}`}
>
{t.label}
</button>
))}
</div>
<main className="max-w-6xl mx-auto p-4 sm:p-6">
{tab === 'board' && <BoardTab />}
{tab === 'events' && <EventsTab />}
{tab === 'gallery' && <GalleryTab />}
{tab === 'sponsors' && <SponsorsTab />}
{tab === 'minutes' && <MinutesTab />}
{tab === 'members' && <MembersTab />}
</main>
</div>
);
};
// ── Entry point ───────────────────────────────────────────────────────────────
const Manage: React.FC = () => {
const [token, setToken] = useState<string | null>(getToken);
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
if (!token) return;
api<User>('/auth/me').then(setUser).catch(() => { localStorage.removeItem('mcdi_token'); setToken(null); });
}, [token]);
const handleLogin = (t: string, u: User) => {
localStorage.setItem('mcdi_token', t);
setToken(t); setUser(u);
};
const handleLogout = () => {
localStorage.removeItem('mcdi_token');
setToken(null); setUser(null);
};
if (!token || !user) return <Login onLogin={handleLogin} />;
return <Dashboard user={user} onLogout={handleLogout} />;
};
export default Manage;

View file

@ -10,36 +10,6 @@ interface BoardMember {
bio: string;
}
const boardMembers: BoardMember[] = [
{
name: "Rita Brandborg",
position: "President",
email: "rita.brandborg@deafmissoula.org",
image: "/headshots/rita.jpg",
bio: "Meet Rita Brandborg, the proud mom of two tiny tornados (Levi and Josie) who keep her on her toes! When she's not wrangling her mini-me's or teaching Sign 102 at the University of Montana, you can find Rita snapping photos with her trusty camera or reeling in the big ones fly fishing. This Deaf momma is all about spreading joy, love, and a little bit of chaos wherever she goes! With a heart full of laughter and a mind full of stories, Rita is living life to the fullest - and loving every minute of it!"
},
{
name: "Skyla Wilson",
position: "Vice President",
email: "skyla.wilson@deafmissoula.org",
image: "/headshots/skyla.jpg",
bio: "Meet Skyla Wilson, the river soul with a heart tuned to justice. Shes a fierce advocate for the Deaf community, always ready to stand up, speak out (in her own way), and make space for voices that often go unheard. When shes not pushing for accessibility and equity, youll find her floating peacefully down a river, soaking in the calm before diving back into the work. Skyla blends quiet power with unstoppable purpose—equal parts grace, grit, and a deep love for her community."
},
{
name: "Tessa Williams",
position: "Secretary",
email: "tessa.williams@deafmissoula.org",
image: "/headshots/tessa.jpg",
bio: "Meet Tessa Williams, the sparkplug of the University of Montana Social Worker's program. By day, she's a social work student with a heart of gold; by night, she's a sass-spewing machine who can take down anyone with her quick wit and sharp humor. When she's not advocating for Deaf rights or making her friends laugh, Tessa can be found sipping on a matcha latte with honey (her happy place). Don't mess with this tiny firecracker - she's got love for all, except maybe for those who can't keep up with her sass."
},
{
name: "Aubz M",
position: "Treasurer",
email: "aubz.m@deafmissoula.org",
image: "/headshots/aubz.jpg",
bio: "Aubz M is a shining star at the local veterinary hospital, where they're paving their way to become a certified vet tech. This animal whisperer's heart beats for creatures great and small, but it also swells with love for self-care and coziness. When they're not snuggling Kanga (their adorable pup), Aubz can be found soaking up wellness vibes or cracking jokes that'll leave you giggling. With a quick wit and thoughtful spirit, this Deaf rockstar is spreading kindness and compassion wherever they go!"
}
];
const shuffleArray = (array: BoardMember[]) => {
const shuffled = [...array];
@ -60,7 +30,9 @@ const MeetBoard: React.FC = () => {
const controlsTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => {
setShuffledMembers(shuffleArray(boardMembers));
fetch('/api/board')
.then(r => r.json())
.then((members: BoardMember[]) => setShuffledMembers(shuffleArray(members)));
}, []);
const togglePlay = () => {

View file

@ -8,56 +8,19 @@ interface MinuteEntry {
fileUrl: string;
}
const minutesData: MinuteEntry[] = [
{
date: '04/12/2025',
meetingType: 'Membership Meeting',
location: 'The Break Coffee',
fileUrl: '/minutes/minutes-04122025.pdf',
},
{
date: '03/08/2025',
meetingType: 'Board Meeting',
location: 'The Break Espresso',
fileUrl: '/minutes/minutes-03082025.pdf',
},
{
date: '02/08/2025',
meetingType: 'Board Meeting',
location: 'Book Exchange - Liquid Planet',
fileUrl: '/minutes/minutes-02082025.pdf',
},
{
date: '01/11/2025',
meetingType: 'Membership Meeting',
location: 'Black Coffee Roasting Company',
fileUrl: '/minutes/minutes-01112025.pdf',
},
{
date: '10/17/2024',
meetingType: 'Board Meeting',
location: 'UC Market (University)',
fileUrl: '/minutes/minutes-10172024.pdf',
},
{
date: '09/14/2024',
meetingType: 'Membership Meeting',
location: 'Dog Wash Cafe',
fileUrl: '/minutes/minutes-09142024.pdf',
},
{
date: '08/06/2024',
meetingType: 'Membership Meeting',
location: 'The Break Espresso',
fileUrl: '/minutes/minutes-08062024.pdf',
},
];
const Minutes: React.FC = () => {
const [sortColumn, setSortColumn] = useState<keyof MinuteEntry>('date');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const [selectedMinutes, setSelectedMinutes] = useState<MinuteEntry | null>(null);
const [isMobile, setIsMobile] = useState(false);
const [minutesData, setMinutesData] = useState<MinuteEntry[]>([]);
useEffect(() => {
fetch('/api/minutes')
.then(r => r.json())
.then(setMinutesData);
}, []);
useEffect(() => {
const checkMobile = () => {

View file

@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
interface SponsorCardProps {
@ -103,37 +103,17 @@ interface SponsorInfo {
const Sponsors: React.FC = () => {
const [selectedSponsor, setSelectedSponsor] = useState<string | null>(null);
const [sponsorInfo, setSponsorInfo] = useState<Record<string, SponsorInfo>>({});
const sponsorInfo: Record<string, SponsorInfo> = {
"Drum Coffee": {
description: "Drum Coffee partnered with MCDi to provide ASL lessons on coffee-related signology, enhancing their ability to serve Deaf community members. This initiative promotes inclusivity and improves communication in their bustling cafe environment.",
logoPath: "/sponsors/drum-coffee-missoula.png",
websiteUrl: "https://drumcoffeeroasting.com/",
facebookUrl: "https://m.facebook.com/drumcoffeemt",
instagramUrl: "https://www.instagram.com/drumcoffee/"
},
"Imagine Nation Brewing": {
description: "Imagine Nation Brewing hosted a vibrant fundraising event, donating proceeds to MCDi. Their generous support not only raised funds but also raised awareness about the Deaf community, making a significant impact on our mission.",
logoPath: "/sponsors/imagine-nation-brewing.png",
websiteUrl: "https://imaginenationbrewing.com/",
facebookUrl: "https://m.facebook.com/ImagineNationBrewing",
instagramUrl: "https://www.instagram.com/imaginenationbrewingco"
},
"GILD Brewing": {
description: "GILD Brewing organized a community-focused fundraiser for MCDi, combining craft beer tasting with Deaf culture education. Their event not only raised funds but also fostered a deeper understanding of Deaf culture, making a significant impact on our mission.",
logoPath: "/sponsors/gild-brewing.png",
websiteUrl: "https://www.gildbrewing.com/",
facebookUrl: "https://m.facebook.com/gildbrewing/",
instagramUrl: "https://www.instagram.com/gildbrewing/"
},
"SBS Solar": {
description: "SBS Solar supports MCDi's mission to serve the Deaf community in Missoula. Their commitment to renewable energy and community engagement aligns with our values of accessibility and sustainability.",
logoPath: "/sponsors/sbs-solar.png",
websiteUrl: "https://www.sbslink.com/",
facebookUrl: "https://www.facebook.com/sbssolar",
instagramUrl: "https://www.instagram.com/sbssolarmt/"
},
};
useEffect(() => {
fetch('/api/sponsors')
.then(r => r.json())
.then((sponsors: Array<{ name: string } & SponsorInfo>) => {
const record: Record<string, SponsorInfo> = {};
sponsors.forEach(s => { record[s.name] = s; });
setSponsorInfo(record);
});
}, []);
return (
<div className="container mx-auto px-4 py-8">

View file

@ -1,4 +1,3 @@
version: '3'
services:
app:
build:
@ -7,17 +6,57 @@ services:
args:
CACHEBUST: ${CACHEBUST:-1}
pull_policy: build
restart: unless-stopped
ports:
- "801:801"
environment:
- NODE_ENV=production
- VERSION=1.11
- VERSION=2.0
env_file:
- .env
- stack.env
volumes:
- uploads-data:/app/uploads
depends_on:
mongodb:
condition: service_healthy
networks:
- caddy_network
- internal
healthcheck:
test: ["CMD", "wget", "-qO-", "http://localhost:801/api/events"]
interval: 30s
timeout: 10s
retries: 3
mongodb:
image: mongo:8.0
restart: unless-stopped
volumes:
- mongo-data:/data/db
networks:
- internal
env_file:
- stack.env
environment:
- MONGO_INITDB_ROOT_USERNAME=${MONGO_USER}
- MONGO_INITDB_ROOT_PASSWORD=${MONGO_PASSWORD}
- MONGO_INITDB_DATABASE=mcdi
command: ["--auth", "--bind_ip_all"]
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017 --quiet
interval: 30s
timeout: 10s
retries: 5
networks:
caddy_network:
external: true
internal:
driver: bridge
volumes:
mongo-data:
name: mcdi-mongo-data
uploads-data:
name: mcdi-uploads-data

View file

@ -1,29 +1,34 @@
{
"name": "mcdi-website-server",
"version": "1.0.0",
"version": "2.0.0",
"private": true,
"dependencies": {
"express": "^4.18.2",
"dotenv": "^16.3.1",
"mongoose": "^7.6.3",
"cors": "^2.8.5",
"body-parser": "^1.20.2",
"nodemailer": "^6.9.1"
},
"devDependencies": {
"@types/cors": "^2.8.12",
"@types/express": "^4.17.20",
"@types/node": "^20.8.9",
"@types/mongodb": "^4.0.7",
"@types/nodemailer": "^6.4.0",
"nodemon": "^3.0.1",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"start": "node dist/index.js",
"dev": "nodemon src/index.ts",
"dev": "tsx watch src/index.ts",
"build": "tsc",
"postinstall": "npm run build"
"seed": "tsx src/seed.ts"
},
"dependencies": {
"argon2": "^0.43.0",
"cors": "^2.8.5",
"dotenv": "^16.5.0",
"express": "^5.1.0",
"jose": "^5.10.0",
"mongoose": "^8.13.0",
"multer": "^1.4.5-lts.1",
"nodemailer": "^6.9.16",
"zod": "^3.24.2"
},
"devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^5.0.1",
"@types/multer": "^1.4.12",
"@types/node": "^22.15.0",
"@types/nodemailer": "^6.4.17",
"tsx": "^4.19.3",
"typescript": "^5.8.3"
}
}
}

9
server/src/db.ts Normal file
View file

@ -0,0 +1,9 @@
import mongoose from 'mongoose';
export async function connectDB(): Promise<void> {
const uri = process.env.MONGODB_URI;
if (!uri) throw new Error('MONGODB_URI is not defined');
await mongoose.connect(uri);
console.log('MongoDB connected');
}

View file

@ -1,41 +1,51 @@
import 'dotenv/config';
import express from 'express';
import path from 'path';
import fs from 'fs';
import cors from 'cors';
import bodyParser from 'body-parser';
import sendEmail from './api/sendEmail';
import path from 'path';
import { connectDB } from './db';
import { errorHandler } from './middleware/errorHandler';
import authRoutes from './routes/auth';
import boardRoutes from './routes/board';
import eventRoutes from './routes/events';
import galleryRoutes from './routes/gallery';
import sponsorRoutes from './routes/sponsors';
import minuteRoutes from './routes/minutes';
import memberRoutes from './routes/members';
import emailRoutes from './routes/email';
const app = express();
const port = process.env.PORT || 801;
const port = process.env.PORT ?? 801;
app.use(cors());
app.use(bodyParser.json());
app.use(express.static(path.join(__dirname, '../../client/build')));
app.use(express.json());
app.post('/api/sendEmail', sendEmail);
// Serve uploaded files
app.use('/uploads', express.static(path.join(__dirname, '../../uploads')));
app.get('/api/calendar-events', (req, res) => {
try {
const calendarDataPath = path.join(__dirname, '../calendarData.json');
if (fs.existsSync(calendarDataPath)) {
const calendarData = fs.readFileSync(calendarDataPath, 'utf-8');
const events = JSON.parse(calendarData);
res.json(events);
} else {
// If file doesn't exist, return an empty array
res.json([]);
}
} catch (error: unknown) {
console.error('Error reading calendar events:', error);
res.status(500).json({ error: 'Failed to fetch calendar events' });
}
// API routes
app.use('/api/auth', authRoutes);
app.use('/api/board', boardRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/gallery', galleryRoutes);
app.use('/api/sponsors', sponsorRoutes);
app.use('/api/minutes', minuteRoutes);
app.use('/api/members', memberRoutes);
app.use('/api', emailRoutes);
// Serve built React app
const clientBuild = path.join(__dirname, '../../client/build');
app.use(express.static(clientBuild));
app.get('*', (_req, res) => {
res.sendFile(path.join(clientBuild, 'index.html'));
});
// Serve React app for all other routes
app.get('*', (req, res) => {
res.sendFile(path.join(__dirname, '../../client/build/index.html'));
});
app.use(errorHandler);
app.listen(port, () => {
console.log(`Server is running on port ${port}`);
});
connectDB()
.then(() => {
app.listen(port, () => console.log(`Server running on port ${port}`));
})
.catch((err) => {
console.error('Failed to connect to MongoDB:', err);
process.exit(1);
});

View file

@ -0,0 +1,27 @@
import { Request, Response, NextFunction } from 'express';
import { jwtVerify } from 'jose';
export interface AuthRequest extends Request {
userId?: string;
userRole?: string;
}
const secret = () => new TextEncoder().encode(process.env.JWT_SECRET ?? 'fallback-dev-secret');
export async function requireAuth(req: AuthRequest, res: Response, next: NextFunction): Promise<void> {
const header = req.headers.authorization;
if (!header?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Unauthorized' });
return;
}
try {
const token = header.slice(7);
const { payload } = await jwtVerify(token, secret());
req.userId = payload.userId as string;
req.userRole = payload.role as string;
next();
} catch {
res.status(401).json({ error: 'Invalid or expired token' });
}
}

View file

@ -0,0 +1,24 @@
import { Request, Response, NextFunction } from 'express';
import { ZodError } from 'zod';
export function errorHandler(
err: unknown,
req: Request,
res: Response,
next: NextFunction
): void {
if (err instanceof ZodError) {
res.status(400).json({ error: 'Validation error', details: err.flatten() });
return;
}
if (err instanceof Error) {
const status = (err as NodeJS.ErrnoException & { status?: number }).status ?? 500;
const message = status < 500 ? err.message : 'Internal server error';
if (status >= 500) console.error(err);
res.status(status).json({ error: message });
return;
}
res.status(500).json({ error: 'Internal server error' });
}

View file

@ -0,0 +1,33 @@
import multer from 'multer';
import path from 'path';
import fs from 'fs';
const uploadsBase = path.join(__dirname, '../../../uploads');
function storageFor(subdir: string) {
return multer.diskStorage({
destination: (_req, _file, cb) => {
const dir = path.join(uploadsBase, subdir);
fs.mkdirSync(dir, { recursive: true });
cb(null, dir);
},
filename: (_req, file, cb) => {
const unique = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
cb(null, unique + path.extname(file.originalname));
},
});
}
const imageFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
const allowed = /jpeg|jpg|png|gif|webp/;
cb(null, allowed.test(path.extname(file.originalname).toLowerCase()) && allowed.test(file.mimetype));
};
const pdfFilter: multer.Options['fileFilter'] = (_req, file, cb) => {
cb(null, file.mimetype === 'application/pdf' || path.extname(file.originalname).toLowerCase() === '.pdf');
};
export const uploadHeadshot = multer({ storage: storageFor('headshots'), fileFilter: imageFilter, limits: { fileSize: 5 * 1024 * 1024 } });
export const uploadPhoto = multer({ storage: storageFor('photos'), fileFilter: imageFilter, limits: { fileSize: 10 * 1024 * 1024 } });
export const uploadLogo = multer({ storage: storageFor('sponsors'), fileFilter: imageFilter, limits: { fileSize: 5 * 1024 * 1024 } });
export const uploadMinutes = multer({ storage: storageFor('minutes'), fileFilter: pdfFilter, limits: { fileSize: 20 * 1024 * 1024 } });

View file

@ -0,0 +1,21 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IBoardMember extends Document {
name: string;
position: string;
email: string;
image: string;
bio: string;
order: number;
}
const BoardMemberSchema = new Schema<IBoardMember>({
name: { type: String, required: true, trim: true },
position: { type: String, required: true, trim: true },
email: { type: String, required: true, trim: true, lowercase: true },
image: { type: String, required: true },
bio: { type: String, required: true },
order: { type: Number, default: 0 },
}, { timestamps: true });
export const BoardMember = mongoose.model<IBoardMember>('BoardMember', BoardMemberSchema);

View file

@ -0,0 +1,25 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IEvent extends Document {
date: string;
title: string;
description: string;
startTime: string;
endTime: string;
pointOfContact: string;
email: string;
address: string;
}
const EventSchema = new Schema<IEvent>({
date: { type: String, required: true },
title: { type: String, required: true, trim: true },
description: { type: String, required: true, trim: true },
startTime: { type: String, required: true },
endTime: { type: String, required: true },
pointOfContact: { type: String, required: true, trim: true },
email: { type: String, required: true, trim: true, lowercase: true },
address: { type: String, required: true, trim: true },
}, { timestamps: true });
export const Event = mongoose.model<IEvent>('Event', EventSchema);

View file

@ -0,0 +1,15 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IGalleryItem extends Document {
imageUrl: string;
alt: string;
order: number;
}
const GalleryItemSchema = new Schema<IGalleryItem>({
imageUrl: { type: String, required: true },
alt: { type: String, required: true, trim: true },
order: { type: Number, default: 0 },
}, { timestamps: true });
export const GalleryItem = mongoose.model<IGalleryItem>('GalleryItem', GalleryItemSchema);

View file

@ -0,0 +1,27 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IMember extends Document {
firstName: string;
lastName: string;
email: string;
phone?: string;
address?: string;
membershipType: 'regular' | 'honorary' | 'board';
status: 'active' | 'inactive';
joinDate: Date;
notes?: string;
}
const MemberSchema = new Schema<IMember>({
firstName: { type: String, required: true, trim: true },
lastName: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, trim: true, lowercase: true },
phone: { type: String },
address: { type: String },
membershipType: { type: String, enum: ['regular', 'honorary', 'board'], default: 'regular' },
status: { type: String, enum: ['active', 'inactive'], default: 'active' },
joinDate: { type: Date, required: true, default: Date.now },
notes: { type: String },
}, { timestamps: true });
export const Member = mongoose.model<IMember>('Member', MemberSchema);

View file

@ -0,0 +1,17 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IMinute extends Document {
date: string;
meetingType: string;
location: string;
fileUrl: string;
}
const MinuteSchema = new Schema<IMinute>({
date: { type: String, required: true },
meetingType: { type: String, required: true, trim: true },
location: { type: String, required: true, trim: true },
fileUrl: { type: String, required: true },
}, { timestamps: true });
export const Minute = mongoose.model<IMinute>('Minute', MinuteSchema);

View file

@ -0,0 +1,23 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface ISponsor extends Document {
name: string;
logoPath: string;
description: string;
websiteUrl?: string;
facebookUrl?: string;
instagramUrl?: string;
order: number;
}
const SponsorSchema = new Schema<ISponsor>({
name: { type: String, required: true, trim: true },
logoPath: { type: String, required: true },
description: { type: String, required: true, trim: true },
websiteUrl: { type: String },
facebookUrl: { type: String },
instagramUrl: { type: String },
order: { type: Number, default: 0 },
}, { timestamps: true });
export const Sponsor = mongoose.model<ISponsor>('Sponsor', SponsorSchema);

20
server/src/models/User.ts Normal file
View file

@ -0,0 +1,20 @@
import mongoose, { Document, Schema } from 'mongoose';
export interface IUser extends Document {
username: string;
password: string;
name: string;
email: string;
role: 'admin' | 'board';
createdAt: Date;
}
const UserSchema = new Schema<IUser>({
username: { type: String, required: true, unique: true, trim: true },
password: { type: String, required: true },
name: { type: String, required: true, trim: true },
email: { type: String, required: true, unique: true, trim: true, lowercase: true },
role: { type: String, enum: ['admin', 'board'], default: 'board' },
}, { timestamps: true });
export const User = mongoose.model<IUser>('User', UserSchema);

48
server/src/routes/auth.ts Normal file
View file

@ -0,0 +1,48 @@
import { Router } from 'express';
import { SignJWT } from 'jose';
import * as argon2 from 'argon2';
import { z } from 'zod';
import { User } from '../models/User';
import { requireAuth, AuthRequest } from '../middleware/auth';
const router = Router();
const loginSchema = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
router.post('/login', async (req, res) => {
const { username, password } = loginSchema.parse(req.body);
const user = await User.findOne({ username });
if (!user) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
const valid = await argon2.verify(user.password, password);
if (!valid) {
res.status(401).json({ error: 'Invalid credentials' });
return;
}
const secret = new TextEncoder().encode(process.env.JWT_SECRET ?? 'fallback-dev-secret');
const token = await new SignJWT({ userId: user.id, role: user.role })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('7d')
.sign(secret);
res.json({ token, user: { id: user.id, username: user.username, name: user.name, role: user.role } });
});
router.get('/me', requireAuth, async (req: AuthRequest, res) => {
const user = await User.findById(req.userId).select('-password');
if (!user) {
res.status(404).json({ error: 'User not found' });
return;
}
res.json(user);
});
export default router;

View file

@ -0,0 +1,45 @@
import { Router } from 'express';
import { z } from 'zod';
import { BoardMember } from '../models/BoardMember';
import { requireAuth } from '../middleware/auth';
import { uploadHeadshot } from '../middleware/upload';
const router = Router();
const boardSchema = z.object({
name: z.string().min(1),
position: z.string().min(1),
email: z.string().email(),
bio: z.string().min(1),
order: z.coerce.number().optional(),
image: z.string().optional(),
});
router.get('/', async (_req, res) => {
const members = await BoardMember.find().sort({ order: 1, createdAt: 1 });
res.json(members);
});
router.post('/', requireAuth, uploadHeadshot.single('image'), async (req, res) => {
const data = boardSchema.parse(req.body);
const image = req.file ? `/uploads/headshots/${req.file.filename}` : (data.image ?? '');
const member = await BoardMember.create({ ...data, image });
res.status(201).json(member);
});
router.put('/:id', requireAuth, uploadHeadshot.single('image'), async (req, res) => {
const data = boardSchema.partial().parse(req.body);
const update: Record<string, unknown> = { ...data };
if (req.file) update.image = `/uploads/headshots/${req.file.filename}`;
const member = await BoardMember.findByIdAndUpdate(req.params.id, update, { new: true });
if (!member) { res.status(404).json({ error: 'Not found' }); return; }
res.json(member);
});
router.delete('/:id', requireAuth, async (req, res) => {
await BoardMember.findByIdAndDelete(req.params.id);
res.status(204).send();
});
export default router;

View file

@ -0,0 +1,35 @@
import { Router } from 'express';
import { z } from 'zod';
import nodemailer from 'nodemailer';
const router = Router();
const emailSchema = z.object({
name: z.string().min(1),
email: z.string().email(),
message: z.string().min(1),
});
router.post('/sendEmail', async (req, res) => {
const { name, email, message } = emailSchema.parse(req.body);
const transporter = nodemailer.createTransport({
service: 'gmail',
auth: {
user: process.env.GOOGLE_EMAIL,
pass: process.env.GOOGLE_APP_PASSWORD,
},
});
await transporter.sendMail({
from: process.env.GOOGLE_EMAIL,
to: process.env.CONTACT_EMAIL ?? process.env.GOOGLE_EMAIL,
subject: `MCDi Contact Form: ${name}`,
text: `From: ${name} <${email}>\n\n${message}`,
replyTo: email,
});
res.json({ success: true });
});
export default router;

View file

@ -0,0 +1,42 @@
import { Router } from 'express';
import { z } from 'zod';
import { Event } from '../models/Event';
import { requireAuth } from '../middleware/auth';
const router = Router();
const eventSchema = z.object({
date: z.string().min(1),
title: z.string().min(1),
description: z.string().min(1),
startTime: z.string().min(1),
endTime: z.string().min(1),
pointOfContact: z.string().min(1),
email: z.string().email(),
address: z.string().min(1),
});
router.get('/', async (_req, res) => {
const events = await Event.find().sort({ date: 1 });
res.json(events);
});
router.post('/', requireAuth, async (req, res) => {
const data = eventSchema.parse(req.body);
const event = await Event.create(data);
res.status(201).json(event);
});
router.put('/:id', requireAuth, async (req, res) => {
const data = eventSchema.partial().parse(req.body);
const event = await Event.findByIdAndUpdate(req.params.id, data, { new: true });
if (!event) { res.status(404).json({ error: 'Not found' }); return; }
res.json(event);
});
router.delete('/:id', requireAuth, async (req, res) => {
await Event.findByIdAndDelete(req.params.id);
res.status(204).send();
});
export default router;

View file

@ -0,0 +1,37 @@
import { Router } from 'express';
import { z } from 'zod';
import { GalleryItem } from '../models/GalleryItem';
import { requireAuth } from '../middleware/auth';
import { uploadPhoto } from '../middleware/upload';
const router = Router();
router.get('/', async (_req, res) => {
const items = await GalleryItem.find().sort({ order: 1, createdAt: 1 });
res.json(items);
});
router.post('/', requireAuth, uploadPhoto.single('image'), async (req, res) => {
const schema = z.object({ alt: z.string().min(1), order: z.coerce.number().optional() });
const data = schema.parse(req.body);
const imageUrl = req.file
? `/uploads/photos/${req.file.filename}`
: z.string().min(1).parse(req.body.imageUrl);
const item = await GalleryItem.create({ ...data, imageUrl });
res.status(201).json(item);
});
router.put('/:id', requireAuth, async (req, res) => {
const schema = z.object({ alt: z.string().optional(), order: z.coerce.number().optional() });
const data = schema.parse(req.body);
const item = await GalleryItem.findByIdAndUpdate(req.params.id, data, { new: true });
if (!item) { res.status(404).json({ error: 'Not found' }); return; }
res.json(item);
});
router.delete('/:id', requireAuth, async (req, res) => {
await GalleryItem.findByIdAndDelete(req.params.id);
res.status(204).send();
});
export default router;

View file

@ -0,0 +1,43 @@
import { Router } from 'express';
import { z } from 'zod';
import { Member } from '../models/Member';
import { requireAuth } from '../middleware/auth';
const router = Router();
const memberSchema = z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
address: z.string().optional(),
membershipType: z.enum(['regular', 'honorary', 'board']).optional(),
status: z.enum(['active', 'inactive']).optional(),
joinDate: z.string().optional(),
notes: z.string().optional(),
});
router.get('/', requireAuth, async (_req, res) => {
const members = await Member.find().sort({ lastName: 1, firstName: 1 });
res.json(members);
});
router.post('/', requireAuth, async (req, res) => {
const data = memberSchema.parse(req.body);
const member = await Member.create(data);
res.status(201).json(member);
});
router.put('/:id', requireAuth, async (req, res) => {
const data = memberSchema.partial().parse(req.body);
const member = await Member.findByIdAndUpdate(req.params.id, data, { new: true });
if (!member) { res.status(404).json({ error: 'Not found' }); return; }
res.json(member);
});
router.delete('/:id', requireAuth, async (req, res) => {
await Member.findByIdAndDelete(req.params.id);
res.status(204).send();
});
export default router;

View file

@ -0,0 +1,33 @@
import { Router } from 'express';
import { z } from 'zod';
import { Minute } from '../models/Minute';
import { requireAuth } from '../middleware/auth';
import { uploadMinutes } from '../middleware/upload';
const router = Router();
router.get('/', async (_req, res) => {
const minutes = await Minute.find().sort({ date: -1 });
res.json(minutes);
});
router.post('/', requireAuth, uploadMinutes.single('file'), async (req, res) => {
const schema = z.object({
date: z.string().min(1),
meetingType: z.string().min(1),
location: z.string().min(1),
});
const data = schema.parse(req.body);
const fileUrl = req.file
? `/uploads/minutes/${req.file.filename}`
: z.string().min(1).parse(req.body.fileUrl);
const minute = await Minute.create({ ...data, fileUrl });
res.status(201).json(minute);
});
router.delete('/:id', requireAuth, async (req, res) => {
await Minute.findByIdAndDelete(req.params.id);
res.status(204).send();
});
export default router;

View file

@ -0,0 +1,46 @@
import { Router } from 'express';
import { z } from 'zod';
import { Sponsor } from '../models/Sponsor';
import { requireAuth } from '../middleware/auth';
import { uploadLogo } from '../middleware/upload';
const router = Router();
const sponsorSchema = z.object({
name: z.string().min(1),
description: z.string().min(1),
websiteUrl: z.string().url().optional().or(z.literal('')),
facebookUrl: z.string().url().optional().or(z.literal('')),
instagramUrl: z.string().url().optional().or(z.literal('')),
order: z.coerce.number().optional(),
logoPath: z.string().optional(),
});
router.get('/', async (_req, res) => {
const sponsors = await Sponsor.find().sort({ order: 1, createdAt: 1 });
res.json(sponsors);
});
router.post('/', requireAuth, uploadLogo.single('logo'), async (req, res) => {
const data = sponsorSchema.parse(req.body);
const logoPath = req.file ? `/uploads/sponsors/${req.file.filename}` : (data.logoPath ?? '');
const sponsor = await Sponsor.create({ ...data, logoPath });
res.status(201).json(sponsor);
});
router.put('/:id', requireAuth, uploadLogo.single('logo'), async (req, res) => {
const data = sponsorSchema.partial().parse(req.body);
const update: Record<string, unknown> = { ...data };
if (req.file) update.logoPath = `/uploads/sponsors/${req.file.filename}`;
const sponsor = await Sponsor.findByIdAndUpdate(req.params.id, update, { new: true });
if (!sponsor) { res.status(404).json({ error: 'Not found' }); return; }
res.json(sponsor);
});
router.delete('/:id', requireAuth, async (req, res) => {
await Sponsor.findByIdAndDelete(req.params.id);
res.status(204).send();
});
export default router;

89
server/src/seed.ts Normal file
View file

@ -0,0 +1,89 @@
import 'dotenv/config';
import * as argon2 from 'argon2';
import { connectDB } from './db';
import { User } from './models/User';
import { BoardMember } from './models/BoardMember';
import { Event } from './models/Event';
import { GalleryItem } from './models/GalleryItem';
import { Sponsor } from './models/Sponsor';
import { Minute } from './models/Minute';
async function seed() {
await connectDB();
// Admin user
const existing = await User.findOne({ username: 'admin' });
if (!existing) {
const password = await argon2.hash(process.env.ADMIN_PASSWORD ?? 'changeme123!');
await User.create({ username: 'admin', password, name: 'MCDi Admin', email: 'admin@deafmissoula.org', role: 'admin' });
console.log('Created admin user');
}
// Board members
const boardCount = await BoardMember.countDocuments();
if (boardCount === 0) {
await BoardMember.insertMany([
{ name: 'Rita Brandborg', position: 'President', email: 'rita.brandborg@deafmissoula.org', image: '/headshots/rita.jpg', bio: "Meet Rita Brandborg, the proud mom of two tiny tornados (Levi and Josie) who keep her on her toes! When she's not wrangling her mini-me's or teaching Sign 102 at the University of Montana, you can find Rita snapping photos with her trusty camera or reeling in the big ones fly fishing. This Deaf momma is all about spreading joy, love, and a little bit of chaos wherever she goes! With a heart full of laughter and a mind full of stories, Rita is living life to the fullest - and loving every minute of it!", order: 0 },
{ name: 'Skyla Wilson', position: 'Vice President', email: 'skyla.wilson@deafmissoula.org', image: '/headshots/skyla.jpg', bio: "Meet Skyla Wilson, the river soul with a heart tuned to justice. She's a fierce advocate for the Deaf community, always ready to stand up, speak out (in her own way), and make space for voices that often go unheard. When she's not pushing for accessibility and equity, you'll find her floating peacefully down a river, soaking in the calm before diving back into the work. Skyla blends quiet power with unstoppable purpose—equal parts grace, grit, and a deep love for her community.", order: 1 },
{ name: 'Tessa Williams', position: 'Secretary', email: 'tessa.williams@deafmissoula.org', image: '/headshots/tessa.jpg', bio: "Meet Tessa Williams, the sparkplug of the University of Montana Social Worker's program. By day, she's a social work student with a heart of gold; by night, she's a sass-spewing machine who can take down anyone with her quick wit and sharp humor. When she's not advocating for Deaf rights or making her friends laugh, Tessa can be found sipping on a matcha latte with honey (her happy place). Don't mess with this tiny firecracker - she's got love for all, except maybe for those who can't keep up with her sass.", order: 2 },
{ name: 'Aubz M', position: 'Treasurer', email: 'aubz.m@deafmissoula.org', image: '/headshots/aubz.jpg', bio: "Aubz M is a shining star at the local veterinary hospital, where they're paving their way to become a certified vet tech. This animal whisperer's heart beats for creatures great and small, but it also swells with love for self-care and coziness. When they're not snuggling Kanga (their adorable pup), Aubz can be found soaking up wellness vibes or cracking jokes that'll leave you giggling. With a quick wit and thoughtful spirit, this Deaf rockstar is spreading kindness and compassion wherever they go!", order: 3 },
]);
console.log('Seeded board members');
}
// Events
const eventCount = await Event.countDocuments();
if (eventCount === 0) {
await Event.insertMany([
{ date: '2026-03-14', title: 'MCDi Board Meeting', description: 'Board Meeting at Funk It Coffee and Thrift', startTime: '10:00', endTime: '11:00', pointOfContact: 'Tessa Williams', email: 'tessa.williams@deafmissoula.org', address: '314 N 1st St. West, Missoula MT 59802' },
{ date: '2026-06-13', title: 'MCDi Board Meeting', description: 'Board Meeting', startTime: '09:00', endTime: '10:00', pointOfContact: 'Tessa Williams', email: 'tessa.williams@deafmissoula.org', address: 'Hybrid' },
{ date: '2026-09-12', title: 'MCDi Board Meeting', description: 'Board Meeting', startTime: '09:00', endTime: '10:00', pointOfContact: 'Tessa Williams', email: 'tessa.williams@deafmissoula.org', address: 'TBD' },
{ date: '2026-09-12', title: 'MCDi Annual Membership Meeting', description: 'Annual Membership Meeting', startTime: '10:00', endTime: '11:00', pointOfContact: 'Tessa Williams', email: 'tessa.williams@deafmissoula.org', address: 'TBD' },
]);
console.log('Seeded events');
}
// Gallery
const galleryCount = await GalleryItem.countDocuments();
if (galleryCount === 0) {
const items = Array.from({ length: 18 }, (_, i) => ({
imageUrl: `/photos/${String(i + 1).padStart(4, '0')}.jpg`,
alt: 'MCDi community event photo',
order: i,
}));
await GalleryItem.insertMany(items);
console.log('Seeded gallery');
}
// Sponsors
const sponsorCount = await Sponsor.countDocuments();
if (sponsorCount === 0) {
await Sponsor.insertMany([
{ name: 'Drum Coffee', logoPath: '/sponsors/drum-coffee-missoula.png', description: 'Drum Coffee partnered with MCDi to provide ASL lessons on coffee-related signology, enhancing their ability to serve Deaf community members. This initiative promotes inclusivity and improves communication in their bustling cafe environment.', websiteUrl: 'https://drumcoffeeroasting.com/', facebookUrl: 'https://m.facebook.com/drumcoffeemt', instagramUrl: 'https://www.instagram.com/drumcoffee/', order: 0 },
{ name: 'Imagine Nation Brewing', logoPath: '/sponsors/imagine-nation-brewing.png', description: 'Imagine Nation Brewing hosted a vibrant fundraising event, donating proceeds to MCDi. Their generous support not only raised funds but also raised awareness about the Deaf community, making a significant impact on our mission.', websiteUrl: 'https://imaginenationbrewing.com/', facebookUrl: 'https://m.facebook.com/ImagineNationBrewing', instagramUrl: 'https://www.instagram.com/imaginenationbrewingco', order: 1 },
{ name: 'GILD Brewing', logoPath: '/sponsors/gild-brewing.png', description: 'GILD Brewing organized a community-focused fundraiser for MCDi, combining craft beer tasting with Deaf culture education. Their event not only raised funds but also fostered a deeper understanding of Deaf culture, making a significant impact on our mission.', websiteUrl: 'https://www.gildbrewing.com/', facebookUrl: 'https://m.facebook.com/gildbrewing/', instagramUrl: 'https://www.instagram.com/gildbrewing/', order: 2 },
{ name: 'SBS Solar', logoPath: '/sponsors/sbs-solar.png', description: "SBS Solar supports MCDi's mission to serve the Deaf community in Missoula. Their commitment to renewable energy and community engagement aligns with our values of accessibility and sustainability.", websiteUrl: 'https://www.sbslink.com/', facebookUrl: 'https://www.facebook.com/sbssolar', instagramUrl: 'https://www.instagram.com/sbssolarmt/', order: 3 },
]);
console.log('Seeded sponsors');
}
// Minutes
const minuteCount = await Minute.countDocuments();
if (minuteCount === 0) {
await Minute.insertMany([
{ date: '04/12/2025', meetingType: 'Membership Meeting', location: 'The Break Coffee', fileUrl: '/minutes/minutes-04122025.pdf' },
{ date: '03/08/2025', meetingType: 'Board Meeting', location: 'The Break Espresso', fileUrl: '/minutes/minutes-03082025.pdf' },
{ date: '02/08/2025', meetingType: 'Board Meeting', location: 'Book Exchange - Liquid Planet', fileUrl: '/minutes/minutes-02082025.pdf' },
{ date: '01/11/2025', meetingType: 'Membership Meeting', location: 'Black Coffee Roasting Company', fileUrl: '/minutes/minutes-01112025.pdf' },
{ date: '10/17/2024', meetingType: 'Board Meeting', location: 'UC Market (University)', fileUrl: '/minutes/minutes-10172024.pdf' },
{ date: '09/14/2024', meetingType: 'Membership Meeting', location: 'Dog Wash Cafe', fileUrl: '/minutes/minutes-09142024.pdf' },
{ date: '08/06/2024', meetingType: 'Membership Meeting', location: 'The Break Espresso', fileUrl: '/minutes/minutes-08062024.pdf' },
]);
console.log('Seeded minutes');
}
console.log('Seed complete');
process.exit(0);
}
seed().catch((err) => { console.error(err); process.exit(1); });

View file

@ -1,13 +1,17 @@
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"target": "ES2022",
"module": "CommonJS",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"sourceMap": true
},
"include": ["src/**/*"]
}
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -1 +1,12 @@
CACHEBUST=1736003093
# MongoDB
MONGO_USER=mcdi
MONGO_PASSWORD=changeme-set-a-strong-password
MONGODB_URI=mongodb://mcdi:changeme-set-a-strong-password@mongodb:27017/mcdi?authSource=admin
# JWT
JWT_SECRET=changeme-set-a-long-random-secret
# Admin dashboard default password (changed on first login recommended)
ADMIN_PASSWORD=changeme123!