Add Update/Article models, migration script, and complete LAD rebrand

Backend:
- New models: Update, Article (Deaf Focus)
- Expanded Event + Document models: directusId, photoUrl, new minute types
- New routes/controllers: /api/updates, /api/articles (full CRUD)
- Migration script: pulls all data from db.lad1908.org Directus API,
  downloads public PDFs into uploads/documents/, idempotent re-runs
- .env.example for production deployment reference
- Rename ocd_db → lad_db throughout

Frontend:
- Contact: LAD contact info, removed Olathe address and map
- Membership: LAD copy, membership fees replaced with contact-us prompt
- Donate: LAD name in metadata, hero, and FAQs

Infrastructure:
- mongo-init: simplified, uses MONGO_USER/MONGO_PASSWORD env vars, lad_db
- docker-compose: rename volumes ocd-* → lad-*, image names updated
- Upload directory structure committed via .gitkeep files
This commit is contained in:
Chris Haulmark 2026-05-24 15:29:30 -06:00
parent c8a76c7763
commit c9e042fa98
25 changed files with 725 additions and 321 deletions

17
backend/.env.example Normal file
View file

@ -0,0 +1,17 @@
PORT=4000
NODE_ENV=production
MONGO_HOST=mongodb
MONGO_PORT=27017
MONGO_DB=lad_db
MONGO_USER=lad_admin
MONGO_PASSWORD=changeme
CORS_ORIGIN=https://lad.sigd.net
JWT_SECRET=changeme-use-a-long-random-string
JWT_EXPIRES_IN=7d
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=changeme

18
backend/.gitignore vendored Normal file
View file

