Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ app.use('/api/profile', mutationLimiter, require('./routes/profile'));
app.use('/api/notifications', require('./routes/notifications'));
app.use('/api/discussion', mutationLimiter, require('./routes/discussion'));
app.use('/api/dashboard', require('./routes/dashboard'));
app.use('/api/videos', mutationLimiter, require('./routes/videos'));
app.use('/api/feeds', require('./routes/feeds'));

app.get('/health', (req, res) => {
const { clientCount } = require('./lib/sseEmitter');
Expand Down
34 changes: 34 additions & 0 deletions backend/config/constants.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// config/constants.js

// separation of citation CATEGORY (what the claim is) vs TOPIC (what the claim is about)

const CATEGORIES = [
'Statistics & Data',
'Quote / Misattribution',
'Historical Claim',
'Scientific Claim',
'Context / Methodology',
'Other',
];

const DEFAULT_CATEGORY = 'Uncategorized';
const ALL_CATEGORIES = [...CATEGORIES, DEFAULT_CATEGORY];


const TOPICS = [
'Science & Technology',
'History',
'Politics & News',
'Education',
'Health & Fitness',
'Economics',
'Philosophy',
'Entertainment'
];

module.exports = {
CATEGORIES,
DEFAULT_CATEGORY,
ALL_CATEGORIES,
TOPICS
};
50 changes: 36 additions & 14 deletions backend/config/experts.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,38 @@
// Hardcoded expert allowlist. Add usernames via the EXPERT_USERNAMES env var
// (comma-separated) for local/deployment configuration without code changes.
const DEFAULT_EXPERTS = [];

const EXPERT_USERNAMES = new Set([
...DEFAULT_EXPERTS,
...(process.env.EXPERT_USERNAMES
? process.env.EXPERT_USERNAMES.split(',').map(s => s.trim()).filter(Boolean)
: []),
]);

function isExpert(username) {
return typeof username === 'string' && EXPERT_USERNAMES.has(username);
// config/experts.js

// Hardcoded expert domain mapping.
// Instead of a simple array, we map usernames to their specialized Topics.
const DEFAULT_EXPERTS = {
};


const expertRegistry = { ...DEFAULT_EXPERTS };

if (process.env.EXPERT_CONFIG) {
const entries = process.env.EXPERT_CONFIG.split('|');
entries.forEach(entry => {
const [username, topicsStr] = entry.split(':');
if (username && topicsStr) {
expertRegistry[username.trim()] = topicsStr.split(',').map(t => t.trim());
}
});
}


function isExpert(username, topic = null) {
if (typeof username !== 'string' || !expertRegistry[username]) {
return false;
}

if (topic) {
return expertRegistry[username].includes(topic);
}

return true;
}

function getExpertTopics(username) {
return expertRegistry[username] || [];
}

module.exports = { EXPERT_USERNAMES, isExpert };
module.exports = { isExpert, getExpertTopics, expertRegistry };
11 changes: 9 additions & 2 deletions backend/lib/notifyExperts.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@ const Expert = require('../models/Expert');
const UserProfile = require('../models/UserProfile');
const Notification = require('../models/Notification');

// NOTE: Expert.topics is a subject-matter taxonomy (e.g. "History"), while `category`
// here is a claim-type taxonomy (e.g. "Quote / Misattribution") — they're different
// dimensions (see backend/config/constants.js). Citations/requests aren't currently
// run through the topic-classification pipeline (only backend/routes/videos.js is),
// so this match against Expert.topics will find no experts until that's wired up.
// Kept as `category` (not silently dropped) so this starts working the moment
// citation/request topic classification lands, without another schema change here.
async function notifyExpertsForCategory(category, { videoId, itemId, itemType, title, excludeUsername }) {
try {
const experts = await Expert.find({ categories: category }).lean();
const experts = await Expert.find({ topics: category }).lean();

const notifications = [];
for (const expert of experts) {
if (expert.username === excludeUsername) continue;

// Check if expert has muted this category
const profile = await UserProfile.findOne({ username: expert.username }).lean();
const muted = profile?.mutedCategories || [];
const muted = profile?.mutedTopics || [];
if (muted.includes(category)) continue;

notifications.push({
Expand Down
4 changes: 4 additions & 0 deletions backend/models/Citation.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const citationSchema = new mongoose.Schema({
parentCitationId: { type: String, default: null },
category: { type: String, enum: ALL_CATEGORIES, default: DEFAULT_CATEGORY },
categoryVerified: { type: Boolean, default: false },
topics: { type: [String], default: [] },
verifiedBy: { type: String, default: null },
verifiedAt: { type: Date, default: null },
});
Expand All @@ -37,6 +38,9 @@ citationSchema.index({ videoId: 1, username: 1 });
// Category filter/aggregation index for the panel filter and dashboard.
citationSchema.index({ videoId: 1, category: 1 });

// Filter within given topic
citationSchema.index({ topics: 1, verifiedBy: 1 });

// Thread index: speeds up nested reply queries.
citationSchema.index({ parentCitationId: 1, dateAdded: 1 });

Expand Down
6 changes: 3 additions & 3 deletions backend/models/Expert.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
const mongoose = require('mongoose');
const { ALL_CATEGORIES } = require('../config/categories');
const { TOPICS } = require('../config/constants');

const expertSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
categories: [{ type: String, enum: ALL_CATEGORIES }],
topics: [{ type: String, enum: TOPICS }], // Uses TOPICS from constants
grantedAt: { type: Date, default: Date.now },
grantedBy: { type: String, default: null },
});

// username's index is already created by `unique: true` above.
expertSchema.index({ categories: 1 });
expertSchema.index({ topics: 1 });

module.exports = mongoose.model('Expert', expertSchema);
4 changes: 2 additions & 2 deletions backend/models/ExpertApplication.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
const mongoose = require('mongoose');
const { ALL_CATEGORIES } = require('../config/categories');
const { TOPICS } = require('../config/constants');

const expertApplicationSchema = new mongoose.Schema({
username: { type: String, required: true },
category: { type: String, required: true, enum: ALL_CATEGORIES },
topics: [{ type: String, enum: TOPICS, required: true }],
credentials: { type: String, required: true },
status: { type: String, enum: ['pending', 'approved', 'rejected'], default: 'pending' },
reason: { type: String, default: null },
Expand Down
1 change: 1 addition & 0 deletions backend/models/Notification.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const notificationSchema = new mongoose.Schema({
username: { type: String, required: true },
type: { type: String, enum: ['new_citation', 'new_request', 'application_approved', 'application_rejected'], required: true },
category: { type: String, default: null },
topic: { type: String, default: null },
videoId: { type: String, default: null },
itemId: { type: String, default: null },
itemType: { type: String, enum: ['citation', 'request', null], default: null },
Expand Down
8 changes: 7 additions & 1 deletion backend/models/Request.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,15 @@ const requestSchema = new mongoose.Schema({
username: { type: String, required: true },
dateAdded: { type: Date, default: Date.now },
voteScore: { type: Number, default: 0 },

category: { type: String, enum: ALL_CATEGORIES, default: DEFAULT_CATEGORY },
categoryVerified: { type: Boolean, default: false },

topics: { type: [String], default: [] },

verifiedBy: { type: String, default: null },
verifiedAt: { type: Date, default: null },
});

// ── Indexes ───────────────────────────────────

// Primary sort index: list queries filter by videoId then sort by date.
Expand All @@ -30,4 +33,7 @@ requestSchema.index({ videoId: 1, username: 1 });
// Category filter/aggregation index for the panel filter and dashboard.
requestSchema.index({ videoId: 1, category: 1 });

// Filter within given topic
requestSchema.index({ topics: 1, verifiedBy: 1 });

module.exports = mongoose.model('Request', requestSchema);
4 changes: 2 additions & 2 deletions backend/models/UserProfile.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ const userProfileSchema = new mongoose.Schema({
username: { type: String, required: true, unique: true },
displayName: { type: String, default: '' },
bio: { type: String, default: '', maxlength: 500 },
followedCategories: [{ type: String }],
mutedCategories: [{ type: String }],
followedTopics: [{ type: String }],
mutedTopics: [{ type: String }],
createdAt: { type: Date, default: Date.now },
});

Expand Down
32 changes: 32 additions & 0 deletions backend/models/Video.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
const mongoose = require('mongoose');

const videoSchema = new mongoose.Schema({
videoId: {
type: String,
required: true,
unique: true
},
title: {
type: String,
required: true
},
thumbnailUrl: {
type: String,
default: ''
},
channelName: {
type: String,
default: ''
},
youtubeTopics: [{
type: String
}],
lastUpdated: {
type: Date,
default: Date.now
}
});

// videoId's index is already created by `unique: true` above.

module.exports = mongoose.model('Video', videoSchema);
4 changes: 2 additions & 2 deletions backend/routes/citations.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
const router = require('express').Router();
const Citation = require('../models/Citation');
const sseEmitter = require('../lib/sseEmitter');
const { CATEGORIES, ALL_CATEGORIES, DEFAULT_CATEGORY } = require('../config/categories');
const { ALL_CATEGORIES, DEFAULT_CATEGORY, TOPICS } = require('../config/constants');
const { isExpert } = require('../config/experts');
const { notifyExpertsForCategory } = require('../lib/notifyExperts');
const { applyVote } = require('../lib/voting');
Expand Down Expand Up @@ -231,7 +231,7 @@ router.patch('/:videoId/:id/category', async (req, res) => {
if (!category || !username) {
return res.status(400).json({ success: false, error: 'category and username are required' });
}
if (!CATEGORIES.includes(category)) {
if (!ALL_CATEGORIES.includes(category)) {
return res.status(400).json({ success: false, error: 'Invalid category' });
}

Expand Down
24 changes: 12 additions & 12 deletions backend/routes/experts.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@ const Expert = require('../models/Expert');
const ExpertApplication = require('../models/ExpertApplication');
const { isExpert: isHardcodedExpert } = require('../config/experts');
const { isAdmin } = require('../config/admins');
const { ALL_CATEGORIES } = require('../config/categories');
const { TOPICS } = require('../config/constants');

// GET /api/experts/:username — check if user is an expert
router.get('/:username', async (req, res) => {
try {
if (isHardcodedExpert(req.params.username)) {
return res.json({ success: true, isExpert: true, categories: ALL_CATEGORIES });
return res.json({ success: true, isExpert: true, topics: TOPICS });
}
const expert = await Expert.findOne({ username: req.params.username }).lean();
res.set('Cache-Control', 'no-store');
res.json({
success: true,
isExpert: !!expert,
categories: expert ? expert.categories : [],
topics: expert ? expert.topics : [],
});
} catch (err) {
res.status(500).json({ success: false, error: err.message });
Expand All @@ -26,22 +26,22 @@ router.get('/:username', async (req, res) => {
// POST /api/experts/apply — submit expert application
router.post('/apply', async (req, res) => {
try {
const { username, category, credentials } = req.body;
if (!username || !category || !credentials) {
return res.status(400).json({ success: false, error: 'username, category, and credentials are required' });
const { username, topics, credentials } = req.body;
if (!username || !topics || !Array.isArray(topics) || topics.length === 0 || !credentials) {
return res.status(400).json({ success: false, error: 'username, topics (non-empty array), and credentials are required' });
}
if (!ALL_CATEGORIES.includes(category)) {
return res.status(400).json({ success: false, error: 'Invalid category' });
if (!topics.every(t => TOPICS.includes(t))) {
return res.status(400).json({ success: false, error: 'Invalid topic' });
}

const existing = await ExpertApplication.findOne({
username, category, status: 'pending',
username, status: 'pending',
});
if (existing) {
return res.status(409).json({ success: false, error: 'You already have a pending application for this category' });
return res.status(409).json({ success: false, error: 'You already have a pending application' });
}

const app = await ExpertApplication.create({ username, category, credentials });
const app = await ExpertApplication.create({ username, topics, credentials });
res.status(201).json({ success: true, id: app._id });
} catch (err) {
res.status(500).json({ success: false, error: err.message });
Expand Down Expand Up @@ -103,7 +103,7 @@ router.patch('/applications/:id/review', async (req, res) => {
if (status === 'approved') {
await Expert.findOneAndUpdate(
{ username: app.username },
{ $addToSet: { categories: app.category }, $setOnInsert: { grantedAt: new Date(), grantedBy: reviewedBy } },
{ $addToSet: { topics: { $each: app.topics } }, $setOnInsert: { grantedAt: new Date(), grantedBy: reviewedBy } },
{ upsert: true }
);
}
Expand Down
Loading
Loading