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:
parent
c8a76c7763
commit
c9e042fa98
25 changed files with 725 additions and 321 deletions
17
backend/.env.example
Normal file
17
backend/.env.example
Normal 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
18
backend/.gitignore
vendored
Normal 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
|
||||
343
backend/scripts/migrate-directus.ts
Normal file
343
backend/scripts/migrate-directus.ts
Normal 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);
|
||||
});
|
||||
80
backend/src/controllers/articleController.ts
Normal file
80
backend/src/controllers/articleController.ts
Normal 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' } });
|
||||
}
|
||||
};
|
||||
95
backend/src/controllers/updateController.ts
Normal file
95
backend/src/controllers/updateController.ts
Normal 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' } });
|
||||
}
|
||||
};
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
28
backend/src/models/Article.ts
Normal file
28
backend/src/models/Article.ts
Normal 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);
|
||||
|
|
@ -9,8 +9,11 @@ export interface IDocument extends Document {
|
|||
fileType: string;
|
||||
fileSize: number;
|
||||
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
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -84,7 +87,10 @@ const EventSchema: Schema = new Schema({
|
|||
tags: [{
|
||||
type: String,
|
||||
trim: true
|
||||
}]
|
||||
}],
|
||||
directusId: { type: Number, index: true, sparse: true },
|
||||
photoUrl: { type: String, trim: true },
|
||||
photoCaption: { type: String, trim: true }
|
||||
}, {
|
||||
timestamps: true
|
||||
});
|
||||
|
|
|
|||
36
backend/src/models/Update.ts
Normal file
36
backend/src/models/Update.ts
Normal 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);
|
||||
12
backend/src/routes/articles.ts
Normal file
12
backend/src/routes/articles.ts
Normal 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;
|
||||
12
backend/src/routes/updates.ts
Normal file
12
backend/src/routes/updates.ts
Normal 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;
|
||||
0
backend/uploads/documents/.gitkeep
Normal file
0
backend/uploads/documents/.gitkeep
Normal file
0
backend/uploads/images/.gitkeep
Normal file
0
backend/uploads/images/.gitkeep
Normal file
0
backend/uploads/subtitles/.gitkeep
Normal file
0
backend/uploads/subtitles/.gitkeep
Normal file
0
backend/uploads/textversions/.gitkeep
Normal file
0
backend/uploads/textversions/.gitkeep
Normal file
0
backend/uploads/thumbnails/.gitkeep
Normal file
0
backend/uploads/thumbnails/.gitkeep
Normal file
0
backend/uploads/transcripts/.gitkeep
Normal file
0
backend/uploads/transcripts/.gitkeep
Normal file
0
backend/uploads/videos/.gitkeep
Normal file
0
backend/uploads/videos/.gitkeep
Normal 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
|
||||
|
|
|
|||
2
frontend/next-env.d.ts
vendored
2
frontend/next-env.d.ts
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,28 +39,33 @@ 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>
|
||||
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">General Inquiries</h3>
|
||||
<a
|
||||
href="mailto:info@olathedeafclub.com"
|
||||
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: Mon–Fri, 9am–4pm</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"
|
||||
href="https://facebook.com/lad1908"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer-social-icon"
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue