HIGH PRIORITY: Fixes production 404 error on research inquiry form Research Inquiry API: - Add POST /api/research-inquiry endpoint for form submissions - Add admin endpoints for inquiry management (list, get, assign, respond, delete) - Create ResearchInquiry model with MongoDB integration - Add to moderation queue for human review (strategic quadrant) - Include rate limiting (5 req/min) and CSRF protection - Tested locally: endpoint responding, data saving to DB Umami Analytics (Privacy-First): - Add Docker Compose config for Umami + PostgreSQL - Create nginx reverse proxy config with SSL support - Implement privacy-first tracking script (DNT, opt-out, no cookies) - Integrate tracking across 26 public HTML pages - Exclude admin pages from tracking (privacy boundary) - Add comprehensive deployment guide (UMAMI_SETUP_GUIDE.md) - Environment variables added to .env.example Files Created (9): - src/models/ResearchInquiry.model.js - src/controllers/research.controller.js - src/routes/research.routes.js - public/js/components/umami-tracker.js - deployment-quickstart/nginx-analytics.conf - deployment-quickstart/UMAMI_SETUP_GUIDE.md - scripts/add-umami-tracking.sh - scripts/add-tracking-python.py - SESSION_SUMMARY_ANALYTICS_RESEARCH_INQUIRY.md Files Modified (29): - src/routes/index.js (research routes) - deployment-quickstart/docker-compose.yml (umami services) - deployment-quickstart/.env.example (umami config) - 26 public HTML pages (tracking script) Values Alignment: ✅ Privacy-First Design (cookie-free, DNT honored, opt-out available) ✅ Human Agency (research inquiries require human review) ✅ Data Sovereignty (self-hosted analytics, no third-party sharing) ✅ GDPR Compliance (no personal data in analytics) ✅ Transparency (open-source tools, documented setup) Testing Status: ✅ Research inquiry: Locally tested, data verified in MongoDB ⏳ Umami analytics: Pending production deployment Next Steps: 1. Deploy to production (./scripts/deploy.sh) 2. Test research form on live site 3. Deploy Umami following UMAMI_SETUP_GUIDE.md 4. Update umami-tracker.js with website ID after setup 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
162 lines
3.9 KiB
JavaScript
162 lines
3.9 KiB
JavaScript
/**
|
|
* ResearchInquiry Model
|
|
* Academic research collaboration inquiries
|
|
*/
|
|
|
|
const { ObjectId } = require('mongodb');
|
|
const { getCollection } = require('../utils/db.util');
|
|
|
|
class ResearchInquiry {
|
|
/**
|
|
* Create a new research inquiry
|
|
*/
|
|
static async create(data) {
|
|
const collection = await getCollection('research_inquiries');
|
|
|
|
const inquiry = {
|
|
contact: {
|
|
name: data.contact.name,
|
|
email: data.contact.email,
|
|
institution: data.contact.institution
|
|
},
|
|
research: {
|
|
research_question: data.research.research_question,
|
|
methodology: data.research.methodology,
|
|
context: data.research.context,
|
|
needs: data.research.needs || [], // Array of collaboration needs
|
|
other_needs: data.research.other_needs,
|
|
timeline: data.research.timeline
|
|
},
|
|
status: data.status || 'new', // new/reviewed/responded/closed
|
|
assigned_to: data.assigned_to,
|
|
response: {
|
|
sent_at: data.response?.sent_at,
|
|
content: data.response?.content,
|
|
responder: data.response?.responder
|
|
},
|
|
created_at: new Date()
|
|
};
|
|
|
|
const result = await collection.insertOne(inquiry);
|
|
return { ...inquiry, _id: result.insertedId };
|
|
}
|
|
|
|
/**
|
|
* Find inquiry by ID
|
|
*/
|
|
static async findById(id) {
|
|
const collection = await getCollection('research_inquiries');
|
|
return await collection.findOne({ _id: new ObjectId(id) });
|
|
}
|
|
|
|
/**
|
|
* Find inquiries by status
|
|
*/
|
|
static async findByStatus(status, options = {}) {
|
|
const collection = await getCollection('research_inquiries');
|
|
const { limit = 20, skip = 0 } = options;
|
|
|
|
return await collection
|
|
.find({ status })
|
|
.sort({ created_at: -1 })
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Find all inquiries with pagination
|
|
*/
|
|
static async findAll(options = {}) {
|
|
const collection = await getCollection('research_inquiries');
|
|
const { limit = 20, skip = 0 } = options;
|
|
|
|
return await collection
|
|
.find({})
|
|
.sort({ created_at: -1 })
|
|
.skip(skip)
|
|
.limit(limit)
|
|
.toArray();
|
|
}
|
|
|
|
/**
|
|
* Update inquiry
|
|
*/
|
|
static async update(id, updates) {
|
|
const collection = await getCollection('research_inquiries');
|
|
|
|
const result = await collection.updateOne(
|
|
{ _id: new ObjectId(id) },
|
|
{ $set: updates }
|
|
);
|
|
|
|
return result.modifiedCount > 0;
|
|
}
|
|
|
|
/**
|
|
* Assign inquiry to user
|
|
*/
|
|
static async assign(id, userId) {
|
|
const collection = await getCollection('research_inquiries');
|
|
|
|
const result = await collection.updateOne(
|
|
{ _id: new ObjectId(id) },
|
|
{
|
|
$set: {
|
|
assigned_to: new ObjectId(userId),
|
|
status: 'reviewed'
|
|
}
|
|
}
|
|
);
|
|
|
|
return result.modifiedCount > 0;
|
|
}
|
|
|
|
/**
|
|
* Mark as responded
|
|
*/
|
|
static async respond(id, responseData) {
|
|
const collection = await getCollection('research_inquiries');
|
|
|
|
const result = await collection.updateOne(
|
|
{ _id: new ObjectId(id) },
|
|
{
|
|
$set: {
|
|
status: 'responded',
|
|
'response.sent_at': new Date(),
|
|
'response.content': responseData.content,
|
|
'response.responder': responseData.responder
|
|
}
|
|
}
|
|
);
|
|
|
|
return result.modifiedCount > 0;
|
|
}
|
|
|
|
/**
|
|
* Count by status
|
|
*/
|
|
static async countByStatus(status) {
|
|
const collection = await getCollection('research_inquiries');
|
|
return await collection.countDocuments({ status });
|
|
}
|
|
|
|
/**
|
|
* Count all inquiries
|
|
*/
|
|
static async countAll() {
|
|
const collection = await getCollection('research_inquiries');
|
|
return await collection.countDocuments({});
|
|
}
|
|
|
|
/**
|
|
* Delete inquiry
|
|
*/
|
|
static async delete(id) {
|
|
const collection = await getCollection('research_inquiries');
|
|
const result = await collection.deleteOne({ _id: new ObjectId(id) });
|
|
return result.deletedCount > 0;
|
|
}
|
|
}
|
|
|
|
module.exports = ResearchInquiry;
|