From 65a4178e2b1491117ddb9e1b8a5ed7b53c4b5be3 Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Thu, 28 May 2026 20:45:41 -0400 Subject: [PATCH] Add Bible verse highlights and notes API --- README.md | 10 +- dbTools/bible.js | 287 +++++++++++++++++++++++++++++++++++++++++ dbTools/profile.js | 4 + def/profile.js | 4 +- mongoDB.js | 2 + routes/bible.js | 313 ++++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 617 insertions(+), 3 deletions(-) create mode 100644 dbTools/bible.js diff --git a/README.md b/README.md index 7779397..43a69e4 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,14 @@ The API is divided into several sections based on functionality. Most routes und - `GET /chapters/:chapterId`: Get the content of a chapter. - `GET /chapters/:chapterId/verses`: Get the verses of a chapter. - `GET /search`: Search the Bible. +- `GET /mine`: Get Bible highlights and notes for the active profile. +- `GET /verses/:verseKey/counters`: Get highlight and note counters for a verse key like `GEN.1.1`. +- `GET /verses/:verseKey/activity`: Get profiles that highlighted/noted a verse and recent notes, limited to 10 by default. +- `POST /verses/:verseKey/highlight`: Highlight a verse for the active profile. +- `DELETE /verses/:verseKey/highlight`: Remove a verse highlight for the active profile. +- `POST /verses/:verseKey/notes`: Add a note for a verse. +- `PUT /verses/:verseKey/notes/:noteId`: Update one of the active profile's notes. +- `DELETE /verses/:verseKey/notes/:noteId`: Delete one of the active profile's notes. ### Subsplash (`/subsplash`) @@ -139,4 +147,4 @@ The API is divided into several sections based on functionality. Most routes und ### TODO - [ ] Define nodes schema -- [ ] Implement basic login/registration \ No newline at end of file +- [ ] Implement basic login/registration diff --git a/dbTools/bible.js b/dbTools/bible.js new file mode 100644 index 0000000..89c0b58 --- /dev/null +++ b/dbTools/bible.js @@ -0,0 +1,287 @@ +const DBName = "EMI_SOCIAL"; + +const toProfileIds = (DB, profileIds = []) => { + return profileIds + .filter((profileId) => DB.ObjectID.isValid(profileId)) + .map((profileId) => DB.ObjectID(profileId)); +}; + +const publicProfile = (profile) => { + if (!profile) return null; + return { + _id: profile._id, + profile: profile.profile, + username: profile.username, + isGroup: profile.isGroup, + isCourse: profile.isCourse, + }; +}; + +const bibleDB = (DB) => { + DB.bibleVerseCols = DB.db.db(DBName).collection("bible_verses"); + DB.bibleVerseCols.createIndex({ highlightedBy: 1 }).catch(console.error); + DB.bibleVerseCols.createIndex({ notedBy: 1 }).catch(console.error); + + const getVerseDoc = async (verseKey) => { + return DB.bibleVerseCols.findOne({ _id: verseKey }).catch((err) => { + console.log(err); + return false; + }); + }; + + DB.getBibleProfileData = async (profileid) => { + if (!DB.ObjectID.isValid(profileid)) return false; + const profile = await DB.profileCols.findOne( + { _id: DB.ObjectID(profileid) }, + { projection: { bibleHighlights: 1, bibleNotes: 1 } } + ).catch((err) => { + console.log(err); + return false; + }); + + return { + highlights: profile?.bibleHighlights || [], + notes: profile?.bibleNotes || [], + }; + }; + + DB.getBibleVerseCounters = async (verseKey) => { + const verse = await getVerseDoc(verseKey); + const highlightedBy = Array.isArray(verse?.highlightedBy) ? verse.highlightedBy : []; + const notedBy = Array.isArray(verse?.notedBy) ? verse.notedBy : []; + return { + verseKey, + highlightCount: highlightedBy.length, + noteProfileCount: notedBy.length, + notesCount: verse?.notesCount || 0, + }; + }; + + DB.getBibleVerseActivity = async (verseKey, limit = 10) => { + const verse = await getVerseDoc(verseKey); + const highlightedBy = Array.isArray(verse?.highlightedBy) ? verse.highlightedBy : []; + const notedBy = Array.isArray(verse?.notedBy) ? verse.notedBy : []; + const highlightProfiles = toProfileIds(DB, highlightedBy); + const noteProfiles = toProfileIds(DB, notedBy); + + const [highlights, noteUsers, notes] = await Promise.all([ + highlightProfiles.length + ? DB.profileCols.find({ _id: { $in: highlightProfiles } }).project({ + profile: 1, + username: 1, + isGroup: 1, + isCourse: 1, + }).toArray() + : [], + noteProfiles.length + ? DB.profileCols.find({ _id: { $in: noteProfiles } }).project({ + profile: 1, + username: 1, + isGroup: 1, + isCourse: 1, + }).toArray() + : [], + DB.profileCols.aggregate([ + { $match: { "bibleNotes.verseKey": verseKey } }, + { $unwind: "$bibleNotes" }, + { $match: { "bibleNotes.verseKey": verseKey } }, + { $sort: { "bibleNotes.updatedAt": -1, "bibleNotes.createdAt": -1 } }, + { $limit: limit }, + { + $project: { + _id: 0, + note: "$bibleNotes", + profile: { + _id: "$_id", + profile: "$profile", + username: "$username", + isGroup: "$isGroup", + isCourse: "$isCourse", + } + } + } + ]).toArray(), + ]); + + return { + counters: await DB.getBibleVerseCounters(verseKey), + highlightedBy: highlights.map(publicProfile), + notedBy: noteUsers.map(publicProfile), + notes, + }; + }; + + DB.addBibleHighlight = async (profileid, verseKey) => { + if (!DB.ObjectID.isValid(profileid)) return false; + const now = new Date(); + const _id = DB.ObjectID(profileid); + const profileUpdate = await DB.profileCols.updateOne( + { _id, "bibleHighlights.verseKey": { $ne: verseKey } }, + { + $push: { + bibleHighlights: { + verseKey, + createdAt: now, + } + }, + $set: { lastUpdate: now } + } + ).catch((err) => { + console.log(err); + return false; + }); + + await DB.bibleVerseCols.updateOne( + { _id: verseKey }, + { + $setOnInsert: { createdAt: now }, + $set: { updatedAt: now }, + $addToSet: { highlightedBy: profileid + "" } + }, + { upsert: true } + ).catch((err) => { + console.log(err); + return false; + }); + + if (DB.clearProfileCache) DB.clearProfileCache(profileid); + return profileUpdate; + }; + + DB.removeBibleHighlight = async (profileid, verseKey) => { + if (!DB.ObjectID.isValid(profileid)) return false; + const now = new Date(); + const _id = DB.ObjectID(profileid); + const profileUpdate = await DB.profileCols.updateOne( + { _id }, + { + $pull: { bibleHighlights: { verseKey } }, + $set: { lastUpdate: now } + } + ).catch((err) => { + console.log(err); + return false; + }); + + await DB.bibleVerseCols.updateOne( + { _id: verseKey }, + { + $pull: { highlightedBy: profileid + "" }, + $set: { updatedAt: now } + } + ).catch((err) => { + console.log(err); + return false; + }); + + if (DB.clearProfileCache) DB.clearProfileCache(profileid); + return profileUpdate; + }; + + DB.addBibleNote = async (profileid, verseKey, text) => { + if (!DB.ObjectID.isValid(profileid)) return false; + const now = new Date(); + const note = { + _id: DB.ObjectID().toString(), + verseKey, + text, + createdAt: now, + updatedAt: now, + }; + const _id = DB.ObjectID(profileid); + const profileUpdate = await DB.profileCols.updateOne( + { _id }, + { + $push: { bibleNotes: note }, + $set: { lastUpdate: now } + } + ).catch((err) => { + console.log(err); + return false; + }); + + await DB.bibleVerseCols.updateOne( + { _id: verseKey }, + { + $setOnInsert: { createdAt: now }, + $set: { updatedAt: now }, + $addToSet: { notedBy: profileid + "" }, + $inc: { notesCount: 1 } + }, + { upsert: true } + ).catch((err) => { + console.log(err); + return false; + }); + + if (DB.clearProfileCache) DB.clearProfileCache(profileid); + return profileUpdate ? note : false; + }; + + DB.updateBibleNote = async (profileid, verseKey, noteId, text) => { + if (!DB.ObjectID.isValid(profileid)) return false; + const now = new Date(); + const _id = DB.ObjectID(profileid); + const result = await DB.profileCols.findOneAndUpdate( + { _id, bibleNotes: { $elemMatch: { _id: noteId, verseKey } } }, + { + $set: { + "bibleNotes.$.text": text, + "bibleNotes.$.updatedAt": now, + lastUpdate: now, + } + }, + { + returnOriginal: false, + projection: { bibleNotes: 1 } + } + ).catch((err) => { + console.log(err); + return false; + }); + + if (DB.clearProfileCache) DB.clearProfileCache(profileid); + return result?.value?.bibleNotes?.find((note) => note._id === noteId) || null; + }; + + DB.removeBibleNote = async (profileid, verseKey, noteId) => { + if (!DB.ObjectID.isValid(profileid)) return false; + const now = new Date(); + const _id = DB.ObjectID(profileid); + const result = await DB.profileCols.updateOne( + { _id, bibleNotes: { $elemMatch: { _id: noteId, verseKey } } }, + { + $pull: { bibleNotes: { _id: noteId, verseKey } }, + $set: { lastUpdate: now } + } + ).catch((err) => { + console.log(err); + return false; + }); + + if (result?.modifiedCount) { + const stillHasNotes = await DB.profileCols.findOne( + { _id, "bibleNotes.verseKey": verseKey }, + { projection: { _id: 1 } } + ); + + const verseUpdate = { + $inc: { notesCount: -1 }, + $set: { updatedAt: now } + }; + if (!stillHasNotes) { + verseUpdate.$pull = { notedBy: profileid + "" }; + } + + await DB.bibleVerseCols.updateOne({ _id: verseKey }, verseUpdate).catch((err) => { + console.log(err); + return false; + }); + } + + if (DB.clearProfileCache) DB.clearProfileCache(profileid); + return result; + }; +} + +module.exports = bibleDB; diff --git a/dbTools/profile.js b/dbTools/profile.js index be7f84e..6754cb1 100644 --- a/dbTools/profile.js +++ b/dbTools/profile.js @@ -5,6 +5,10 @@ let userProfileCache = {}; userDB = (DB) => { DB.profileCols = DB.db.db(DBName).collection("profiles"); + DB.clearProfileCache = (profileid) => { + if (userProfileCache[profileid]) delete userProfileCache[profileid]; + }; + DB.newProfile = (profileObj) => { return DB.profileCols.insertOne(profileObj.toObj()).catch((err) => { console.log(err); diff --git a/def/profile.js b/def/profile.js index 8698171..927c96e 100644 --- a/def/profile.js +++ b/def/profile.js @@ -17,6 +17,8 @@ class User { this.lastUpdate = info.lastUpdate || new Date(); this.newsFeedCache = info.newsFeedCache || []; this.notifications = info.notifications || []; + this.bibleHighlights = info.bibleHighlights || []; + this.bibleNotes = info.bibleNotes || []; //groupRelated this.isGroup = info.isGroup || false; @@ -33,4 +35,4 @@ class User { } -module.exports = User; \ No newline at end of file +module.exports = User; diff --git a/mongoDB.js b/mongoDB.js index 4ed313b..0c67193 100644 --- a/mongoDB.js +++ b/mongoDB.js @@ -10,6 +10,7 @@ const profileDB = require("./dbTools/profile.js"); const paymentDB = require("./dbTools/payments.js"); const songsDB = require("./dbTools/songs.js"); const chatDB = require("./dbTools/chat.js"); +const bibleDB = require("./dbTools/bible.js"); console.log("Connecting to MongoDB..."); const nodeMajorVersion = parseInt((process.versions.node || "0").split(".")[0], 10); @@ -179,6 +180,7 @@ const getDB = new Promise((resolve, reject) => { paymentDB(DB); songsDB(DB); chatDB(DB); + bibleDB(DB); resolve(DB); }); diff --git a/routes/bible.js b/routes/bible.js index 2e0aff9..3fc50e8 100644 --- a/routes/bible.js +++ b/routes/bible.js @@ -2,6 +2,7 @@ const axios = require('axios'); var express = require('express') var router = express.Router() const DB = require("./../mongoDB.js"); +const { getProfileId } = require("./../utils/sessionUtils.js"); const fetchAPI = async (path) => { baseUrl = "https://api.scripture.api.bible/v1/" @@ -11,6 +12,20 @@ const fetchAPI = async (path) => { const defaultBibleId = "592420522e16049f-01"; +const normalizeVerseKey = (verseKey) => { + if (!verseKey || typeof verseKey !== "string") return ""; + return verseKey.trim().toUpperCase(); +}; + +const isValidVerseKey = (verseKey) => { + return /^[A-Z0-9]+(\.[A-Z0-9]+)+$/.test(verseKey); +}; + +const getNoteText = (req) => { + const text = req.body?.text || req.body?.note || ""; + return typeof text === "string" ? text.trim() : ""; +}; + //getMedia('y42zyf3').then(console.log) DB.getDB.then((DB) => { /** @@ -37,6 +52,302 @@ DB.getDB.then((DB) => { return res.json(bibles); }); + /** + * @swagger + * /bible/mine: + * get: + * summary: Get Bible highlights and notes for the active profile + * tags: [Bible] + * security: + * - cookieAuth: [] + * responses: + * 200: + * description: OK + */ + router.get("/mine", async (req, res) => { + const profileid = getProfileId(req); + const bibleData = await DB.getBibleProfileData(profileid); + if (!bibleData) return res.status(400).json({ status: "Invalid profile" }); + return res.json({ + status: "ok", + ...bibleData + }); + }); + + /** + * @swagger + * /bible/verses/{verseKey}/counters: + * get: + * summary: Get highlight and note counters for a Bible verse + * tags: [Bible] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: verseKey + * required: true + * schema: + * type: string + * example: GEN.1.1 + * responses: + * 200: + * description: OK + */ + router.get("/verses/:verseKey/counters", async (req, res) => { + const verseKey = normalizeVerseKey(req.params.verseKey); + if (!isValidVerseKey(verseKey)) { + return res.status(400).json({ status: "Invalid verse key" }); + } + + const counters = await DB.getBibleVerseCounters(verseKey); + return res.json({ + status: "ok", + ...counters + }); + }); + + /** + * @swagger + * /bible/verses/{verseKey}/activity: + * get: + * summary: Get profiles and recent notes for a Bible verse + * tags: [Bible] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: verseKey + * required: true + * schema: + * type: string + * example: GEN.1.1 + * - in: query + * name: limit + * schema: + * type: integer + * default: 10 + * responses: + * 200: + * description: OK + */ + router.get("/verses/:verseKey/activity", async (req, res) => { + const verseKey = normalizeVerseKey(req.params.verseKey); + if (!isValidVerseKey(verseKey)) { + return res.status(400).json({ status: "Invalid verse key" }); + } + + const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 10, 1), 10); + const activity = await DB.getBibleVerseActivity(verseKey, limit); + return res.json({ + status: "ok", + verseKey, + ...activity + }); + }); + + /** + * @swagger + * /bible/verses/{verseKey}/highlight: + * post: + * summary: Highlight a Bible verse for the active profile + * tags: [Bible] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: verseKey + * required: true + * schema: + * type: string + * example: GEN.1.1 + * responses: + * 200: + * description: OK + */ + router.post("/verses/:verseKey/highlight", async (req, res) => { + const profileid = getProfileId(req); + const verseKey = normalizeVerseKey(req.params.verseKey); + if (!isValidVerseKey(verseKey)) { + return res.status(400).json({ status: "Invalid verse key" }); + } + + const result = await DB.addBibleHighlight(profileid, verseKey); + if (!result) return res.status(400).json({ status: "Could not add highlight" }); + + const counters = await DB.getBibleVerseCounters(verseKey); + return res.json({ + status: "ok", + highlighted: true, + ...counters + }); + }); + + /** + * @swagger + * /bible/verses/{verseKey}/highlight: + * delete: + * summary: Remove a Bible verse highlight for the active profile + * tags: [Bible] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: verseKey + * required: true + * schema: + * type: string + * example: GEN.1.1 + * responses: + * 200: + * description: OK + */ + router.delete("/verses/:verseKey/highlight", async (req, res) => { + const profileid = getProfileId(req); + const verseKey = normalizeVerseKey(req.params.verseKey); + if (!isValidVerseKey(verseKey)) { + return res.status(400).json({ status: "Invalid verse key" }); + } + + const result = await DB.removeBibleHighlight(profileid, verseKey); + if (!result) return res.status(400).json({ status: "Could not remove highlight" }); + + const counters = await DB.getBibleVerseCounters(verseKey); + return res.json({ + status: "ok", + highlighted: false, + ...counters + }); + }); + + /** + * @swagger + * /bible/verses/{verseKey}/notes: + * post: + * summary: Add a Bible verse note for the active profile + * tags: [Bible] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: verseKey + * required: true + * schema: + * type: string + * example: GEN.1.1 + * responses: + * 200: + * description: OK + */ + router.post("/verses/:verseKey/notes", async (req, res) => { + const profileid = getProfileId(req); + const verseKey = normalizeVerseKey(req.params.verseKey); + const text = getNoteText(req); + if (!isValidVerseKey(verseKey)) { + return res.status(400).json({ status: "Invalid verse key" }); + } + if (!text) return res.status(400).json({ status: "Note text is required" }); + + const note = await DB.addBibleNote(profileid, verseKey, text); + if (!note) return res.status(400).json({ status: "Could not add note" }); + + const counters = await DB.getBibleVerseCounters(verseKey); + return res.json({ + status: "ok", + verseKey, + note, + counters + }); + }); + + /** + * @swagger + * /bible/verses/{verseKey}/notes/{noteId}: + * put: + * summary: Update a Bible verse note for the active profile + * tags: [Bible] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: verseKey + * required: true + * schema: + * type: string + * example: GEN.1.1 + * - in: path + * name: noteId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: OK + */ + router.put("/verses/:verseKey/notes/:noteId", async (req, res) => { + const profileid = getProfileId(req); + const verseKey = normalizeVerseKey(req.params.verseKey); + const noteId = req.params.noteId; + const text = getNoteText(req); + if (!isValidVerseKey(verseKey)) { + return res.status(400).json({ status: "Invalid verse key" }); + } + if (!text) return res.status(400).json({ status: "Note text is required" }); + + const note = await DB.updateBibleNote(profileid, verseKey, noteId, text); + if (!note) return res.status(404).json({ status: "Note not found" }); + + return res.json({ + status: "ok", + verseKey, + note + }); + }); + + /** + * @swagger + * /bible/verses/{verseKey}/notes/{noteId}: + * delete: + * summary: Delete a Bible verse note for the active profile + * tags: [Bible] + * security: + * - cookieAuth: [] + * parameters: + * - in: path + * name: verseKey + * required: true + * schema: + * type: string + * example: GEN.1.1 + * - in: path + * name: noteId + * required: true + * schema: + * type: string + * responses: + * 200: + * description: OK + */ + router.delete("/verses/:verseKey/notes/:noteId", async (req, res) => { + const profileid = getProfileId(req); + const verseKey = normalizeVerseKey(req.params.verseKey); + const noteId = req.params.noteId; + if (!isValidVerseKey(verseKey)) { + return res.status(400).json({ status: "Invalid verse key" }); + } + + const result = await DB.removeBibleNote(profileid, verseKey, noteId); + if (!result) return res.status(400).json({ status: "Could not delete note" }); + if (!result.modifiedCount) return res.status(404).json({ status: "Note not found" }); + + const counters = await DB.getBibleVerseCounters(verseKey); + return res.json({ + status: "ok", + verseKey, + deleted: true, + counters + }); + }); + /** * @swagger * /bible/books: @@ -219,4 +530,4 @@ DB.getDB.then((DB) => { }); -module.exports = router \ No newline at end of file +module.exports = router