@ -0,0 +1,18 @@
node_modules/
dist/
.env
uploads/documents/*
uploads/images/*
uploads/videos/*
uploads/thumbnails/*
uploads/subtitles/*
uploads/transcripts/*
uploads/textversions/*
!uploads/documents/.gitkeep
!uploads/images/.gitkeep
!uploads/videos/.gitkeep
!uploads/thumbnails/.gitkeep
!uploads/subtitles/.gitkeep
!uploads/transcripts/.gitkeep
!uploads/textversions/.gitkeep
scripts/migration-report.json

View file

@ -0,0 +1,343 @@
/**
* Directus MongoDB migration script
*
* Run from the backend directory:
* npx tsx scripts/migrate-directus.ts
*
* What it does:
* - Fetches all records from db.lad1908.org (updates, events, minutes, deaffocus)
* - Downloads publicly-accessible PDFs into uploads/documents/
* - Downloads publicly-accessible images into uploads/images/
* - Inserts records into MongoDB (skips existing directusId to allow re-runs)
* - Writes scripts/migration-report.json with a full manifest
*/
import 'dotenv/config';
import mongoose from 'mongoose';
import fs from 'fs-extra';
import path from 'path';
import https from 'https';
import http from 'http';
import { fileURLToPath } from 'url';
import Update from '../src/models/Update.js';
import Article from '../src/models/Article.js';
import Event from '../src/models/Event.js';
import Document from '../src/models/Document.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BACKEND_ROOT = path.resolve(__dirname, '..');
const DIRECTUS = 'https://db.lad1908.org';
// ── helpers ──────────────────────────────────────────────────────────────────
async function connectDB() {
const user = process.env.MONGO_USER;
const pass = process.env.MONGO_PASSWORD;
const host = process.env.MONGO_HOST || 'localhost';
const port = process.env.MONGO_PORT || '27017';
const db = process.env.MONGO_DB || 'lad_db';
const uri = user && pass
? `mongodb://${user}:${pass}@${host}:${port}/${db}?authSource=${db}`
: `mongodb://${host}:${port}/${db}`;
await mongoose.connect(uri);
console.log('✓ MongoDB connected');
}
async function fetchAll(collection: string): Promise<any[]> {
const results: any[] = [];
const limit = 100;
let offset = 0;
while (true) {
const url = `${DIRECTUS}/items/${collection}?limit=${limit}&offset=${offset}`;
const res = await fetch(url);
if (!res.ok) throw new Error(`Directus ${collection} fetch failed: ${res.status}`);
const json: any = await res.json();
const batch: any[] = json.data ?? [];
results.push(...batch);
if (batch.length < limit) break;
offset += limit;
}
return results;
}
function downloadFile(url: string, dest: string): Promise<{ ok: boolean; size: number }> {
return new Promise((resolve) => {
const proto = url.startsWith('https') ? https : http;
const req = proto.get(url, (res) => {
if (res.statusCode === 301 || res.statusCode === 302) {
return downloadFile(res.headers.location!, dest).then(resolve);
}
if (res.statusCode !== 200) {
res.resume();
return resolve({ ok: false, size: 0 });
}
const stream = fs.createWriteStream(dest);
let size = 0;
res.on('data', (chunk: Buffer) => { size += chunk.length; });
res.pipe(stream);
stream.on('finish', () => resolve({ ok: true, size }));
stream.on('error', () => resolve({ ok: false, size: 0 }));
});
req.on('error', () => resolve({ ok: false, size: 0 }));
});
}
function safeFilename(prefix: string, id: number, date: string, ext: string): string {
return `${prefix}-${date.replace(/[^0-9-]/g, '')}-${id}${ext}`;
}
function mapMinutesType(type: string | null): string {
switch (type) {
case 'Board Meeting': return 'board_meeting_minutes';
case 'Committee Meeting': return 'committee_meeting_minutes';
case 'Quarterly Meeting': return 'quarterly_meeting_minutes';
case 'Conference Minutes': return 'conference_minutes';
case 'Financial Report': return 'financial_report';
case 'Special Meeting': return 'special_meeting_minutes';
default: return 'meeting_minutes';
}
}
// ── main ─────────────────────────────────────────────────────────────────────
interface Report {
startedAt: string;
finishedAt?: string;
inserted: Record<string, number>;
skipped: Record<string, number>;
downloadedFiles: number;
failedDownloads: { collection: string; directusId: number; assetId: string; reason: string }[];
membersOnlyPDFs: { directusId: number; date: string; type: string; assetId: string }[];
errors: { collection: string; directusId: number; message: string }[];
}
async function main() {
await connectDB();
await fs.ensureDir(path.join(BACKEND_ROOT, 'uploads/documents'));
await fs.ensureDir(path.join(BACKEND_ROOT, 'uploads/images'));
const report: Report = {
startedAt: new Date().toISOString(),
inserted: { updates: 0, events: 0, minutes: 0, articles: 0 },
skipped: { updates: 0, events: 0, minutes: 0, articles: 0 },
downloadedFiles: 0,
failedDownloads: [],
membersOnlyPDFs: [],
errors: []
};
// ── UPDATES ────────────────────────────────────────────────────────────────
console.log('\n── Fetching updates…');
const directusUpdates = await fetchAll('updates');
console.log(` Found ${directusUpdates.length} records`);
for (const r of directusUpdates) {
if (!r.id) continue;
const exists = await Update.findOne({ directusId: r.id });
if (exists) { report.skipped.updates++; continue; }
let photoUrl: string | undefined;
if (r.main_photo) {
const assetUrl = `${DIRECTUS}/assets/${r.main_photo}`;
const fname = safeFilename('update', r.id, r.date ?? 'undated', '.jpg');
const dest = path.join(BACKEND_ROOT, 'uploads/images', fname);
const dl = await downloadFile(assetUrl, dest);
if (dl.ok) {
photoUrl = `uploads/images/${fname}`;
report.downloadedFiles++;
} else {
photoUrl = assetUrl; // fall back to Directus URL; replace when images are provided
report.failedDownloads.push({ collection: 'updates', directusId: r.id, assetId: r.main_photo, reason: '403 or unreachable' });
}
}
try {
await new Update({
directusId: r.id,
title: r.title ?? '(untitled)',
date: r.date ? new Date(r.date) : new Date(r.created_on),
body: r.info ?? '',
status: r.status === 'published' ? 'published' : 'draft',
photoUrl,
photoCaption: r.main_photo_caption ?? undefined,
videoUrl: r.video ?? undefined,
gallery: Array.isArray(r.gallery) ? r.gallery : []
}).save();
report.inserted.updates++;
} catch (e: any) {
report.errors.push({ collection: 'updates', directusId: r.id, message: e.message });
}
}
console.log(` Inserted ${report.inserted.updates}, skipped ${report.skipped.updates}`);
// ── EVENTS ─────────────────────────────────────────────────────────────────
console.log('\n── Fetching events…');
const directusEvents = await fetchAll('events');
console.log(` Found ${directusEvents.length} records`);
for (const r of directusEvents) {
if (!r.id) continue;
const exists = await Event.findOne({ directusId: r.id });
if (exists) { report.skipped.events++; continue; }
let photoUrl: string | undefined;
if (r.main_photo) {
const assetUrl = `${DIRECTUS}/assets/${r.main_photo}`;
const fname = safeFilename('event', r.id, r.date ?? 'undated', '.jpg');
const dest = path.join(BACKEND_ROOT, 'uploads/images', fname);
const dl = await downloadFile(assetUrl, dest);
if (dl.ok) {
photoUrl = `uploads/images/${fname}`;
report.downloadedFiles++;
} else {
photoUrl = assetUrl;
report.failedDownloads.push({ collection: 'events', directusId: r.id, assetId: r.main_photo, reason: '403 or unreachable' });
}
}
try {
await new Event({
directusId: r.id,
title: r.title ?? '(untitled)',
description: r.info?.trim() || 'No description provided',
date: r.date ? new Date(r.date) : new Date(r.created_on),
time: 'TBD',
location: 'TBD',
category: 'general',
status: r.status === 'published' ? 'published' : 'draft',
registrationRequired: false,
registeredCount: 0,
photoUrl,
photoCaption: r.main_photo_caption ?? undefined
}).save();
report.inserted.events++;
} catch (e: any) {
report.errors.push({ collection: 'events', directusId: r.id, message: e.message });
}
}
console.log(` Inserted ${report.inserted.events}, skipped ${report.skipped.events}`);
// ── MINUTES ────────────────────────────────────────────────────────────────
console.log('\n── Fetching minutes…');
const directusMinutes = await fetchAll('minutes');
console.log(` Found ${directusMinutes.length} records`);
for (const r of directusMinutes) {
if (!r.id) continue;
const exists = await Document.findOne({ directusId: r.id });
if (exists) { report.skipped.minutes++; continue; }
if (r.members_only) {
report.membersOnlyPDFs.push({
directusId: r.id,
date: r.date ?? '',
type: r.type ?? '',
assetId: r.attachment ?? ''
});
report.skipped.minutes++;
continue;
}
if (!r.attachment) {
report.errors.push({ collection: 'minutes', directusId: r.id, message: 'No attachment UUID' });
continue;
}
const assetUrl = `${DIRECTUS}/assets/${r.attachment}`;
const dateStr = (r.date ?? 'undated').substring(0, 10);
const fname = safeFilename('minutes', r.id, dateStr, '.pdf');
const dest = path.join(BACKEND_ROOT, 'uploads/documents', fname);
const dl = await downloadFile(assetUrl, dest);
if (!dl.ok) {
report.failedDownloads.push({ collection: 'minutes', directusId: r.id, assetId: r.attachment, reason: '403 or unreachable' });
continue;
}
report.downloadedFiles++;
const title = `${r.type ?? 'Meeting'}${dateStr}`;
try {
await new Document({
directusId: r.id,
directusAssetId: r.attachment,
title,
description: title,
filePath: `uploads/documents/${fname}`,
originalFilename: fname,
fileType: 'pdf',
fileSize: dl.size,
documentType: mapMinutesType(r.type),
isPublic: true,
meetingDate: r.date ? new Date(r.date) : undefined,
uploadDate: new Date(r.created_on),
lastModified: new Date(r.created_on),
accessibilityChecked: false,
hasTextVersion: false
}).save();
report.inserted.minutes++;
} catch (e: any) {
report.errors.push({ collection: 'minutes', directusId: r.id, message: e.message });
}
}
console.log(` Inserted ${report.inserted.minutes}, skipped ${report.skipped.minutes} (${report.membersOnlyPDFs.length} members-only)`);
// ── DEAF FOCUS ARTICLES ────────────────────────────────────────────────────
console.log('\n── Fetching Deaf Focus articles…');
const directusArticles = await fetchAll('deaffocus');
console.log(` Found ${directusArticles.length} records`);
for (const r of directusArticles) {
if (!r.id) continue;
const exists = await Article.findOne({ directusId: r.id });
if (exists) { report.skipped.articles++; continue; }
try {
await new Article({
directusId: r.id,
title: r.title ?? '(untitled)',
body: r.body ?? '',
order: r.order ?? 0,
status: 'published'
}).save();
report.inserted.articles++;
} catch (e: any) {
report.errors.push({ collection: 'articles', directusId: r.id, message: e.message });
}
}
console.log(` Inserted ${report.inserted.articles}, skipped ${report.skipped.articles}`);
// ── REPORT ─────────────────────────────────────────────────────────────────
report.finishedAt = new Date().toISOString();
const reportPath = path.join(__dirname, 'migration-report.json');
await fs.writeJSON(reportPath, report, { spaces: 2 });
console.log('\n══════════════════════════════════════════');
console.log('Migration complete');
console.log(` Updates inserted: ${report.inserted.updates}`);
console.log(` Events inserted: ${report.inserted.events}`);
console.log(` Minutes inserted: ${report.inserted.minutes}`);
console.log(` Articles inserted: ${report.inserted.articles}`);
console.log(` Files downloaded: ${report.downloadedFiles}`);
console.log(` Members-only PDFs: ${report.membersOnlyPDFs.length} (manual upload needed)`);
console.log(` Failed downloads: ${report.failedDownloads.length}`);
console.log(` Errors: ${report.errors.length}`);
console.log(` Full report: scripts/migration-report.json`);
console.log('══════════════════════════════════════════\n');
if (report.membersOnlyPDFs.length > 0) {
console.log('Members-only PDFs that need manual upload via the admin panel:');
for (const m of report.membersOnlyPDFs) {
console.log(` [${m.directusId}] ${m.date}${m.type} (asset: ${m.assetId})`);
}
}
await mongoose.disconnect();
}
main().catch((err) => {
console.error('Migration failed:', err);
process.exit(1);
});

View file

@ -0,0 +1,80 @@
import { Request, Response } from 'express';
import mongoose from 'mongoose';
import Article from '../models/Article';
export const getArticles = async (req: Request, res: Response) => {
try {
const { status } = req.query;
const query: any = {};
if (status && status !== 'all') query.status = status;
const articles = await Article.find(query).sort({ order: 1 });
return res.status(200).json({ articles });
} catch (error) {
console.error('Error fetching articles:', error);
return res.status(500).json({ error: { message: 'Failed to fetch articles' } });
}
};
export const getArticleById = async (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id))
return res.status(400).json({ error: { message: 'Invalid article ID format' } });
const article = await Article.findById(id);
if (!article) return res.status(404).json({ error: { message: 'Article not found' } });
return res.status(200).json({ article });
} catch (error) {
console.error('Error fetching article:', error);
return res.status(500).json({ error: { message: 'Failed to fetch article' } });
}
};
export const createArticle = async (req: Request, res: Response) => {
try {
const article = new Article(req.body);
await article.save();
return res.status(201).json({ message: 'Article created successfully', article });
} catch (error) {
console.error('Error creating article:', error);
if (error instanceof mongoose.Error.ValidationError)
return res.status(400).json({ error: { message: 'Validation error', details: error.errors } });
return res.status(500).json({ error: { message: 'Failed to create article' } });
}
};
export const updateArticle = async (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id))
return res.status(400).json({ error: { message: 'Invalid article ID format' } });
const article = await Article.findByIdAndUpdate(id, req.body, { new: true, runValidators: true });
if (!article) return res.status(404).json({ error: { message: 'Article not found' } });
return res.status(200).json({ message: 'Article saved successfully', article });
} catch (error) {
console.error('Error updating article:', error);
if (error instanceof mongoose.Error.ValidationError)
return res.status(400).json({ error: { message: 'Validation error', details: error.errors } });
return res.status(500).json({ error: { message: 'Failed to save article' } });
}
};
export const deleteArticle = async (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id))
return res.status(400).json({ error: { message: 'Invalid article ID format' } });
const article = await Article.findByIdAndDelete(id);
if (!article) return res.status(404).json({ error: { message: 'Article not found' } });
return res.status(200).json({ message: 'Article deleted successfully' });
} catch (error) {
console.error('Error deleting article:', error);
return res.status(500).json({ error: { message: 'Failed to delete article' } });
}
};

View file

@ -0,0 +1,95 @@
import { Request, Response } from 'express';
import mongoose from 'mongoose';
import Update from '../models/Update';
export const getUpdates = async (req: Request, res: Response) => {
try {
const { status, search, page = 1, limit = 10 } = req.query;
const query: any = {};
if (status && status !== 'all') query.status = status;
if (search) {
const re = new RegExp(search as string, 'i');
query.$or = [{ title: re }, { body: re }];
}
const skip = ((Number(page) || 1) - 1) * (Number(limit) || 10);
const updates = await Update.find(query)
.sort({ date: -1 })
.skip(skip)
.limit(Number(limit));
const total = await Update.countDocuments(query);
return res.status(200).json({
updates,
pagination: { total, page: Number(page), limit: Number(limit), pages: Math.ceil(total / Number(limit)) }
});
} catch (error) {
console.error('Error fetching updates:', error);
return res.status(500).json({ error: { message: 'Failed to fetch updates' } });
}
};
export const getUpdateById = async (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id))
return res.status(400).json({ error: { message: 'Invalid update ID format' } });
const update = await Update.findById(id);
if (!update) return res.status(404).json({ error: { message: 'Update not found' } });
return res.status(200).json({ update });
} catch (error) {
console.error('Error fetching update:', error);
return res.status(500).json({ error: { message: 'Failed to fetch update' } });
}
};
export const createUpdate = async (req: Request, res: Response) => {
try {
const update = new Update(req.body);
await update.save();
return res.status(201).json({ message: 'Update created successfully', update });
} catch (error) {
console.error('Error creating update:', error);
if (error instanceof mongoose.Error.ValidationError)
return res.status(400).json({ error: { message: 'Validation error', details: error.errors } });
return res.status(500).json({ error: { message: 'Failed to create update' } });
}
};
export const updateUpdate = async (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id))
return res.status(400).json({ error: { message: 'Invalid update ID format' } });
const update = await Update.findByIdAndUpdate(id, req.body, { new: true, runValidators: true });
if (!update) return res.status(404).json({ error: { message: 'Update not found' } });
return res.status(200).json({ message: 'Update saved successfully', update });
} catch (error) {
console.error('Error updating update:', error);
if (error instanceof mongoose.Error.ValidationError)
return res.status(400).json({ error: { message: 'Validation error', details: error.errors } });
return res.status(500).json({ error: { message: 'Failed to save update' } });
}
};
export const deleteUpdate = async (req: Request, res: Response) => {
try {
const { id } = req.params;
if (!mongoose.Types.ObjectId.isValid(id))
return res.status(400).json({ error: { message: 'Invalid update ID format' } });
const update = await Update.findByIdAndDelete(id);
if (!update) return res.status(404).json({ error: { message: 'Update not found' } });
return res.status(200).json({ message: 'Update deleted successfully' });
} catch (error) {
console.error('Error deleting update:', error);
return res.status(500).json({ error: { message: 'Failed to delete update' } });
}
};

View file

