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 memberRoutes from './routes/members';
|
||||||
import videoRoutes from './routes/videos';
|
import videoRoutes from './routes/videos';
|
||||||
import documentRoutes from './routes/documents';
|
import documentRoutes from './routes/documents';
|
||||||
|
import updateRoutes from './routes/updates';
|
||||||
|
import articleRoutes from './routes/articles';
|
||||||
|
|
||||||
// Create Express app
|
// Create Express app
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
@ -27,7 +29,7 @@ const connectDB = async () => {
|
||||||
const mongoPassword = process.env.MONGO_PASSWORD;
|
const mongoPassword = process.env.MONGO_PASSWORD;
|
||||||
const mongoHost = process.env.MONGO_HOST || 'mongodb';
|
const mongoHost = process.env.MONGO_HOST || 'mongodb';
|
||||||
const mongoPort = process.env.MONGO_PORT || '27017';
|
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;
|
let mongoUri;
|
||||||
if (mongoUser && mongoPassword) {
|
if (mongoUser && mongoPassword) {
|
||||||
|
|
@ -75,6 +77,8 @@ app.use('/api/events', eventRoutes);
|
||||||
app.use('/api/members', memberRoutes);
|
app.use('/api/members', memberRoutes);
|
||||||
app.use('/api/videos', videoRoutes);
|
app.use('/api/videos', videoRoutes);
|
||||||
app.use('/api/documents', documentRoutes);
|
app.use('/api/documents', documentRoutes);
|
||||||
|
app.use('/api/updates', updateRoutes);
|
||||||
|
app.use('/api/articles', articleRoutes);
|
||||||
|
|
||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => {
|
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);
|
||||||
|
|
@ -8,9 +8,12 @@ export interface IDocument extends Document {
|
||||||
originalFilename: string;
|
originalFilename: string;
|
||||||
fileType: string;
|
fileType: string;
|
||||||
fileSize: number;
|
fileSize: number;
|
||||||
documentType: 'meeting_minutes' | 'board_meeting_minutes' | 'committee_meeting_minutes' | 'annual_meeting_minutes' |
|
documentType: 'meeting_minutes' | 'board_meeting_minutes' | 'committee_meeting_minutes' | 'annual_meeting_minutes' |
|
||||||
'bylaws' | 'financial_report' | 'annual_report' | 'board_report' | 'committee_report' |
|
'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';
|
'meeting_agenda' | 'board_meeting_agenda' | 'committee_meeting_agenda' | 'program_document';
|
||||||
|
directusId?: number;
|
||||||
|
directusAssetId?: string;
|
||||||
isPublic: boolean;
|
isPublic: boolean;
|
||||||
meetingDate?: Date;
|
meetingDate?: Date;
|
||||||
uploadDate: Date;
|
uploadDate: Date;
|
||||||
|
|
@ -56,6 +59,7 @@ const DocumentSchema: Schema = new Schema({
|
||||||
required: true,
|
required: true,
|
||||||
enum: [
|
enum: [
|
||||||
'meeting_minutes', 'board_meeting_minutes', 'committee_meeting_minutes', 'annual_meeting_minutes',
|
'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',
|
'bylaws', 'financial_report', 'annual_report', 'board_report', 'committee_report',
|
||||||
'meeting_agenda', 'board_meeting_agenda', 'committee_meeting_agenda', 'program_document'
|
'meeting_agenda', 'board_meeting_agenda', 'committee_meeting_agenda', 'program_document'
|
||||||
],
|
],
|
||||||
|
|
@ -91,7 +95,9 @@ const DocumentSchema: Schema = new Schema({
|
||||||
textVersionPath: {
|
textVersionPath: {
|
||||||
type: String,
|
type: String,
|
||||||
trim: true
|
trim: true
|
||||||
}
|
},
|
||||||
|
directusId: { type: Number, index: true, sparse: true },
|
||||||
|
directusAssetId: { type: String, trim: true }
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
timestamps: true
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ export interface IEvent extends Document {
|
||||||
recurrencePattern?: string;
|
recurrencePattern?: string;
|
||||||
organizer?: mongoose.Types.ObjectId;
|
organizer?: mongoose.Types.ObjectId;
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
directusId?: number;
|
||||||
|
photoUrl?: string;
|
||||||
|
photoCaption?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Event schema
|
// Event schema
|
||||||
|
|
@ -77,14 +80,17 @@ const EventSchema: Schema = new Schema({
|
||||||
recurrencePattern: {
|
recurrencePattern: {
|
||||||
type: String
|
type: String
|
||||||
},
|
},
|
||||||
organizer: {
|
organizer: {
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'User'
|
ref: 'User'
|
||||||
},
|
},
|
||||||
tags: [{
|
tags: [{
|
||||||
type: String,
|
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
|
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:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./security-reports:/reports
|
- ./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:
|
profiles:
|
||||||
- security
|
- security
|
||||||
|
|
||||||
|
|
@ -151,6 +151,6 @@ networks:
|
||||||
# Volumes with backup capability
|
# Volumes with backup capability
|
||||||
volumes:
|
volumes:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
name: ocd-mongo-data
|
name: lad-mongo-data
|
||||||
redis-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" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// 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';
|
import ContactForm from '../../components/molecules/ContactForm';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Contact Us | Olathe Club of the Deaf',
|
title: 'Contact Us | Louisiana Association of the Deaf',
|
||||||
description: 'Get in touch with Olathe Club of the Deaf. We\'d love to hear from you!',
|
description: 'Get in touch with the Louisiana Association of the Deaf. We\'d love to hear from you!',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ContactPage() {
|
export default function ContactPage() {
|
||||||
|
|
@ -39,29 +39,34 @@ export default function ContactPage() {
|
||||||
|
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="card event-card-gold-border p-4">
|
<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
|
<a
|
||||||
href="mailto:info@olathedeafclub.com"
|
href="mailto:info@lad1908.org"
|
||||||
className="card-link hover:underline"
|
className="card-link hover:underline"
|
||||||
>
|
>
|
||||||
info@olathedeafclub.com
|
info@lad1908.org
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="card event-card-gold-border p-4">
|
<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>
|
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">Interpreter Requests (Deaf Focus)</h3>
|
||||||
<address className="not-italic section-text">
|
<a
|
||||||
123 Main Street<br />
|
href="mailto:request@deaffocus.org"
|
||||||
Olathe, KS 66061
|
className="card-link hover:underline block mb-1"
|
||||||
</address>
|
>
|
||||||
|
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>
|
||||||
|
|
||||||
<div className="card event-card-gold-border p-4">
|
<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">
|
<div className="flex space-x-4">
|
||||||
<a
|
<a
|
||||||
href="https://facebook.com"
|
href="https://facebook.com/lad1908"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="footer-social-icon"
|
className="footer-social-icon"
|
||||||
aria-label="Facebook page"
|
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" />
|
<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>
|
</svg>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -109,40 +90,7 @@ export default function ContactPage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import Link from 'next/link';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Donate | Olathe Club of the Deaf',
|
title: 'Donate | Louisiana Association of the Deaf',
|
||||||
description: 'Support the Olathe Club of the Deaf through donations. Your contribution helps us serve the deaf community in Olathe.',
|
description: 'Support the Louisiana Association of the Deaf through donations. Your contribution helps us serve the Deaf community across Louisiana.',
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function DonatePage() {
|
export default function DonatePage() {
|
||||||
|
|
@ -16,7 +16,7 @@ export default function DonatePage() {
|
||||||
<div className="max-w-3xl mx-auto text-center">
|
<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>
|
<h1 className="hero-title text-4xl md:text-5xl font-bold mb-6">Support Our Mission</h1>
|
||||||
<p className="hero-subtitle text-xl">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -199,7 +199,7 @@ export default function DonatePage() {
|
||||||
<div className="card event-card-gold-border p-6">
|
<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>
|
<h3 className="card-title event-card-title-gold text-xl font-semibold mb-2">Is my donation tax-deductible?</h3>
|
||||||
<p className="section-text">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card event-card-gold-border p-6">
|
<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">
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="card event-card-gold-border p-6">
|
<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">
|
<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>
|
<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">
|
<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>
|
</p>
|
||||||
<div className="card video-container-gold rounded-lg p-8 max-w-3xl mx-auto">
|
<div className="card video-container-gold rounded-lg p-8 max-w-3xl mx-auto">
|
||||||
<p className="section-text text-center mb-6">
|
<p className="section-text text-center mb-6">
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import Link from 'next/link';
|
||||||
import type { Metadata } from 'next';
|
import type { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Membership | Olathe Club of the Deaf',
|
title: 'Membership | Louisiana Association 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.',
|
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() {
|
export default function MembershipPage() {
|
||||||
|
|
@ -16,7 +16,7 @@ export default function MembershipPage() {
|
||||||
<div className="max-w-3xl mx-auto text-center">
|
<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>
|
<h1 className="hero-title text-4xl md:text-5xl font-bold mb-6">Become a Member</h1>
|
||||||
<p className="hero-subtitle text-xl">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32,7 +32,7 @@ export default function MembershipPage() {
|
||||||
<ul className="space-y-3 benefit-card-description">
|
<ul className="space-y-3 benefit-card-description">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="card-date font-bold mr-2">•</span>
|
<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>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="card-date font-bold mr-2">•</span>
|
<span className="card-date font-bold mr-2">•</span>
|
||||||
|
|
@ -57,7 +57,7 @@ export default function MembershipPage() {
|
||||||
</li>
|
</li>
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
<span className="card-date font-bold mr-2">•</span>
|
<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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -90,7 +90,7 @@ export default function MembershipPage() {
|
||||||
<div className="card video-container-gold p-6">
|
<div className="card video-container-gold p-6">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h3 className="card-title text-2xl font-bold mb-2">Regular</h3>
|
<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>
|
</div>
|
||||||
<ul className="space-y-3 card-description mb-8">
|
<ul className="space-y-3 card-description mb-8">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
|
|
@ -118,7 +118,7 @@ export default function MembershipPage() {
|
||||||
<div className="text-center mb-6">
|
<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>
|
<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>
|
<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>
|
</div>
|
||||||
<ul className="space-y-3 card-description mb-8">
|
<ul className="space-y-3 card-description mb-8">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
|
|
@ -149,7 +149,7 @@ export default function MembershipPage() {
|
||||||
<div className="card video-container-gold p-6">
|
<div className="card video-container-gold p-6">
|
||||||
<div className="text-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<h3 className="card-title text-2xl font-bold mb-2">Lifetime</h3>
|
<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>
|
</div>
|
||||||
<ul className="space-y-3 card-description mb-8">
|
<ul className="space-y-3 card-description mb-8">
|
||||||
<li className="flex items-start">
|
<li className="flex items-start">
|
||||||
|
|
@ -180,9 +180,8 @@ export default function MembershipPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-10 text-center">
|
<div className="mt-10 text-center">
|
||||||
<p className="section-text">
|
<p className="section-text">
|
||||||
Note: Student and senior discounts are available. Please
|
Please <Link href="/contact" className="card-link hover:underline"> contact us </Link>
|
||||||
<Link href="/contact" className="card-link hover:underline"> contact us </Link>
|
for current membership rates and any available discounts.
|
||||||
for more information.
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -224,7 +223,7 @@ export default function MembershipPage() {
|
||||||
<div>
|
<div>
|
||||||
<h3 className="card-title text-xl font-semibold mb-2">Board Approval</h3>
|
<h3 className="card-title text-xl font-semibold mb-2">Board Approval</h3>
|
||||||
<p className="section-text">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -235,7 +234,7 @@ export default function MembershipPage() {
|
||||||
<div>
|
<div>
|
||||||
<h3 className="card-title text-xl font-semibold mb-2">Receive Membership Card</h3>
|
<h3 className="card-title text-xl font-semibold mb-2">Receive Membership Card</h3>
|
||||||
<p className="section-text">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -259,14 +258,14 @@ export default function MembershipPage() {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||||
<div className="card event-card-gold-border p-6">
|
<div className="card event-card-gold-border p-6">
|
||||||
<blockquote className="section-text mb-4">
|
<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>
|
</blockquote>
|
||||||
<div className="card-title font-semibold">Sarah K.</div>
|
<div className="card-title font-semibold">Sarah K.</div>
|
||||||
<div className="card-date text-sm">Member since 2019</div>
|
<div className="card-date text-sm">Member since 2019</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="card event-card-gold-border p-6">
|
<div className="card event-card-gold-border p-6">
|
||||||
<blockquote className="section-text mb-4">
|
<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>
|
</blockquote>
|
||||||
<div className="card-title font-semibold">David M.</div>
|
<div className="card-title font-semibold">David M.</div>
|
||||||
<div className="card-date text-sm">Member since 2021</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">
|
<div className="container mx-auto px-4 text-center">
|
||||||
<h2 className="cta-title text-3xl font-bold mb-4">Ready to Join?</h2>
|
<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">
|
<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>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
<div className="flex flex-col sm:flex-row justify-center gap-4">
|
||||||
<Link
|
<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('lad_db');
|
||||||
db = db.getSiblingDB('ocd_db');
|
|
||||||
|
|
||||||
// Create admin user if it doesn't exist
|
if (db.getUser(process.env.MONGO_USER || 'lad_admin') == null) {
|
||||||
if (db.getUser('admin') == null) {
|
|
||||||
db.createUser({
|
db.createUser({
|
||||||
user: 'admin',
|
user: process.env.MONGO_USER || 'lad_admin',
|
||||||
pwd: process.env.MONGO_ADMIN_PASSWORD || 'admin',
|
pwd: process.env.MONGO_PASSWORD || 'changeme',
|
||||||
roles: [{ role: 'readWrite', db: 'ocd_db' }]
|
roles: [{ role: 'readWrite', db: 'lad_db' }]
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create video collection with validation
|
// Collections are created automatically by Mongoose on first write.
|
||||||
db.createCollection('videos', {
|
// Index definitions live in the model files.
|
||||||
validator: {
|
// This script exists to create the DB user and is safe to re-run.
|
||||||
$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'
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create indexes for video collection
|
console.log('LAD MongoDB initialization complete.');
|
||||||
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');
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue