diff --git a/src/controllers/socialMediaControllers.js b/src/controllers/socialMediaControllers.js new file mode 100644 index 0000000000..c8e609db29 --- /dev/null +++ b/src/controllers/socialMediaControllers.js @@ -0,0 +1,257 @@ +const fs = require('fs'); +const axios = require('axios'); +const pinterestSchedule = require('../models/pinterestSchedule'); + +const OAUTH_URL = 'https://api.pinterest.com/v5/oauth'; +const ACCESS_TOKEN_FILE = 'access_token.txt'; +const PINTEREST_ENDPOINT = process.env.PINTEREST_SANDBOX_API + ? 'https://api-sandbox.pinterest.com/v5' + : 'https://api.pinterest.com/v5'; +const ONE_COMMUNITY_BOARD_NAME = 'OneCommunity'; + +// get access token from pinterest and store it +async function getPinterestAccessToken(authorizationCode) { + const auth = `${process.env.PINTEREST_APP_ID}:${process.env.PINTEREST_APP_SECRET}`; + const b64auth = Buffer.from(auth).toString('base64'); + + const authHeaders = { + Authorization: `Basic ${b64auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }; + + const postData = new URLSearchParams({ + grant_type: 'authorization_code', + code: authorizationCode, + redirect_uri: process.env.PINTEREST_REDIRECT_URI, + }); + + const response = await axios.post(`${OAUTH_URL}/token`, postData.toString(), { + headers: authHeaders, + }); + + const { access_token, refresh_token, expires_in } = response.data; + const expireTime = new Date().getTime() + expires_in * 1000; + + const jsonToken = { accessToken: access_token, refreshToken: refresh_token, expireTime }; + fs.writeFileSync(ACCESS_TOKEN_FILE, JSON.stringify(jsonToken)); + return jsonToken; +} +async function refreshPinterestAccessToken() { + const tokenData = JSON.parse(fs.readFileSync(ACCESS_TOKEN_FILE, 'utf8')); + const auth = `${process.env.PINTEREST_APP_ID}:${process.env.PINTEREST_APP_SECRET}`; + const b64auth = Buffer.from(auth).toString('base64'); + + const postData = new URLSearchParams({ + grant_type: 'refresh_token', + refresh_token: tokenData.refreshToken, + }); + + const response = await axios.post(`${OAUTH_URL}/token`, postData.toString(), { + headers: { + Authorization: `Basic ${b64auth}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + const { access_token, expires_in } = response.data; + const expireTime = new Date().getTime() + expires_in * 1000; + + const updated = { ...tokenData, accessToken: access_token, expireTime }; + fs.writeFileSync(ACCESS_TOKEN_FILE, JSON.stringify(updated)); + return updated; +} +function getValidAccessTokenOrExcepts() { + if (!fs.existsSync(ACCESS_TOKEN_FILE)) { + throw new Error('Access token file not found'); + } + const tokenData = fs.readFileSync(ACCESS_TOKEN_FILE, 'utf8'); + const tokenObject = JSON.parse(tokenData); + const expireTime = new Date(tokenObject.expireTime); + if (new Date() > expireTime) { + throw new Error('Access token expired'); + } + return tokenObject; +} + +// fetch access token from local file +async function fetchAccessToken() { + if (process.env.PINTEREST_SANDBOX_API) { + return process.env.PINTEREST_SANDBOX_API_TOKEN; + } + + let tokenObject; + try { + tokenObject = getValidAccessTokenOrExcepts(); + } catch { + // Token missing or expired — try refresh first + try { + tokenObject = await refreshPinterestAccessToken(); + } catch { + throw new Error('Pinterest token expired. Please re-authenticate via /api/pinterest/auth'); + } + } + + return tokenObject.accessToken; +} + +async function getPinterestRequestHeaders() { + const accessToken = await fetchAccessToken(); + return { Authorization: `Bearer ${accessToken}` }; +} + +async function fetchBoardList() { + const requestUrl = `${PINTEREST_ENDPOINT}/boards`; + const response = await axios.get(requestUrl, { + headers: await getPinterestRequestHeaders(), + responseType: 'json', + }); + const boardList = response.data.items; + // console.log(boardList); + // res.status(200).json(boardList); + return boardList; +} +function initiatePinterestAuth(_req, res) { + const scopes = ['pins:read', 'pins:write', 'boards:read', 'boards:write', 'users:read']; + const authUrl = new URL('https://www.pinterest.com/oauth/'); + authUrl.searchParams.set('client_id', process.env.PINTEREST_APP_ID); + authUrl.searchParams.set('redirect_uri', process.env.PINTEREST_REDIRECT_URI); + authUrl.searchParams.set('response_type', 'code'); + authUrl.searchParams.set('scope', scopes.join(',')); + res.redirect(authUrl.toString()); +} + +async function handlePinterestCallback(req, res) { + const { code } = req.query; + if (!code) return res.status(400).json({ error: 'No authorization code received.' }); + try { + await getPinterestAccessToken(code); + res.status(200).json({ message: 'Pinterest authenticated successfully.' }); + } catch (err) { + res.status(500).json({ error: err.message }); + } +} + +async function createBoard(title, details) { + const requestUrl = `${PINTEREST_ENDPOINT}/boards`; + const postData = { name: title, description: details, privacy: 'PUBLIC' }; + // const createPinHeaders = { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }; + const createPinHeaders = await getPinterestRequestHeaders(); + + const response = await axios.post(requestUrl, postData, { + headers: createPinHeaders, + responseType: 'json', + }); + + return response.data; +} + +async function getPostData(requestBody) { + const boardList = await fetchBoardList(); + + // If One Community board not exist, create one + let OneCommBoard = boardList.find((board) => board.name === ONE_COMMUNITY_BOARD_NAME); + if (!OneCommBoard) { + const boardDetails = 'Updates from One Community'; + OneCommBoard = await createBoard(ONE_COMMUNITY_BOARD_NAME, boardDetails); + } + + const boardId = OneCommBoard.id; + + // Process content + let sourceType; + const { imgType } = requestBody; + let mediaSourceItems; + const { mediaItems } = requestBody; + let mediaSource; + + if (imgType === 'FILE') { + // Process upload image file + const contentType = mediaItems.split(';')[0].split(':')[1].trim(); + const data = mediaItems.split(',')[1].trim(); + + mediaSourceItems = { content_type: contentType, data }; + sourceType = 'image_base64'; + mediaSource = { source_type: sourceType, ...mediaSourceItems }; + } else { + // Process url image source + mediaSourceItems = mediaItems; + sourceType = 'image_url'; + mediaSource = { source_type: sourceType, ...mediaSourceItems }; + } + const { description } = requestBody; + const { title } = requestBody; + + const postData = { board_id: boardId, description, title, media_source: mediaSource }; + + return postData; +} + +// Send post pin request to Pinterest API +async function postPinImmediately(postData) { + const requestUrl = `${PINTEREST_ENDPOINT}/pins`; + return await axios.post(requestUrl, postData, { + headers: await getPinterestRequestHeaders(), + responseType: 'json', + }); +} + +async function createPin(req, res) { + try { + const postData = await getPostData(req.body); + const response = await postPinImmediately(postData); + res.status(200).json(response.data); + } catch (error) { + if (error.response) { + console.error('Error creating Pinterest pin:', error.response.data); + res.status(error.response.status).json({ error: error.response.data.message }); + } else { + console.error('Error creating Pinterest pin:', error.message); + res.status(500).json({ error: error.message || 'Failed to create Pinterest pin' }); + } + } +} + +async function schedulePin(req, res) { + try { + const postDataObj = await getPostData(req.body); + const postData = JSON.stringify(postDataObj); + const { scheduledTime } = req.body; + + const scheduledPin = new pinterestSchedule({ postData, scheduledTime }); + await scheduledPin.save(); + res.status(200).send(); + } catch (err) { + res.status(500).send(); + } +} + +async function fetchScheduledPin(_req, res) { + try { + // TODO: add pagination + const scheduledPinList = await pinterestSchedule.find(); + res.status(200).json(scheduledPinList); + } catch (err) { + res.status(500).send('Failed to fetch scheduled pins'); + } +} + +async function deletedScheduledPin(req, res) { + try { + await pinterestSchedule.deleteOne({ _id: req.params.id }); + res.status(200).send('Scheduled pin post deleted successfully!'); + } catch (err) { + res.status(500).send('Failed to deleted scheduled pin post!'); + } +} + +module.exports = { + createPin, + fetchBoardList, + createBoard, + schedulePin, + fetchScheduledPin, + deletedScheduledPin, + postPinImmediately, + initiatePinterestAuth, + handlePinterestCallback, +}; diff --git a/src/cronjobs/pinterestScheduleJob.js b/src/cronjobs/pinterestScheduleJob.js new file mode 100644 index 0000000000..6762860b5f --- /dev/null +++ b/src/cronjobs/pinterestScheduleJob.js @@ -0,0 +1,26 @@ +const { CronJob } = require('cron'); +const moment = require('moment-timezone'); +const pinterestSchedule = require('../models/pinterestSchedule'); +const { postPinImmediately } = require('../controllers/socialMediaControllers'); + +async function pinterestScheduleJob() { + new CronJob('* * * * *', async () => { + try { + const now = new Date().toISOString(); + const scheduledPinList = await pinterestSchedule.find({ scheduledTime: { $lte: now } }); + + for (const scheduledPin of scheduledPinList) { + const postData = JSON.parse(scheduledPin.postData); + // console.log(`Creating pin: ${scheduledPin}`) + await postPinImmediately(postData); + + // Delete database record after posting to pinterest + await pinterestSchedule.deleteOne(scheduledPin); + } + } catch (err) { + console.log(err); + } + }).start(); +} + +module.exports = pinterestScheduleJob; diff --git a/src/models/pinterestSchedule.js b/src/models/pinterestSchedule.js new file mode 100644 index 0000000000..3c5839005b --- /dev/null +++ b/src/models/pinterestSchedule.js @@ -0,0 +1,10 @@ +const mongoose = require('mongoose'); + +const { Schema } = mongoose; + +const pinterestSchedule = new Schema({ + postData: { type: String, required: true }, + scheduledTime: { type: Date, required: true }, +}); + +module.exports = mongoose.model('pinterestSchedule', pinterestSchedule); diff --git a/src/routes/socialMediaRouter.js b/src/routes/socialMediaRouter.js new file mode 100644 index 0000000000..2a57df2393 --- /dev/null +++ b/src/routes/socialMediaRouter.js @@ -0,0 +1,26 @@ +const express = require('express'); +const { + createPin, + schedulePin, + fetchScheduledPin, + deletedScheduledPin, +} = require('../controllers/socialMediaControllers'); +const { + initiatePinterestAuth, + handlePinterestCallback, +} = require('../controllers/socialMediaControllers'); + +const socialMediaRouter = express.Router(); + +// social media routes +socialMediaRouter.route('/pinterest/createPin').post(createPin); +// Step 1 — redirects user to Pinterest login +socialMediaRouter.route('/pinterest/auth').get(initiatePinterestAuth); + +// Step 2 — Pinterest redirects back here with ?code= +socialMediaRouter.route('/pinterest/auth/callback').get(handlePinterestCallback); +socialMediaRouter.route('/pinterest/schedule').post(schedulePin); +socialMediaRouter.route('/pinterest/schedule').get(fetchScheduledPin); +socialMediaRouter.route('/pinterest/schedule/:id').delete(deletedScheduledPin); + +module.exports = socialMediaRouter; diff --git a/src/server.js b/src/server.js index 4ec46ec767..448ce74d15 100644 --- a/src/server.js +++ b/src/server.js @@ -17,6 +17,7 @@ require('./cronjobs/userProfileJobs')(); require('./cronjobs/pullRequestReviewJobs')(); require('./jobs/analyticsAggregation').scheduleDaily(); require('./cronjobs/bidWinnerJobs')(); +require('./cronjobs/pinterestScheduleJob')(); // Process pending and stuck emails on startup (only after DB is connected) mongoose.connection.once('connected', () => { diff --git a/src/startup/middleware.js b/src/startup/middleware.js index d6f14cee43..7d5addf891 100644 --- a/src/startup/middleware.js +++ b/src/startup/middleware.js @@ -37,7 +37,12 @@ module.exports = function (app) { if (req.originalUrl.startsWith('/api/mastodon')) { return next(); } - const openPaths = ['/api/lb/myWebhooks']; + // const openPaths = ['/api/lb/myWebhooks']; + const openPaths = [ + '/paypal/webhook', // existing + '/pinterest/auth', // add this + '/pinterest/auth/callback', // add this + ]; if (req.originalUrl === '/') { res.status(200).send('This is the homepage for rest services'); @@ -119,6 +124,15 @@ module.exports = function (app) { } // Skip auth check for PayPal webhook route + // Skip auth check for Pinterest OAuth routes + if (req.originalUrl.startsWith('/api/social/pinterest/auth')) { + return next(); + } + + // Skip auth check for PayPal webhook route + if (openPaths.includes(req.path)) { + return next(); + } if (openPaths.includes(req.path)) { return next(); // Allow PayPal requests through diff --git a/src/startup/routes.js b/src/startup/routes.js index 49c325c22b..a360079dd6 100644 --- a/src/startup/routes.js +++ b/src/startup/routes.js @@ -145,7 +145,7 @@ const jobsRouter = require('../routes/jobsRouter'); const laborCostRouter = require('../routes/laborCostRouter'); const jobAnalyticsRouter = require('../routes/jobAnalyticsRouter'); const liveJournalPost = require('../models/liveJournalPost'); - +const socialMediaRouter = require('../routes/socialMediaRouter'); // const actionItemRouter = require('../routes/actionItemRouter')(actionItem); // const actionItemRouter = require('../routes/actionItemRouter')(actionItem); // const actionItemRouter = require('../routes/actionItemRouter')(actionItem); @@ -666,4 +666,8 @@ module.exports = function (app) { app.use('/api/kitchenandinventory/recipes', recipeRouter); app.use('/api/analytics', analyticsRouter); + + //social media routes + app.use('/api/social', socialMediaRouter); // keep existing + app.use('/social', socialMediaRouter); // add this for Vite proxy };