diff --git a/.claude/session-state.json b/.claude/session-state.json
index bdffcd4a..14eff928 100644
--- a/.claude/session-state.json
+++ b/.claude/session-state.json
@@ -43,13 +43,13 @@
"last_deliberation": null
},
"FileEditHook": {
- "timestamp": "2025-10-24T03:42:14.478Z",
- "file": "/home/theflow/projects/tractatus/src/middleware/csrf-protection.middleware.js",
+ "timestamp": "2025-10-24T03:55:50.791Z",
+ "file": "/home/theflow/projects/tractatus/public/js/components/footer.js",
"result": "passed"
},
"FileWriteHook": {
- "timestamp": "2025-10-24T00:04:27.173Z",
- "file": "/home/theflow/projects/tractatus/public/js/admin/editorial-guidelines.js",
+ "timestamp": "2025-10-24T03:53:41.535Z",
+ "file": "/home/theflow/projects/tractatus/src/routes/contact.routes.js",
"result": "passed"
}
},
@@ -58,25 +58,25 @@
"tokens": 30000
},
"alerts": [],
- "last_updated": "2025-10-24T03:42:14.478Z",
+ "last_updated": "2025-10-24T03:55:50.791Z",
"initialized": true,
"framework_components": {
"CrossReferenceValidator": {
"message": 0,
"tokens": 0,
- "timestamp": "2025-10-24T03:44:35.044Z",
- "last_validation": "2025-10-24T03:44:35.043Z",
- "validations_performed": 961
+ "timestamp": "2025-10-24T03:56:06.108Z",
+ "last_validation": "2025-10-24T03:56:06.108Z",
+ "validations_performed": 977
},
"BashCommandValidator": {
"message": 0,
"tokens": 0,
"timestamp": null,
- "last_validation": "2025-10-24T03:44:35.045Z",
- "validations_performed": 593,
- "blocks_issued": 68
+ "last_validation": "2025-10-24T03:56:06.109Z",
+ "validations_performed": 599,
+ "blocks_issued": 69
}
},
- "action_count": 593,
+ "action_count": 599,
"auto_compact_events": []
}
\ No newline at end of file
diff --git a/public/js/components/footer.js b/public/js/components/footer.js
index adb05c02..a41de994 100644
--- a/public/js/components/footer.js
+++ b/public/js/components/footer.js
@@ -86,7 +86,7 @@
Legal
@@ -118,6 +118,83 @@
+
+
+
`;
// Insert footer at end of body
@@ -168,6 +245,118 @@
console.log('[Footer] Language changed to:', event.detail.language);
this.render();
});
+
+ // Contact modal functionality
+ this.setupContactModal();
+ }
+
+ setupContactModal() {
+ const modal = document.getElementById('contact-modal');
+ const openBtn = document.getElementById('open-contact-modal');
+ const closeBtn = document.getElementById('close-contact-modal');
+ const cancelBtn = document.getElementById('cancel-contact');
+ const form = document.getElementById('contact-form');
+ const successMsg = document.getElementById('contact-success');
+ const errorMsg = document.getElementById('contact-error');
+ const errorText = document.getElementById('contact-error-message');
+ const submitBtn = document.getElementById('contact-submit');
+
+ if (!modal || !openBtn || !form) {
+ console.warn('[Footer] Contact modal elements not found');
+ return;
+ }
+
+ const openModal = () => {
+ modal.classList.remove('hidden');
+ document.getElementById('contact-name')?.focus();
+ };
+
+ const closeModal = () => {
+ modal.classList.add('hidden');
+ form.reset();
+ successMsg.classList.add('hidden');
+ errorMsg.classList.add('hidden');
+ };
+
+ // Event listeners
+ openBtn.addEventListener('click', (e) => {
+ e.preventDefault();
+ openModal();
+ });
+
+ closeBtn?.addEventListener('click', closeModal);
+ cancelBtn?.addEventListener('click', closeModal);
+
+ // Close on backdrop click
+ modal.addEventListener('click', (e) => {
+ if (e.target === modal) {
+ closeModal();
+ }
+ });
+
+ // Form submission
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ // Reset messages
+ successMsg.classList.add('hidden');
+ errorMsg.classList.add('hidden');
+
+ const formData = {
+ type: document.getElementById('contact-type').value,
+ name: document.getElementById('contact-name').value,
+ email: document.getElementById('contact-email').value,
+ organization: document.getElementById('contact-organization').value || null,
+ subject: document.getElementById('contact-subject').value || null,
+ message: document.getElementById('contact-message').value
+ };
+
+ // Disable submit button
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Sending...';
+
+ try {
+ // Get CSRF token from cookie
+ const csrfToken = document.cookie
+ .split('; ')
+ .find(row => row.startsWith('csrf-token='))
+ ?.split('=')[1];
+
+ const response = await fetch('/api/contact/submit', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRF-Token': csrfToken || ''
+ },
+ body: JSON.stringify(formData)
+ });
+
+ const data = await response.json();
+
+ if (response.ok && data.success) {
+ // Show success message
+ successMsg.classList.remove('hidden');
+ form.reset();
+
+ // Close modal after 2 seconds
+ setTimeout(() => {
+ closeModal();
+ }, 2000);
+ } else {
+ // Show error message
+ errorText.textContent = data.message || data.error || 'Failed to send message. Please try again.';
+ errorMsg.classList.remove('hidden');
+ }
+ } catch (error) {
+ console.error('Contact form error:', error);
+ errorText.textContent = 'Network error. Please check your connection and try again.';
+ errorMsg.classList.remove('hidden');
+ } finally {
+ // Re-enable submit button
+ submitBtn.disabled = false;
+ submitBtn.textContent = 'Send Message';
+ }
+ });
}
}
diff --git a/src/controllers/contact.controller.js b/src/controllers/contact.controller.js
new file mode 100644
index 00000000..d00b7897
--- /dev/null
+++ b/src/controllers/contact.controller.js
@@ -0,0 +1,270 @@
+/**
+ * Contact Controller
+ * Handle general contact form submissions
+ */
+
+const Contact = require('../models/Contact.model');
+const logger = require('../utils/logger.util');
+const { getClientIp } = require('../utils/security-logger');
+
+/**
+ * POST /api/contact/submit
+ * Submit a contact form
+ */
+async function submit(req, res) {
+ try {
+ const { type, name, email, organization, phone, subject, message } = req.body;
+
+ // Create contact record
+ const contact = await Contact.create({
+ type: type || 'general',
+ contact: {
+ name,
+ email,
+ organization,
+ phone
+ },
+ inquiry: {
+ subject,
+ message
+ },
+ source: 'footer_contact',
+ metadata: {
+ user_agent: req.get('user-agent'),
+ ip: getClientIp(req),
+ source_page: req.get('referer'),
+ referrer: req.get('referer')
+ }
+ });
+
+ logger.info(`[Contact] New submission: ${contact._id} from ${email}`);
+
+ res.status(201).json({
+ success: true,
+ message: 'Thank you for contacting us. We will respond within 24 hours.',
+ contact_id: contact._id
+ });
+
+ } catch (error) {
+ logger.error('[Contact] Submit error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to submit contact form',
+ message: 'An error occurred while submitting your message. Please try again.'
+ });
+ }
+}
+
+/**
+ * GET /api/contact/admin/list
+ * List contact submissions (admin)
+ */
+async function list(req, res) {
+ try {
+ const { status, type, priority, limit = 20, skip = 0 } = req.query;
+
+ const filters = {};
+ if (status) filters.status = status;
+ if (type) filters.type = type;
+ if (priority) filters.priority = priority;
+
+ const contacts = await Contact.list(filters, {
+ limit: parseInt(limit),
+ skip: parseInt(skip)
+ });
+
+ const total = await Contact.countByStatus(status || undefined);
+
+ res.json({
+ success: true,
+ contacts,
+ pagination: {
+ total,
+ limit: parseInt(limit),
+ skip: parseInt(skip),
+ hasMore: parseInt(skip) + contacts.length < total
+ }
+ });
+
+ } catch (error) {
+ logger.error('[Contact] List error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch contacts'
+ });
+ }
+}
+
+/**
+ * GET /api/contact/admin/stats
+ * Get contact statistics (admin)
+ */
+async function getStats(req, res) {
+ try {
+ const stats = await Contact.getStats();
+
+ res.json({
+ success: true,
+ stats
+ });
+
+ } catch (error) {
+ logger.error('[Contact] Stats error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch statistics'
+ });
+ }
+}
+
+/**
+ * GET /api/contact/admin/:id
+ * Get single contact by ID (admin)
+ */
+async function getById(req, res) {
+ try {
+ const { id } = req.params;
+
+ const contact = await Contact.findById(id);
+
+ if (!contact) {
+ return res.status(404).json({
+ success: false,
+ error: 'Contact not found'
+ });
+ }
+
+ res.json({
+ success: true,
+ contact
+ });
+
+ } catch (error) {
+ logger.error('[Contact] Get by ID error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to fetch contact'
+ });
+ }
+}
+
+/**
+ * POST /api/contact/admin/:id/assign
+ * Assign contact to admin user
+ */
+async function assign(req, res) {
+ try {
+ const { id } = req.params;
+ const { user_id } = req.body;
+
+ const contact = await Contact.assign(id, user_id);
+
+ res.json({
+ success: true,
+ message: 'Contact assigned successfully',
+ contact
+ });
+
+ } catch (error) {
+ logger.error('[Contact] Assign error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to assign contact'
+ });
+ }
+}
+
+/**
+ * POST /api/contact/admin/:id/respond
+ * Mark contact as responded
+ */
+async function respond(req, res) {
+ try {
+ const { id } = req.params;
+ const { content } = req.body;
+
+ const contact = await Contact.markResponded(id, {
+ content,
+ responder: req.user.id
+ });
+
+ res.json({
+ success: true,
+ message: 'Response recorded successfully',
+ contact
+ });
+
+ } catch (error) {
+ logger.error('[Contact] Respond error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to record response'
+ });
+ }
+}
+
+/**
+ * PUT /api/contact/admin/:id
+ * Update contact
+ */
+async function update(req, res) {
+ try {
+ const { id } = req.params;
+ const { status, priority, assigned_to } = req.body;
+
+ const updateData = {};
+ if (status) updateData.status = status;
+ if (priority) updateData.priority = priority;
+ if (assigned_to !== undefined) updateData.assigned_to = assigned_to;
+
+ const contact = await Contact.update(id, updateData);
+
+ res.json({
+ success: true,
+ message: 'Contact updated successfully',
+ contact
+ });
+
+ } catch (error) {
+ logger.error('[Contact] Update error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to update contact'
+ });
+ }
+}
+
+/**
+ * DELETE /api/contact/admin/:id
+ * Delete contact
+ */
+async function deleteContact(req, res) {
+ try {
+ const { id } = req.params;
+
+ await Contact.delete(id);
+
+ res.json({
+ success: true,
+ message: 'Contact deleted successfully'
+ });
+
+ } catch (error) {
+ logger.error('[Contact] Delete error:', error);
+ res.status(500).json({
+ success: false,
+ error: 'Failed to delete contact'
+ });
+ }
+}
+
+module.exports = {
+ submit,
+ list,
+ getStats,
+ getById,
+ assign,
+ respond,
+ update,
+ deleteContact
+};
diff --git a/src/models/Contact.model.js b/src/models/Contact.model.js
new file mode 100644
index 00000000..9d00d7b0
--- /dev/null
+++ b/src/models/Contact.model.js
@@ -0,0 +1,205 @@
+/**
+ * Contact Model
+ * General contact form submissions (non-media, non-case)
+ * Foundation for unified CRM system
+ */
+
+const { ObjectId } = require('mongodb');
+const { getCollection } = require('../utils/db.util');
+
+class Contact {
+ /**
+ * Create a new contact submission
+ */
+ static async create(data) {
+ const collection = await getCollection('contacts');
+
+ const contact = {
+ type: data.type || 'general', // general, partnership, technical, feedback
+ contact: {
+ name: data.contact.name,
+ email: data.contact.email,
+ organization: data.contact.organization || null,
+ phone: data.contact.phone || null
+ },
+ inquiry: {
+ subject: data.inquiry.subject || null,
+ message: data.inquiry.message
+ },
+ source: data.source || 'footer_contact', // Track where submission came from
+ status: data.status || 'new', // new, assigned, responded, closed
+ priority: data.priority || 'normal', // low, normal, high
+ assigned_to: data.assigned_to || null,
+ metadata: {
+ user_agent: data.metadata?.user_agent,
+ ip: data.metadata?.ip,
+ source_page: data.metadata?.source_page,
+ referrer: data.metadata?.referrer
+ },
+ response: {
+ sent_at: data.response?.sent_at || null,
+ content: data.response?.content || null,
+ responder: data.response?.responder || null
+ },
+ created_at: new Date(),
+ updated_at: new Date()
+ };
+
+ const result = await collection.insertOne(contact);
+ return { ...contact, _id: result.insertedId };
+ }
+
+ /**
+ * Find contact by ID
+ */
+ static async findById(id) {
+ const collection = await getCollection('contacts');
+ return await collection.findOne({ _id: new ObjectId(id) });
+ }
+
+ /**
+ * Find contacts by status
+ */
+ static async findByStatus(status, options = {}) {
+ const collection = await getCollection('contacts');
+ const { limit = 20, skip = 0 } = options;
+
+ return await collection
+ .find({ status })
+ .sort({ created_at: -1 })
+ .skip(skip)
+ .limit(limit)
+ .toArray();
+ }
+
+ /**
+ * Find contacts by type
+ */
+ static async findByType(type, options = {}) {
+ const collection = await getCollection('contacts');
+ const { limit = 20, skip = 0 } = options;
+
+ return await collection
+ .find({ type })
+ .sort({ created_at: -1 })
+ .skip(skip)
+ .limit(limit)
+ .toArray();
+ }
+
+ /**
+ * List all contacts with filtering
+ */
+ static async list(filters = {}, options = {}) {
+ const collection = await getCollection('contacts');
+ const { limit = 20, skip = 0 } = options;
+
+ const query = {};
+ if (filters.status) query.status = filters.status;
+ if (filters.type) query.type = filters.type;
+ if (filters.priority) query.priority = filters.priority;
+ if (filters.assigned_to) query.assigned_to = new ObjectId(filters.assigned_to);
+
+ return await collection
+ .find(query)
+ .sort({ created_at: -1 })
+ .skip(skip)
+ .limit(limit)
+ .toArray();
+ }
+
+ /**
+ * Count contacts by status
+ */
+ static async countByStatus(status) {
+ const collection = await getCollection('contacts');
+ return await collection.countDocuments({ status });
+ }
+
+ /**
+ * Update contact
+ */
+ static async update(id, data) {
+ const collection = await getCollection('contacts');
+
+ const updateData = {
+ ...data,
+ updated_at: new Date()
+ };
+
+ await collection.updateOne(
+ { _id: new ObjectId(id) },
+ { $set: updateData }
+ );
+
+ return await this.findById(id);
+ }
+
+ /**
+ * Assign contact to user
+ */
+ static async assign(id, userId) {
+ return await this.update(id, {
+ assigned_to: userId ? new ObjectId(userId) : null,
+ status: 'assigned'
+ });
+ }
+
+ /**
+ * Mark contact as responded
+ */
+ static async markResponded(id, responseData) {
+ return await this.update(id, {
+ status: 'responded',
+ response: {
+ sent_at: new Date(),
+ content: responseData.content,
+ responder: responseData.responder
+ }
+ });
+ }
+
+ /**
+ * Delete contact
+ */
+ static async delete(id) {
+ const collection = await getCollection('contacts');
+ return await collection.deleteOne({ _id: new ObjectId(id) });
+ }
+
+ /**
+ * Get statistics
+ */
+ static async getStats() {
+ const collection = await getCollection('contacts');
+
+ const [total, newCount, assignedCount, respondedCount, closedCount] = await Promise.all([
+ collection.countDocuments(),
+ collection.countDocuments({ status: 'new' }),
+ collection.countDocuments({ status: 'assigned' }),
+ collection.countDocuments({ status: 'responded' }),
+ collection.countDocuments({ status: 'closed' })
+ ]);
+
+ // Type breakdown
+ const typeBreakdown = await collection.aggregate([
+ { $group: { _id: '$type', count: { $sum: 1 } } }
+ ]).toArray();
+
+ return {
+ total,
+ by_status: {
+ new: newCount,
+ assigned: assignedCount,
+ responded: respondedCount,
+ closed: closedCount
+ },
+ by_type: typeBreakdown.reduce((acc, item) => {
+ acc[item._id] = item.count;
+ return acc;
+ }, {})
+ };
+ }
+}
+
+module.exports = Contact;
diff --git a/src/routes/contact.routes.js b/src/routes/contact.routes.js
new file mode 100644
index 00000000..7462a727
--- /dev/null
+++ b/src/routes/contact.routes.js
@@ -0,0 +1,95 @@
+/**
+ * Contact Routes
+ * Public contact form and admin management
+ */
+
+const express = require('express');
+const router = express.Router();
+
+const contactController = require('../controllers/contact.controller');
+const { authenticateToken, requireRole } = require('../middleware/auth.middleware');
+const { validateRequired } = require('../middleware/validation.middleware');
+const { asyncHandler } = require('../middleware/error.middleware');
+const { createInputValidationMiddleware } = require('../middleware/input-validation.middleware');
+const { formRateLimiter } = require('../middleware/rate-limit.middleware');
+const { csrfProtection } = require('../middleware/csrf-protection.middleware');
+
+/**
+ * Public Routes
+ */
+
+// Validation schema for contact submission
+const contactSubmitSchema = {
+ 'type': { required: false, type: 'string', maxLength: 50 },
+ 'name': { required: true, type: 'name', maxLength: 100 },
+ 'email': { required: true, type: 'email', maxLength: 254 },
+ 'organization': { required: false, type: 'string', maxLength: 200 },
+ 'phone': { required: false, type: 'phone', maxLength: 50 },
+ 'subject': { required: false, type: 'string', maxLength: 200 },
+ 'message': { required: true, type: 'string', maxLength: 5000 }
+};
+
+// POST /api/contact/submit - Submit contact form
+router.post('/submit',
+ formRateLimiter, // 5 requests per minute
+ csrfProtection, // CSRF validation
+ createInputValidationMiddleware(contactSubmitSchema),
+ validateRequired(['name', 'email', 'message']),
+ asyncHandler(contactController.submit)
+);
+
+/**
+ * Admin Routes (require authentication)
+ */
+
+// GET /api/contact/admin/stats - Get contact statistics
+router.get('/admin/stats',
+ authenticateToken,
+ requireRole('admin', 'moderator'),
+ asyncHandler(contactController.getStats)
+);
+
+// GET /api/contact/admin/list - List contacts with filtering
+router.get('/admin/list',
+ authenticateToken,
+ requireRole('admin', 'moderator'),
+ asyncHandler(contactController.list)
+);
+
+// GET /api/contact/admin/:id - Get single contact
+router.get('/admin/:id',
+ authenticateToken,
+ requireRole('admin', 'moderator'),
+ asyncHandler(contactController.getById)
+);
+
+// POST /api/contact/admin/:id/assign - Assign contact to user
+router.post('/admin/:id/assign',
+ authenticateToken,
+ requireRole('admin', 'moderator'),
+ asyncHandler(contactController.assign)
+);
+
+// POST /api/contact/admin/:id/respond - Mark as responded
+router.post('/admin/:id/respond',
+ authenticateToken,
+ requireRole('admin', 'moderator'),
+ validateRequired(['content']),
+ asyncHandler(contactController.respond)
+);
+
+// PUT /api/contact/admin/:id - Update contact
+router.put('/admin/:id',
+ authenticateToken,
+ requireRole('admin', 'moderator'),
+ asyncHandler(contactController.update)
+);
+
+// DELETE /api/contact/admin/:id - Delete contact
+router.delete('/admin/:id',
+ authenticateToken,
+ requireRole('admin'),
+ asyncHandler(contactController.deleteContact)
+);
+
+module.exports = router;
diff --git a/src/routes/index.js b/src/routes/index.js
index bf1caf2d..b64fa463 100644
--- a/src/routes/index.js
+++ b/src/routes/index.js
@@ -27,6 +27,7 @@ const analyticsRoutes = require('./analytics.routes');
const publicationsRoutes = require('./publications.routes');
const submissionsRoutes = require('./submissions.routes');
const relationshipsRoutes = require('./relationships.routes');
+const contactRoutes = require('./contact.routes');
// Development/test routes (only in development)
if (process.env.NODE_ENV !== 'production') {
@@ -55,6 +56,7 @@ router.use('/analytics', analyticsRoutes);
router.use('/publications', publicationsRoutes);
router.use('/submissions', submissionsRoutes);
router.use('/relationships', relationshipsRoutes);
+router.use('/contact', contactRoutes);
// API root endpoint - redirect browsers to documentation
router.get('/', (req, res) => {