diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000..b95068e --- /dev/null +++ b/backend/.env.example @@ -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 diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..3990d45 --- /dev/null +++ b/backend/.gitignore @@ -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 diff --git a/backend/scripts/migrate-directus.ts b/backend/scripts/migrate-directus.ts new file mode 100644 index 0000000..ea25645 --- /dev/null +++ b/backend/scripts/migrate-directus.ts @@ -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 { + 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; + skipped: Record; + 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); +}); diff --git a/backend/src/controllers/articleController.ts b/backend/src/controllers/articleController.ts new file mode 100644 index 0000000..acdc955 --- /dev/null +++ b/backend/src/controllers/articleController.ts @@ -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' } }); + } +}; diff --git a/backend/src/controllers/updateController.ts b/backend/src/controllers/updateController.ts new file mode 100644 index 0000000..68b83e9 --- /dev/null +++ b/backend/src/controllers/updateController.ts @@ -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' } }); + } +}; diff --git a/backend/src/index.ts b/backend/src/index.ts index 3d8e24b..689e67d 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) => { diff --git a/backend/src/models/Article.ts b/backend/src/models/Article.ts new file mode 100644 index 0000000..a4ecf44 --- /dev/null +++ b/backend/src/models/Article.ts @@ -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('Article', ArticleSchema); diff --git a/backend/src/models/Document.ts b/backend/src/models/Document.ts index 2321765..1bc37a9 100644 --- a/backend/src/models/Document.ts +++ b/backend/src/models/Document.ts @@ -8,9 +8,12 @@ export interface IDocument extends Document { originalFilename: string; fileType: string; fileSize: number; - documentType: 'meeting_minutes' | 'board_meeting_minutes' | 'committee_meeting_minutes' | 'annual_meeting_minutes' | - 'bylaws' | 'financial_report' | 'annual_report' | 'board_report' | 'committee_report' | + documentType: 'meeting_minutes' | 'board_meeting_minutes' | 'committee_meeting_minutes' | 'annual_meeting_minutes' | + 'quarterly_meeting_minutes' | 'conference_minutes' | 'special_meeting_minutes' | + 'bylaws' | 'financial_report' | 'annual_report' | 'board_report' | 'committee_report' | 'meeting_agenda' | 'board_meeting_agenda' | 'committee_meeting_agenda' | 'program_document'; + directusId?: number; + directusAssetId?: string; isPublic: boolean; meetingDate?: Date; uploadDate: Date; @@ -56,6 +59,7 @@ const DocumentSchema: Schema = new Schema({ required: true, enum: [ 'meeting_minutes', 'board_meeting_minutes', 'committee_meeting_minutes', 'annual_meeting_minutes', + 'quarterly_meeting_minutes', 'conference_minutes', 'special_meeting_minutes', 'bylaws', 'financial_report', 'annual_report', 'board_report', 'committee_report', 'meeting_agenda', 'board_meeting_agenda', 'committee_meeting_agenda', 'program_document' ], @@ -91,7 +95,9 @@ const DocumentSchema: Schema = new Schema({ textVersionPath: { type: String, trim: true - } + }, + directusId: { type: Number, index: true, sparse: true }, + directusAssetId: { type: String, trim: true } }, { timestamps: true }); diff --git a/backend/src/models/Event.ts b/backend/src/models/Event.ts index 9a1140b..979005a 100644 --- a/backend/src/models/Event.ts +++ b/backend/src/models/Event.ts @@ -16,6 +16,9 @@ export interface IEvent extends Document { recurrencePattern?: string; organizer?: mongoose.Types.ObjectId; tags?: string[]; + directusId?: number; + photoUrl?: string; + photoCaption?: string; } // Event schema @@ -77,14 +80,17 @@ const EventSchema: Schema = new Schema({ recurrencePattern: { type: String }, - organizer: { - type: mongoose.Schema.Types.ObjectId, - ref: 'User' + organizer: { + type: mongoose.Schema.Types.ObjectId, + ref: 'User' }, - tags: [{ + tags: [{ type: String, - trim: true - }] + trim: true + }], + directusId: { type: Number, index: true, sparse: true }, + photoUrl: { type: String, trim: true }, + photoCaption: { type: String, trim: true } }, { timestamps: true }); diff --git a/backend/src/models/Update.ts b/backend/src/models/Update.ts new file mode 100644 index 0000000..f29a97e --- /dev/null +++ b/backend/src/models/Update.ts @@ -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('Update', UpdateSchema); diff --git a/backend/src/routes/articles.ts b/backend/src/routes/articles.ts new file mode 100644 index 0000000..3bf896e --- /dev/null +++ b/backend/src/routes/articles.ts @@ -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; diff --git a/backend/src/routes/updates.ts b/backend/src/routes/updates.ts new file mode 100644 index 0000000..783e1d9 --- /dev/null +++ b/backend/src/routes/updates.ts @@ -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; diff --git a/backend/uploads/documents/.gitkeep b/backend/uploads/documents/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/uploads/images/.gitkeep b/backend/uploads/images/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/uploads/subtitles/.gitkeep b/backend/uploads/subtitles/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/uploads/textversions/.gitkeep b/backend/uploads/textversions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/uploads/thumbnails/.gitkeep b/backend/uploads/thumbnails/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/uploads/transcripts/.gitkeep b/backend/uploads/transcripts/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/backend/uploads/videos/.gitkeep b/backend/uploads/videos/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index 1f6d566..a9637e5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/frontend/next-env.d.ts b/frontend/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/frontend/next-env.d.ts +++ b/frontend/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/frontend/src/app/contact/page.tsx b/frontend/src/app/contact/page.tsx index 2c4ffeb..837664e 100644 --- a/frontend/src/app/contact/page.tsx +++ b/frontend/src/app/contact/page.tsx @@ -3,8 +3,8 @@ import type { Metadata } from 'next'; import ContactForm from '../../components/molecules/ContactForm'; export const metadata: Metadata = { - title: 'Contact Us | Olathe Club of the Deaf', - description: 'Get in touch with Olathe Club of the Deaf. We\'d love to hear from you!', + title: 'Contact Us | Louisiana Association of the Deaf', + description: 'Get in touch with the Louisiana Association of the Deaf. We\'d love to hear from you!', }; export default function ContactPage() { @@ -39,29 +39,34 @@ export default function ContactPage() {
- +
-

Meeting Location

-
- 123 Main Street
- Olathe, KS 66061 -
+

Interpreter Requests (Deaf Focus)

+ + request@deaffocus.org + +

Phone: (225) 319-5586

+

Fax: (225) 308-4025

+

Hours: Mon–Fri, 9am–4pm

- +
-

Connect With Us

+

Follow Us

@@ -109,40 +90,7 @@ export default function ContactPage() { - {/* Map section */} -
-
-
-

Our Location

- - {/* Map placeholder - would be replaced with actual Google Maps component */} -
-
-

Google Maps will be embedded here

-
-
- -
-

- We meet at 123 Main Street, Olathe, KS 66061 -

-

- - Get Directions - - - - -

-
-
-
-
+ ); } diff --git a/frontend/src/app/donate/page.tsx b/frontend/src/app/donate/page.tsx index a11e0a5..04303df 100644 --- a/frontend/src/app/donate/page.tsx +++ b/frontend/src/app/donate/page.tsx @@ -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() {

Support Our Mission

- 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.

@@ -199,7 +199,7 @@ export default function DonatePage() {

Is my donation tax-deductible?

- 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.

@@ -211,7 +211,7 @@ export default function DonatePage() {

Do you accept donations by check or mail?

- 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.

@@ -229,7 +229,7 @@ export default function DonatePage() {

Ready to Make a Difference?

- 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.

diff --git a/frontend/src/app/membership/page.tsx b/frontend/src/app/membership/page.tsx index 0ad7f0a..6f420cf 100644 --- a/frontend/src/app/membership/page.tsx +++ b/frontend/src/app/membership/page.tsx @@ -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() {

Become a Member

- 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.

@@ -32,7 +32,7 @@ export default function MembershipPage() {
  • - Access to all OCD social events, gatherings, and workshops + Access to all LAD social events, gatherings, and workshops
  • @@ -57,7 +57,7 @@ export default function MembershipPage() {
  • - Opportunity to shape the future of OCD through input and feedback + Opportunity to shape the future of LAD through input and feedback
@@ -90,7 +90,7 @@ export default function MembershipPage() {

Regular

-

$30/year

+

Annual membership

  • @@ -118,7 +118,7 @@ export default function MembershipPage() {
    Most Popular

    Family

    -

    $45/year

    +

    Annual membership

    • @@ -149,7 +149,7 @@ export default function MembershipPage() {

      Lifetime

      -

      $500

      +

      One-time payment

      • @@ -180,9 +180,8 @@ export default function MembershipPage() {

      - Note: Student and senior discounts are available. Please - contact us - for more information. + Please contact us + for current membership rates and any available discounts.

@@ -224,7 +223,7 @@ export default function MembershipPage() {

Board Approval

- 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.

@@ -235,7 +234,7 @@ export default function MembershipPage() {

Receive Membership Card

- 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.

@@ -259,14 +258,14 @@ export default function MembershipPage() {
- "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."
Sarah K.
Member since 2019
- "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."
David M.
Member since 2021
@@ -287,7 +286,7 @@ export default function MembershipPage() {

Ready to Join?

- Become a member today and connect with the deaf community in Olathe. + Become a member today and connect with the Deaf community across Louisiana.