= {};
+ sponsors.forEach(s => { record[s.name] = s; });
+ setSponsorInfo(record);
+ });
+ }, []);
return (
diff --git a/docker-compose.yml b/docker-compose.yml
index d5d42b4..d32752c 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -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
diff --git a/server/package.json b/server/package.json
index 429e82b..62999c6 100644
--- a/server/package.json
+++ b/server/package.json
@@ -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"
}
-}
\ No newline at end of file
+}
diff --git a/server/src/db.ts b/server/src/db.ts
new file mode 100644
index 0000000..1af750e
--- /dev/null
+++ b/server/src/db.ts
@@ -0,0 +1,9 @@
+import mongoose from 'mongoose';
+
+export async function connectDB(): Promise {
+ const uri = process.env.MONGODB_URI;
+ if (!uri) throw new Error('MONGODB_URI is not defined');
+
+ await mongoose.connect(uri);
+ console.log('MongoDB connected');
+}
diff --git a/server/src/index.ts b/server/src/index.ts
index f0de94b..7019e2e 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -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}`);
-});
\ No newline at end of file
+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);
+ });
diff --git a/server/src/middleware/auth.ts b/server/src/middleware/auth.ts
new file mode 100644
index 0000000..515c1a7
--- /dev/null
+++ b/server/src/middleware/auth.ts
@@ -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 {
+ 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' });
+ }
+}
diff --git a/server/src/middleware/errorHandler.ts b/server/src/middleware/errorHandler.ts
new file mode 100644
index 0000000..b4b571b
--- /dev/null
+++ b/server/src/middleware/errorHandler.ts
@@ -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' });
+}
diff --git a/server/src/middleware/upload.ts b/server/src/middleware/upload.ts
new file mode 100644
index 0000000..936df80
--- /dev/null
+++ b/server/src/middleware/upload.ts
@@ -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 } });
diff --git a/server/src/models/BoardMember.ts b/server/src/models/BoardMember.ts
new file mode 100644
index 0000000..dc1f17f
--- /dev/null
+++ b/server/src/models/BoardMember.ts
@@ -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({
+ 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('BoardMember', BoardMemberSchema);
diff --git a/server/src/models/Event.ts b/server/src/models/Event.ts
new file mode 100644
index 0000000..5321c79
--- /dev/null
+++ b/server/src/models/Event.ts
@@ -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({
+ 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('Event', EventSchema);
diff --git a/server/src/models/GalleryItem.ts b/server/src/models/GalleryItem.ts
new file mode 100644
index 0000000..db43a93
--- /dev/null
+++ b/server/src/models/GalleryItem.ts
@@ -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({
+ imageUrl: { type: String, required: true },
+ alt: { type: String, required: true, trim: true },
+ order: { type: Number, default: 0 },
+}, { timestamps: true });
+
+export const GalleryItem = mongoose.model('GalleryItem', GalleryItemSchema);
diff --git a/server/src/models/Member.ts b/server/src/models/Member.ts
new file mode 100644
index 0000000..32a3612
--- /dev/null
+++ b/server/src/models/Member.ts
@@ -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({
+ 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('Member', MemberSchema);
diff --git a/server/src/models/Minute.ts b/server/src/models/Minute.ts
new file mode 100644
index 0000000..7842360
--- /dev/null
+++ b/server/src/models/Minute.ts
@@ -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({
+ 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('Minute', MinuteSchema);
diff --git a/server/src/models/Sponsor.ts b/server/src/models/Sponsor.ts
new file mode 100644
index 0000000..0155b75
--- /dev/null
+++ b/server/src/models/Sponsor.ts
@@ -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({
+ 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('Sponsor', SponsorSchema);
diff --git a/server/src/models/User.ts b/server/src/models/User.ts
new file mode 100644
index 0000000..8dd89fb
--- /dev/null
+++ b/server/src/models/User.ts
@@ -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({
+ 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('User', UserSchema);
diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts
new file mode 100644
index 0000000..4239c6f
--- /dev/null
+++ b/server/src/routes/auth.ts
@@ -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;
diff --git a/server/src/routes/board.ts b/server/src/routes/board.ts
new file mode 100644
index 0000000..a523879
--- /dev/null
+++ b/server/src/routes/board.ts
@@ -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 = { ...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;
diff --git a/server/src/routes/email.ts b/server/src/routes/email.ts
new file mode 100644
index 0000000..b48a91e
--- /dev/null
+++ b/server/src/routes/email.ts
@@ -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;
diff --git a/server/src/routes/events.ts b/server/src/routes/events.ts
new file mode 100644
index 0000000..d8ac696
--- /dev/null
+++ b/server/src/routes/events.ts
@@ -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;
diff --git a/server/src/routes/gallery.ts b/server/src/routes/gallery.ts
new file mode 100644
index 0000000..0c2ac82
--- /dev/null
+++ b/server/src/routes/gallery.ts
@@ -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;
diff --git a/server/src/routes/members.ts b/server/src/routes/members.ts
new file mode 100644
index 0000000..b2bd9c0
--- /dev/null
+++ b/server/src/routes/members.ts
@@ -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;
diff --git a/server/src/routes/minutes.ts b/server/src/routes/minutes.ts
new file mode 100644
index 0000000..155a6d8
--- /dev/null
+++ b/server/src/routes/minutes.ts
@@ -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;
diff --git a/server/src/routes/sponsors.ts b/server/src/routes/sponsors.ts
new file mode 100644
index 0000000..010b8b9
--- /dev/null
+++ b/server/src/routes/sponsors.ts
@@ -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 = { ...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;
diff --git a/server/src/seed.ts b/server/src/seed.ts
new file mode 100644
index 0000000..478850c
--- /dev/null
+++ b/server/src/seed.ts
@@ -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); });
diff --git a/server/tsconfig.json b/server/tsconfig.json
index 5b3a382..09cbef3 100644
--- a/server/tsconfig.json
+++ b/server/tsconfig.json
@@ -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/**/*"]
-}
\ No newline at end of file
+ "include": ["src/**/*"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/stack.env b/stack.env
index 4cd18ba..80f05c3 100644
--- a/stack.env
+++ b/stack.env
@@ -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!