-
-
Notifications
You must be signed in to change notification settings - Fork 44
Marcus finishes facebook autoposter backend #2005
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: development
Are you sure you want to change the base?
Changes from all commits
75575ae
8f84604
119f065
73c4219
b28b658
218cdc1
ebe7827
114147f
3fd3cdc
ab206f9
8b529f8
b2291c7
8a233cf
12795f5
53bdce7
4edea3b
aaf59c7
75e33e5
a68af5f
dc2c068
e604213
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| const cron = require('node-cron'); | ||
| const ScheduledFacebookPost = require('../models/scheduledFacebookPost'); | ||
| const { publishToFacebook, getCredentials } = require('../controllers/facebookController'); | ||
| const logger = require('../startup/logger'); | ||
|
|
||
| const PST_TIMEZONE = 'America/Los_Angeles'; | ||
| const MAX_POSTS_PER_TICK = 5; | ||
| const MAX_RETRY_ATTEMPTS = 3; | ||
|
|
||
| const handlePostError = async (post, error) => { | ||
| let errorMessage = 'Unknown error'; | ||
| if (typeof error.details === 'string') { | ||
| errorMessage = error.details; | ||
| } else if (typeof error.details === 'object' && error.details?.message) { | ||
| errorMessage = error.details.message; | ||
| } else if (error.message) { | ||
| errorMessage = error.message; | ||
| } | ||
|
|
||
| post.attempts += 1; | ||
| post.lastError = errorMessage; | ||
|
|
||
| if (post.attempts >= MAX_RETRY_ATTEMPTS) { | ||
| post.status = 'failed'; | ||
| console.log( | ||
| `[FacebookScheduler] Permanently failed after ${post.attempts} attempts:`, | ||
| post._id, | ||
| '-', | ||
| errorMessage, | ||
| ); | ||
| } else { | ||
| post.status = 'pending'; | ||
| console.log( | ||
| `[FacebookScheduler] Attempt ${post.attempts}/${MAX_RETRY_ATTEMPTS} failed, will retry:`, | ||
| post._id, | ||
| '-', | ||
| errorMessage, | ||
| ); | ||
| } | ||
|
|
||
| try { | ||
| await post.save(); | ||
| } catch (saveError) { | ||
| console.error('[FacebookScheduler] Failed to save error status:', saveError.message); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Processes the next pending scheduled post. | ||
| * Uses credentials from OAuth connection or falls back to env vars. | ||
| */ | ||
| const processNextScheduledPost = async () => { | ||
| let nextPost; | ||
|
|
||
| try { | ||
| const credentials = await getCredentials(); | ||
| if (!credentials) { | ||
| return false; | ||
| } | ||
|
|
||
| nextPost = await ScheduledFacebookPost.findOneAndUpdate( | ||
| { status: 'pending', scheduledFor: { $lte: new Date() } }, | ||
| { status: 'sending', lastError: null }, | ||
| { sort: { scheduledFor: 1 }, new: true }, | ||
| ).exec(); | ||
|
|
||
| if (!nextPost) return false; | ||
|
|
||
| console.log('[FacebookScheduler] Processing post:', nextPost._id); | ||
|
|
||
| const hasStoredImage = nextPost.imageData && nextPost.imageMimeType; | ||
|
|
||
| const result = await publishToFacebook({ | ||
| message: nextPost.message || undefined, | ||
| link: nextPost.link, | ||
| imageUrl: hasStoredImage ? undefined : nextPost.imageUrl, | ||
| imageBuffer: hasStoredImage ? nextPost.imageData : undefined, | ||
| imageMimeType: hasStoredImage ? nextPost.imageMimeType : undefined, | ||
| pageId: nextPost.pageId, | ||
| }); | ||
|
|
||
| nextPost.status = 'sent'; | ||
| nextPost.postedAt = new Date(); | ||
| nextPost.postId = result.postId; | ||
| nextPost.postType = result.postType; | ||
| nextPost.attempts += 1; | ||
| nextPost.lastError = null; | ||
| nextPost.imageData = null; | ||
| await nextPost.save(); | ||
|
|
||
| console.log( | ||
| '[FacebookScheduler] Successfully posted:', | ||
| nextPost._id, | ||
| '-> FB Post ID:', | ||
| result.postId, | ||
| ); | ||
| return true; | ||
| } catch (error) { | ||
| console.error('[FacebookScheduler] Error processing post:', error.message); | ||
|
|
||
| if (nextPost) { | ||
| await handlePostError(nextPost, error); | ||
| } | ||
|
|
||
| if (typeof logger?.logException === 'function') { | ||
| logger.logException(error, 'facebookScheduler.process', { | ||
| scheduledId: nextPost?._id?.toString(), | ||
| }); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Checks if credentials are available and logs status periodically | ||
| */ | ||
| let lastCredentialCheck = 0; | ||
| const CREDENTIAL_CHECK_INTERVAL = 5 * 60 * 1000; // 5 minutes | ||
|
|
||
| const checkCredentialsStatus = async () => { | ||
| const now = Date.now(); | ||
| if (now - lastCredentialCheck < CREDENTIAL_CHECK_INTERVAL) { | ||
| return; | ||
| } | ||
| lastCredentialCheck = now; | ||
|
|
||
| const credentials = await getCredentials(); | ||
| if (!credentials) { | ||
| console.log( | ||
| '[FacebookScheduler] Warning: No Facebook credentials configured. Scheduled posts will not be sent.', | ||
| ); | ||
| } else { | ||
| console.log('[FacebookScheduler] Credentials available from:', credentials.source); | ||
| } | ||
| }; | ||
|
|
||
| /** | ||
| * Starts the Facebook scheduler cron job. | ||
| * Runs every minute to check for posts that need to be sent. | ||
| */ | ||
| const startFacebookScheduler = () => { | ||
| console.log('[FacebookScheduler] Starting cron job...'); | ||
|
|
||
| checkCredentialsStatus(); | ||
|
|
||
| cron.schedule( | ||
| '* * * * *', | ||
| async () => { | ||
| await checkCredentialsStatus(); | ||
|
|
||
| let processedCount = 0; | ||
| let keepProcessing = true; | ||
| while (keepProcessing && processedCount < MAX_POSTS_PER_TICK) { | ||
| keepProcessing = await processNextScheduledPost(); | ||
| if (keepProcessing) processedCount += 1; | ||
| } | ||
| if (processedCount > 0) { | ||
| console.log(`[FacebookScheduler] Processed ${processedCount} post(s) this tick`); | ||
| } | ||
| }, | ||
| { timezone: PST_TIMEZONE }, | ||
| ); | ||
| }; | ||
|
|
||
| module.exports = startFacebookScheduler; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| const mongoose = require('mongoose'); | ||
|
|
||
| const { Schema } = mongoose; | ||
|
|
||
| /** | ||
| * Stores the organization's Facebook Page connection. | ||
| * Single shared token approach - one connection used by all authorized users. | ||
| */ | ||
| const FacebookConnectionSchema = new Schema( | ||
| { | ||
| // Page details | ||
| pageId: { type: String, required: true }, | ||
| pageName: { type: String }, | ||
| pageAccessToken: { type: String, required: true }, | ||
|
|
||
| // Token metadata | ||
| tokenExpiresAt: { type: Date }, // Long-lived tokens expire in ~60 days | ||
| tokenType: { type: String, default: 'page_access_token' }, | ||
|
|
||
| // User token (used to refresh page token if needed) | ||
| userAccessToken: { type: String }, | ||
| userTokenExpiresAt: { type: Date }, | ||
| userId: { type: String }, // Facebook user ID who connected | ||
|
|
||
| // Connection status | ||
| isActive: { type: Boolean, default: true }, | ||
| lastVerifiedAt: { type: Date }, | ||
| lastError: { type: String }, | ||
|
|
||
| // Audit trail | ||
| connectedBy: { | ||
| odUserId: { type: String }, // HGN user ID | ||
| name: { type: String }, | ||
| role: { type: String }, | ||
| }, | ||
| disconnectedBy: { | ||
| odUserId: { type: String }, | ||
| name: { type: String }, | ||
| role: { type: String }, | ||
| disconnectedAt: { type: Date }, | ||
| }, | ||
|
|
||
| // Permissions granted during OAuth | ||
| grantedPermissions: [{ type: String }], | ||
| }, | ||
| { timestamps: true }, | ||
| ); | ||
|
|
||
| // Unique active connection per pageId, allow multiple inactive records | ||
| FacebookConnectionSchema.index( | ||
| { pageId: 1 }, | ||
| { unique: true, partialFilterExpression: { isActive: true } }, | ||
| ); | ||
|
|
||
| // Index for quick lookup of active connection | ||
| FacebookConnectionSchema.index({ isActive: 1, createdAt: -1 }); | ||
|
|
||
| /** | ||
| * Static method to get the active connection (if any) | ||
| */ | ||
| FacebookConnectionSchema.statics.getActiveConnection = async function () { | ||
| return this.findOne({ isActive: true }).sort({ createdAt: -1 }).exec(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }; | ||
|
|
||
| /** | ||
| * Static method to deactivate all connections (used before creating new one) | ||
| */ | ||
| FacebookConnectionSchema.statics.deactivateAll = async function (disconnectedBy) { | ||
| return this.updateMany( | ||
| { isActive: true }, | ||
| { | ||
| isActive: false, | ||
| disconnectedBy: { | ||
| ...disconnectedBy, | ||
| disconnectedAt: new Date(), | ||
| }, | ||
| }, | ||
| ); | ||
| }; | ||
|
|
||
| module.exports = mongoose.model('FacebookConnection', FacebookConnectionSchema); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| const mongoose = require('mongoose'); | ||
|
|
||
| const { Schema } = mongoose; | ||
|
|
||
| const ScheduledFacebookPostSchema = new Schema( | ||
| { | ||
| message: { type: String, default: '' }, | ||
| link: { type: String }, | ||
| imageUrl: { type: String }, | ||
| imageData: { type: Buffer, default: null }, | ||
| imageMimeType: { type: String, default: null }, | ||
| imageOriginalName: { type: String, default: null }, | ||
| pageId: { type: String }, | ||
| scheduledFor: { type: Date, required: true }, | ||
| timezone: { type: String, default: 'America/Los_Angeles' }, | ||
| status: { | ||
| type: String, | ||
| enum: ['pending', 'sending', 'sent', 'failed'], | ||
| default: 'pending', | ||
| }, | ||
| attempts: { type: Number, default: 0 }, | ||
| postedAt: { type: Date }, | ||
| postId: { type: String }, | ||
| postType: { type: String }, | ||
| postMethod: { | ||
| type: String, | ||
| enum: ['direct', 'scheduled'], | ||
| default: 'scheduled', | ||
| }, | ||
| lastError: { type: String }, | ||
| createdBy: { | ||
| userId: { type: String }, | ||
| role: { type: String }, | ||
| permissions: { type: Schema.Types.Mixed }, | ||
| }, | ||
| }, | ||
| { timestamps: true }, | ||
| ); | ||
|
|
||
| module.exports = mongoose.model('ScheduledFacebookPost', ScheduledFacebookPostSchema); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| const express = require('express'); | ||
| const multer = require('multer'); | ||
| const { | ||
| postToFacebook, | ||
| postToFacebookWithImage, | ||
| scheduleFacebookPost, | ||
| scheduleFacebookPostWithImage, | ||
| getScheduledPosts, | ||
| getPostHistory, | ||
| cancelScheduledPost, | ||
| updateScheduledPost, | ||
| } = require('../controllers/facebookController'); | ||
| const { | ||
| getConnectionStatus, | ||
| handleAuthCallback, | ||
| connectPage, | ||
| disconnectPage, | ||
| verifyConnection, | ||
| } = require('../controllers/facebookAuthController'); | ||
|
|
||
| const upload = multer({ | ||
| storage: multer.memoryStorage(), | ||
| limits: { | ||
| fileSize: 10 * 1024 * 1024, | ||
|
Check warning on line 24 in src/routes/facebookRouter.js
|
||
| }, | ||
| fileFilter: (req, file, cb) => { | ||
| const allowedMimes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; | ||
| if (allowedMimes.includes(file.mimetype)) { | ||
| cb(null, true); | ||
| } else { | ||
| cb(new Error('Only JPEG, PNG, GIF, and WebP images are allowed'), false); | ||
| } | ||
| }, | ||
| }); | ||
|
|
||
| const routes = function () { | ||
| const router = express.Router(); | ||
|
|
||
| // Auth and connection routes | ||
| router.route('/social/facebook/auth/status').get(getConnectionStatus); | ||
| router.route('/social/facebook/auth/callback').post(handleAuthCallback); | ||
| router.route('/social/facebook/auth/connect').post(connectPage); | ||
| router.route('/social/facebook/auth/disconnect').post(disconnectPage); | ||
| router.route('/social/facebook/auth/verify').post(verifyConnection); | ||
|
|
||
| // Posting routes | ||
| router | ||
| .route('/social/facebook/post/upload') | ||
| .post(upload.single('image'), postToFacebookWithImage); | ||
| router.route('/social/facebook/post').post(postToFacebook); | ||
|
|
||
| // Scheduling routes | ||
| router | ||
| .route('/social/facebook/schedule/upload') | ||
| .post(upload.single('image'), scheduleFacebookPostWithImage); | ||
| router.route('/social/facebook/schedule').post(scheduleFacebookPost); | ||
|
|
||
| // Scheduled posts management | ||
| router.route('/social/facebook/scheduled').get(getScheduledPosts); | ||
| router | ||
| .route('/social/facebook/schedule/:postId') | ||
| .delete(cancelScheduledPost) | ||
| .put(updateScheduledPost); | ||
|
|
||
| // Post history | ||
| router.route('/social/facebook/history').get(getPostHistory); | ||
|
|
||
| return router; | ||
| }; | ||
|
|
||
| module.exports = routes; | ||

There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
pageAccessTokenanduserAccessTokenare stored as plain strings with no encryption and noselect: false, so they come back in any query that doesn't explicitly exclude them. A long-lived Page token is enough to post as the Page for ~60 days, so it's worth keeping these out of default query results at minimum.