@ -15,6 +15,8 @@ import eventRoutes from './routes/events';
import memberRoutes from './routes/members';
import videoRoutes from './routes/videos';
import documentRoutes from './routes/documents';
import updateRoutes from './routes/updates';
import articleRoutes from './routes/articles';
// Create Express app
const app = express();
@ -27,7 +29,7 @@ const connectDB = async () => {
const mongoPassword = process.env.MONGO_PASSWORD;
const mongoHost = process.env.MONGO_HOST || 'mongodb';
const mongoPort = process.env.MONGO_PORT || '27017';
const mongoDb = process.env.MONGO_DB || 'ocd_db';
const mongoDb = process.env.MONGO_DB || 'lad_db';
let mongoUri;
if (mongoUser && mongoPassword) {
@ -75,6 +77,8 @@ app.use('/api/events', eventRoutes);
app.use('/api/members', memberRoutes);
app.use('/api/videos', videoRoutes);
app.use('/api/documents', documentRoutes);
app.use('/api/updates', updateRoutes);
app.use('/api/articles', articleRoutes);
// Error handling middleware
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {

View file

@ -0,0 +1,28 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IArticle extends Document {
directusId?: number;
title: string;
body: string;
order: number;
status: 'published' | 'draft';
}
const ArticleSchema: Schema = new Schema({
directusId: { type: Number, index: true, sparse: true },
title: { type: String, required: true, trim: true },
body: { type: String, default: '' },
order: { type: Number, default: 0, index: true },
status: {
type: String,
required: true,
enum: ['published', 'draft'],
default: 'draft',
index: true
}
}, { timestamps: true });
ArticleSchema.index({ title: 'text', body: 'text' });
ArticleSchema.index({ status: 1, order: 1 });
export default mongoose.model<IArticle>('Article', ArticleSchema);

View file

@ -8,9 +8,12 @@ export interface IDocument extends Document {
originalFilename: string;
fileType: string;
fileSize: number;
documentType: 'meeting_minutes' | 'board_meeting_minutes' | 'committee_meeting_minutes' | 'annual_meeting_minutes' |
'bylaws' | 'financial_report' | 'annual_report' | 'board_report' | 'committee_report' |
documentType: 'meeting_minutes' | 'board_meeting_minutes' | 'committee_meeting_minutes' | 'annual_meeting_minutes' |
'quarterly_meeting_minutes' | 'conference_minutes' | 'special_meeting_minutes' |
'bylaws' | 'financial_report' | 'annual_report' | 'board_report' | 'committee_report' |
'meeting_agenda' | 'board_meeting_agenda' | 'committee_meeting_agenda' | 'program_document';
directusId?: number;
directusAssetId?: string;
isPublic: boolean;
meetingDate?: Date;
uploadDate: Date;
@ -56,6 +59,7 @@ const DocumentSchema: Schema = new Schema({
required: true,
enum: [
'meeting_minutes', 'board_meeting_minutes', 'committee_meeting_minutes', 'annual_meeting_minutes',
'quarterly_meeting_minutes', 'conference_minutes', 'special_meeting_minutes',
'bylaws', 'financial_report', 'annual_report', 'board_report', 'committee_report',
'meeting_agenda', 'board_meeting_agenda', 'committee_meeting_agenda', 'program_document'
],
@ -91,7 +95,9 @@ const DocumentSchema: Schema = new Schema({
textVersionPath: {
type: String,
trim: true
}
},
directusId: { type: Number, index: true, sparse: true },
directusAssetId: { type: String, trim: true }
}, {
timestamps: true
});

View file

@ -16,6 +16,9 @@ export interface IEvent extends Document {
recurrencePattern?: string;
organizer?: mongoose.Types.ObjectId;
tags?: string[];
directusId?: number;
photoUrl?: string;
photoCaption?: string;
}
// Event schema
@ -77,14 +80,17 @@ const EventSchema: Schema = new Schema({
recurrencePattern: {
type: String
},
organizer: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
organizer: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
tags: [{
tags: [{
type: String,
trim: true
}]
trim: true
}],
directusId: { type: Number, index: true, sparse: true },
photoUrl: { type: String, trim: true },
photoCaption: { type: String, trim: true }
}, {
timestamps: true
});

View file

@ -0,0 +1,36 @@
import mongoose, { Schema, Document } from 'mongoose';
export interface IUpdate extends Document {
directusId?: number;
title: string;
date: Date;
body: string;
status: 'published' | 'draft' | 'archived';
photoUrl?: string;
photoCaption?: string;
videoUrl?: string;
gallery?: string[];
}
const UpdateSchema: Schema = new Schema({
directusId: { type: Number, index: true, sparse: true },
title: { type: String, required: true, trim: true },
date: { type: Date, required: true, index: true },
body: { type: String, default: '' },
status: {
type: String,
required: true,
enum: ['published', 'draft', 'archived'],
default: 'draft',
index: true
},
photoUrl: { type: String, trim: true },
photoCaption: { type: String, trim: true },
videoUrl: { type: String, trim: true },
gallery: [{ type: String, trim: true }]
}, { timestamps: true });
UpdateSchema.index({ title: 'text', body: 'text' });
UpdateSchema.index({ status: 1, date: -1 });
export default mongoose.model<IUpdate>('Update', UpdateSchema);

View file

@ -0,0 +1,12 @@
import express from 'express';
import * as articleController from '../controllers/articleController';
const router = express.Router();
router.get('/', articleController.getArticles);
router.get('/:id', articleController.getArticleById);
router.post('/', articleController.createArticle);
router.put('/:id', articleController.updateArticle);
router.delete('/:id', articleController.deleteArticle);
export default router;

View file

@ -0,0 +1,12 @@
import express from 'express';
import * as updateController from '../controllers/updateController';
const router = express.Router();
router.get('/', updateController.getUpdates);
router.get('/:id', updateController.getUpdateById);
router.post('/', updateController.createUpdate);
router.put('/:id', updateController.updateUpdate);
router.delete('/:id', updateController.deleteUpdate);
export default router;

View file

View file

View file

View file

View file

View file

View file

View file

@ -136,7 +136,7 @@ services:
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./security-reports:/reports
command: ["image", "--format", "table", "--output", "/reports/scan-$(date +%Y%m%d).txt", "olathedeafclub-frontend:latest", "olathedeafclub-backend:latest"]
command: ["image", "--format", "table", "--output", "/reports/scan-$(date +%Y%m%d).txt", "lad-website-frontend:latest", "lad-website-backend:latest"]
profiles:
- security
@ -151,6 +151,6 @@ networks:
# Volumes with backup capability
volumes:
mongo-data:
name: ocd-mongo-data
name: lad-mongo-data
redis-data:
name: ocd-redis-data
name: lad-redis-data

View file

@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View file

@ -3,8 +3,8 @@ import type { Metadata } from 'next';
import ContactForm from '../../components/molecules/ContactForm';
export const metadata: Metadata = {
title: 'Contact Us | Olathe Club of the Deaf',
description: 'Get in touch with Olathe Club of the Deaf. We\'d love to hear from you!',
title: 'Contact Us | Louisiana Association of the Deaf',
description: 'Get in touch with the Louisiana Association of the Deaf. We\'d love to hear from you!',
};
export default function ContactPage() {
@ -39,29 +39,34 @@ export default function ContactPage() {
<div className="space-y-6">
<div className="card event-card-gold-border p-4">
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">Email</h3>
<a
href="mailto:info@olathedeafclub.com"
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">General Inquiries</h3>
<a
href="mailto:info@lad1908.org"
className="card-link hover:underline"
>
info@olathedeafclub.com
info@lad1908.org
</a>
</div>
<div className="card event-card-gold-border p-4">
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">Meeting Location</h3>
<address className="not-italic section-text">
123 Main Street<br />
Olathe, KS 66061
</address>
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">Interpreter Requests (Deaf Focus)</h3>
<a
href="mailto:request@deaffocus.org"
className="card-link hover:underline block mb-1"
>
request@deaffocus.org
</a>
<p className="section-text">Phone: (225) 319-5586</p>
<p className="section-text">Fax: (225) 308-4025</p>
<p className="section-text">Hours: MonFri, 9am4pm</p>
</div>
<div className="card event-card-gold-border p-4">
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">Connect With Us</h3>
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">Follow Us</h3>
<div className="flex space-x-4">
<a
href="https://facebook.com"
target="_blank"
<a
href="https://facebook.com/lad1908"
target="_blank"
rel="noopener noreferrer"
className="footer-social-icon"
aria-label="Facebook page"
@ -70,30 +75,6 @@ export default function ContactPage() {
<path d="M22 12c0-5.523-4.477-10-10-10S2 6.477 2 12c0 4.991 3.657 9.128 8.438 9.878v-6.987h-2.54V12h2.54V9.797c0-2.506 1.492-3.89 3.777-3.89 1.094 0 2.238.195 2.238.195v2.46h-1.26c-1.243 0-1.63.771-1.63 1.562V12h2.773l-.443 2.89h-2.33v6.988C18.343 21.128 22 16.991 22 12z" />
</svg>
</a>
<a
href="https://instagram.com"
target="_blank"
rel="noopener noreferrer"
className="footer-social-icon"
aria-label="Instagram profile"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12.315 2c2.43 0 2.784.013 3.808.06 1.064.049 1.791.218 2.427.465a4.902 4.902 0 011.772 1.153 4.902 4.902 0 011.153 1.772c.247.636.416 1.363.465 2.427.048 1.067.06 1.407.06 4.123v.08c0 2.643-.012 2.987-.06 4.043-.049 1.064-.218 1.791-.465 2.427a4.902 4.902 0 01-1.153 1.772 4.902 4.902 0 01-1.772 1.153c-.636.247-1.363.416-2.427.465-1.067.048-1.407.06-4.123.06h-.08c-2.643 0-2.987-.012-4.043-.06-1.064-.049-1.791-.218-2.427-.465a4.902 4.902 0 01-1.772-1.153 4.902 4.902 0 01-1.153-1.772c-.247-.636-.416-1.363-.465-2.427-.047-1.024-.06-1.379-.06-3.808v-.63c0-2.43.013-2.784.06-3.808.049-1.064.218-1.791.465-2.427a4.902 4.902 0 011.153-1.772A4.902 4.902 0 015.45 2.525c.636-.247 1.363-.416 2.427-.465C8.901 2.013 9.256 2 11.685 2h.63zm-.081 1.802h-.468c-2.456 0-2.784.011-3.807.058-.975.045-1.504.207-1.857.344-.467.182-.8.398-1.15.748-.35.35-.566.683-.748 1.15-.137.353-.3.882-.344 1.857-.047 1.023-.058 1.351-.058 3.807v.468c0 2.456.011 2.784.058 3.807.045.975.207 1.504.344 1.857.182.466.399.8.748 1.15.35.35.683.566 1.15.748.353.137.882.3 1.857.344 1.054.048 1.37.058 4.041.058h.08c2.597 0 2.917-.01 3.96-.058.976-.045 1.505-.207 1.858-.344.466-.182.8-.398 1.15-.748.35-.35.566-.683.748-1.15.137-.353.3-.882.344-1.857.048-1.055.058-1.37.058-4.041v-.08c0-2.597-.01-2.917-.058-3.96-.045-.976-.207-1.505-.344-1.858a3.097 3.097 0 00-.748-1.15 3.098 3.098 0 00-1.15-.748c-.353-.137-.882-.3-1.857-.344-1.023-.047-1.351-.058-3.807-.058zM12 6.865a5.135 5.135 0 110 10.27 5.135 5.135 0 010-10.27zm0 1.802a3.333 3.333 0 100 6.666 3.333 3.333 0 000-6.666zm5.338-3.205a1.2 1.2 0 110 2.4 1.2 1.2 0 010-2.4z" />
</svg>
</a>
<a
href="https://youtube.com"
target="_blank"
rel="noopener noreferrer"
className="footer-social-icon"
aria-label="YouTube channel"
>
<svg className="w-6 h-6" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M19.812 5.418c.861.23 1.538.907 1.768 1.768C21.998 8.746 22 12 22 12s0 3.255-.418 4.814a2.504 2.504 0 0 1-1.768 1.768c-1.56.419-7.814.419-7.814.419s-6.255 0-7.814-.419a2.505 2.505 0 0 1-1.768-1.768C2 15.255 2 12 2 12s0-3.255.417-4.814a2.507 2.507 0 0 1 1.768-1.768C5.744 5 11.998 5 11.998 5s6.255 0 7.814.418ZM15.194 12 10 15V9l5.194 3Z" />
</svg>
</a>
</div>
</div>
</div>
@ -109,40 +90,7 @@ export default function ContactPage() {
</div>
</section>
{/* Map section */}
<section className="py-16 bg-color-bg-secondary">
<div className="container mx-auto px-4">
<div className="max-w-6xl mx-auto">
<h2 className="section-heading text-3xl font-bold mb-8 text-center">Our Location</h2>
{/* Map placeholder - would be replaced with actual Google Maps component */}
<div className="aspect-video video-container rounded-lg w-full">
<div className="w-full h-full flex items-center justify-center">
<p className="video-placeholder-text">Google Maps will be embedded here</p>
</div>
</div>
<div className="mt-8 text-center">
<p className="section-text text-lg">
We meet at 123 Main Street, Olathe, KS 66061
</p>
<p className="mt-2 card-date">
<a
href="https://maps.google.com"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center hover:underline"
>
<span>Get Directions</span>
<svg className="w-4 h-4 ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</a>
</p>
</div>
</div>
</div>
</section>
</div>
);
}

View file

@ -3,8 +3,8 @@ import Link from 'next/link';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Donate | Olathe Club of the Deaf',
description: 'Support the Olathe Club of the Deaf through donations. Your contribution helps us serve the deaf community in Olathe.',
title: 'Donate | Louisiana Association of the Deaf',
description: 'Support the Louisiana Association of the Deaf through donations. Your contribution helps us serve the Deaf community across Louisiana.',
};
export default function DonatePage() {
@ -16,7 +16,7 @@ export default function DonatePage() {
<div className="max-w-3xl mx-auto text-center">
<h1 className="hero-title text-4xl md:text-5xl font-bold mb-6">Support Our Mission</h1>
<p className="hero-subtitle text-xl">
Your donations help us continue to serve and support the deaf community in Olathe.
Your donations help us continue to serve and support the Deaf community across Louisiana.
</p>
</div>
</div>
@ -199,7 +199,7 @@ export default function DonatePage() {
<div className="card event-card-gold-border p-6">
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">Is my donation tax-deductible?</h3>
<p className="section-text">
Yes, the Olathe Club of the Deaf is a 501(c)(3) nonprofit organization. All donations are tax-deductible to the extent allowed by law.
Yes, the Louisiana Association of the Deaf is a 501(c)(3) nonprofit organization. All donations are tax-deductible to the extent allowed by law.
</p>
</div>
<div className="card event-card-gold-border p-6">
@ -211,7 +211,7 @@ export default function DonatePage() {
<div className="card event-card-gold-border p-6">
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">Do you accept donations by check or mail?</h3>
<p className="section-text">
Yes, checks can be made payable to "Olathe Club of the Deaf" and mailed to our office address. Please contact us for the current mailing address.
Yes, checks can be made payable to "Louisiana Association of the Deaf" and mailed to our office address. Please contact us for the current mailing address.
</p>
</div>
<div className="card event-card-gold-border p-6">
@ -229,7 +229,7 @@ export default function DonatePage() {
<div className="container mx-auto px-4 text-center">
<h2 className="cta-title text-3xl font-bold mb-4">Ready to Make a Difference?</h2>
<p className="cta-description text-xl max-w-3xl mx-auto mb-8">
Your support helps us continue our mission of serving the deaf community in Olathe.
Your support helps us continue our mission of serving the Deaf community across Louisiana.
</p>
<div className="card video-container-gold rounded-lg p-8 max-w-3xl mx-auto">
<p className="section-text text-center mb-6">

View file

@ -3,8 +3,8 @@ import Link from 'next/link';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Membership | Olathe Club of the Deaf',
description: 'Join the Olathe Club of the Deaf and become part of our vibrant community. Learn about membership benefits and how to apply.',
title: 'Membership | Louisiana Association of the Deaf',
description: 'Join the Louisiana Association of the Deaf and become part of our statewide community. Learn about membership benefits and how to apply.',
};
export default function MembershipPage() {
@ -16,7 +16,7 @@ export default function MembershipPage() {
<div className="max-w-3xl mx-auto text-center">
<h1 className="hero-title text-4xl md:text-5xl font-bold mb-6">Become a Member</h1>
<p className="hero-subtitle text-xl">
Join our vibrant community and connect with other deaf and hard-of-hearing individuals in Olathe.
Join our statewide community and connect with Deaf individuals and allies across Louisiana.
</p>
</div>
</div>
@ -32,7 +32,7 @@ export default function MembershipPage() {
<ul className="space-y-3 benefit-card-description">
<li className="flex items-start">
<span className="card-date font-bold mr-2"></span>
<span>Access to all OCD social events, gatherings, and workshops</span>
<span>Access to all LAD social events, gatherings, and workshops</span>
</li>
<li className="flex items-start">
<span className="card-date font-bold mr-2"></span>
@ -57,7 +57,7 @@ export default function MembershipPage() {
</li>
<li className="flex items-start">
<span className="card-date font-bold mr-2"></span>
<span>Opportunity to shape the future of OCD through input and feedback</span>
<span>Opportunity to shape the future of LAD through input and feedback</span>
</li>
</ul>
</div>
@ -90,7 +90,7 @@ export default function MembershipPage() {
<div className="card video-container-gold p-6">
<div className="text-center mb-6">
<h3 className="card-title text-2xl font-bold mb-2">Regular</h3>
<p className="card-date text-3xl font-bold">$30/year</p>
<p className="card-date text-lg font-medium">Annual membership</p>
</div>
<ul className="space-y-3 card-description mb-8">
<li className="flex items-start">
@ -118,7 +118,7 @@ export default function MembershipPage() {
<div className="text-center mb-6">
<span className="inline-block bg-primary text-white px-3 py-1 rounded-full text-sm font-medium mb-2">Most Popular</span>
<h3 className="card-title text-2xl font-bold mb-2">Family</h3>
<p className="card-date text-3xl font-bold">$45/year</p>
<p className="card-date text-lg font-medium">Annual membership</p>
</div>
<ul className="space-y-3 card-description mb-8">
<li className="flex items-start">
@ -149,7 +149,7 @@ export default function MembershipPage() {
<div className="card video-container-gold p-6">
<div className="text-center mb-6">
<h3 className="card-title text-2xl font-bold mb-2">Lifetime</h3>
<p className="card-date text-3xl font-bold">$500</p>
<p className="card-date text-lg font-medium">One-time payment</p>
</div>
<ul className="space-y-3 card-description mb-8">
<li className="flex items-start">
@ -180,9 +180,8 @@ export default function MembershipPage() {
</div>
<div className="mt-10 text-center">
<p className="section-text">
Note: Student and senior discounts are available. Please
<Link href="/contact" className="card-link hover:underline"> contact us </Link>
for more information.
Please <Link href="/contact" className="card-link hover:underline"> contact us </Link>
for current membership rates and any available discounts.
</p>
</div>
</div>
@ -224,7 +223,7 @@ export default function MembershipPage() {
<div>
<h3 className="card-title text-xl font-semibold mb-2">Board Approval</h3>
<p className="section-text">
All applications are reviewed and approved by the OCD board. This process typically takes 1-2 weeks.
All applications are reviewed and approved by the LAD board. This process typically takes 1-2 weeks.
</p>
</div>
</div>
@ -235,7 +234,7 @@ export default function MembershipPage() {
<div>
<h3 className="card-title text-xl font-semibold mb-2">Receive Membership Card</h3>
<p className="section-text">
Once approved, you'll receive your official OCD membership card and welcome packet with information about upcoming events and activities.
Once approved, you'll receive your official LAD membership confirmation and welcome packet with information about upcoming events and activities.
</p>
</div>
</div>
@ -259,14 +258,14 @@ export default function MembershipPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<div className="card event-card-gold-border p-6">
<blockquote className="section-text mb-4">
"Joining OCD was one of the best decisions I've made. It's helped me connect with the deaf community in Olathe and make lifelong friends."
"Joining LAD was one of the best decisions I've made. It's helped me connect with the Deaf community across Louisiana and make lifelong friends."
</blockquote>
<div className="card-title font-semibold">Sarah K.</div>
<div className="card-date text-sm">Member since 2019</div>
</div>
<div className="card event-card-gold-border p-6">
<blockquote className="section-text mb-4">
"As a parent of a deaf child, OCD has been an invaluable resource for our family. The events and workshops have helped us all learn and grow together."
"As a parent of a deaf child, LAD has been an invaluable resource for our family. The events and workshops have helped us all learn and grow together."
</blockquote>
<div className="card-title font-semibold">David M.</div>
<div className="card-date text-sm">Member since 2021</div>
@ -287,7 +286,7 @@ export default function MembershipPage() {
<div className="container mx-auto px-4 text-center">
<h2 className="cta-title text-3xl font-bold mb-4">Ready to Join?</h2>
<p className="cta-description text-xl max-w-3xl mx-auto mb-8">
Become a member today and connect with the deaf community in Olathe.
Become a member today and connect with the Deaf community across Louisiana.
</p>
<div className="flex flex-col sm:flex-row justify-center gap-4">
<Link

View file

@ -1,217 +1,17 @@
// MongoDB initialization script - runs when container starts
// MongoDB initialization script — runs once when the container is first created.
// Create database
db = db.getSiblingDB('ocd_db');
db = db.getSiblingDB('lad_db');
// Create admin user if it doesn't exist
if (db.getUser('admin') == null) {
if (db.getUser(process.env.MONGO_USER || 'lad_admin') == null) {
db.createUser({
user: 'admin',
pwd: process.env.MONGO_ADMIN_PASSWORD || 'admin',
roles: [{ role: 'readWrite', db: 'ocd_db' }]
user: process.env.MONGO_USER || 'lad_admin',
pwd: process.env.MONGO_PASSWORD || 'changeme',
roles: [{ role: 'readWrite', db: 'lad_db' }]
});
}
// Create video collection with validation
db.createCollection('videos', {
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['title', 'fileUrl', 'thumbnailUrl', 'transcriptText'],
properties: {
title: {
bsonType: 'string',
minLength: 3,
description: 'Title is required and must be at least 3 characters'
},
description: {
bsonType: 'string',
description: 'Description of the video'
},
fileUrl: {
bsonType: 'string',
description: 'URL to the video file (required)'
},
thumbnailUrl: {
bsonType: 'string',
description: 'URL to the thumbnail image (required)'
},
duration: {
bsonType: 'number',
description: 'Duration of the video in seconds'
},
uploadDate: {
bsonType: 'date',
description: 'Date when the video was uploaded'
},
category: {
bsonType: 'string',
description: 'Category of the video'
},
subtitleUrl: {
bsonType: 'string',
description: 'URL to the WebVTT subtitle file'
},
transcriptText: {
bsonType: 'string',
description: 'Full text transcript of the video (required)'
},
transcriptFormat: {
enum: ['plain', 'html', 'json'],
description: 'Format of the transcript (plain, html, or json)'
},
isPublished: {
bsonType: 'bool',
description: 'Whether the video is published or not'
},
relatedPageId: {
bsonType: 'objectId',
description: 'Reference to a related page'
},
tags: {
bsonType: 'array',
items: { bsonType: 'string' },
description: 'Tags for categorizing the video'
}
}
}
},
validationLevel: 'strict'
});
// Collections are created automatically by Mongoose on first write.
// Index definitions live in the model files.
// This script exists to create the DB user and is safe to re-run.
// Create indexes for video collection
db.videos.createIndex({ 'title': 'text', 'transcriptText': 'text' });
db.videos.createIndex({ 'category': 1, 'uploadDate': -1 });
db.videos.createIndex({ 'tags': 1 });
// Create members collection with validation
db.createCollection('members', {
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['firstName', 'lastName', 'email', 'membershipType', 'joinDate', 'status'],
properties: {
firstName: {
bsonType: 'string',
description: 'First name is required'
},
lastName: {
bsonType: 'string',
description: 'Last name is required'
},
email: {
bsonType: 'string',
pattern: '^.+@.+\\..+$',
description: 'Email must be a valid email address'
},
phone: {
bsonType: 'string',
description: 'Phone number'
},
address: {
bsonType: 'object',
properties: {
street: { bsonType: 'string' },
city: { bsonType: 'string' },
state: { bsonType: 'string' },
zip: { bsonType: 'string' }
}
},
membershipType: {
enum: ['regular', 'lifetime', 'honorary'],
description: 'Membership type must be one of: regular, lifetime, honorary'
},
joinDate: {
bsonType: 'date',
description: 'Date when the member joined'
},
expirationDate: {
bsonType: 'date',
description: 'Date when the membership expires'
},
status: {
enum: ['active', 'expired', 'pending'],
description: 'Status must be one of: active, expired, pending'
},
notificationPreference: {
enum: ['email', 'sms', 'both'],
description: 'Notification preference must be one of: email, sms, both'
},
lastRenewalDate: {
bsonType: 'date',
description: 'Date of the last membership renewal'
},
boardMember: {
bsonType: 'bool',
description: 'Whether the member is part of the board'
},
boardPosition: {
bsonType: 'string',
description: 'Position on the board, if applicable'
},
emergencyContact: {
bsonType: 'object',
properties: {
name: { bsonType: 'string' },
relationship: { bsonType: 'string' },
phone: { bsonType: 'string' }
}
}
}
}
},
validationLevel: 'strict'
});
// Create indexes for members collection
db.members.createIndex({ 'email': 1 }, { unique: true });
db.members.createIndex({ 'lastName': 1, 'firstName': 1 });
db.members.createIndex({ 'status': 1, 'expirationDate': 1 });
db.members.createIndex({ 'boardMember': 1 });
// Create admin user collection with validation
db.createCollection('adminUsers', {
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['username', 'passwordHash', 'email'],
properties: {
username: {
bsonType: 'string',
minLength: 3,
maxLength: 50,
description: 'Username is required and must be between 3 and 50 characters'
},
passwordHash: {
bsonType: 'string',
description: 'Password hash is required'
},
email: {
bsonType: 'string',
pattern: '^.+@.+\\..+$',
description: 'Email must be a valid email address'
},
lastLogin: {
bsonType: 'date',
description: 'Date of the last login'
},
resetToken: {
bsonType: 'string',
description: 'Token for password reset'
},
resetTokenExpiry: {
bsonType: 'date',
description: 'Expiry date for password reset token'
}
}
}
},
validationLevel: 'strict'
});
// Create indexes for admin users collection
db.adminUsers.createIndex({ 'username': 1 }, { unique: true });
db.adminUsers.createIndex({ 'email': 1 }, { unique: true });
db.adminUsers.createIndex({ 'resetToken': 1 }, { sparse: true });
console.log('MongoDB initialization completed');
console.log('LAD MongoDB initialization complete.');