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
4 changes: 4 additions & 0 deletions src/app.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
const express = require('express');
const Sentry = require('@sentry/node');
const testRoutes = require('./routes/testRoutes');

Check warning on line 3 in src/app.js

View workflow job for this annotation

GitHub Actions / Lint Check

There should be no empty line between import groups

const app = express();
const logger = require('./startup/logger');
const globalErrorHandler = require('./utilities/errorHandling/globalErrorHandler');

Check warning on line 7 in src/app.js

View workflow job for this annotation

GitHub Actions / Lint Check

There should be no empty line between import groups
// const experienceRoutes = require('./routes/applicantAnalyticsRoutes');

logger.init();
Expand All @@ -20,11 +20,15 @@
app.use('/api/test', testRoutes);

const helpFeedbackRouter = require('./routes/helpFeedbackRouter');
const helpRequestRouter = require('./routes/helpRequestRouter');

Check warning on line 23 in src/app.js

View workflow job for this annotation

GitHub Actions / Lint Check

There should be no empty line between import groups

app.use('/api/feedback', helpFeedbackRouter);
app.use('/api/helprequest', helpRequestRouter);

const path = require('path');

Check warning on line 28 in src/app.js

View workflow job for this annotation

GitHub Actions / Lint Check

`path` import should occur before import of `express`

Check warning on line 28 in src/app.js

View workflow job for this annotation

GitHub Actions / Lint Check

There should be no empty line between import groups

Check warning on line 28 in src/app.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HGNRest&issues=AaBKlcjP69uhNReJfISR&open=AaBKlcjP69uhNReJfISR&pullRequest=2326

app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));

require('./startup/middleware')(app);

const weeklyReportsRouter = require('./routes/weeklyReportsRouter');
Expand Down
305 changes: 305 additions & 0 deletions src/controllers/instagramController.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,305 @@
const fs = require('fs');

Check warning on line 1 in src/controllers/instagramController.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:fs` over `fs`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HGNRest&issues=AaBKlccq69uhNReJfISB&open=AaBKlccq69uhNReJfISB&pullRequest=2326
const path = require('path');

Check warning on line 2 in src/controllers/instagramController.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:path` over `path`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HGNRest&issues=AaBKlccq69uhNReJfISC&open=AaBKlccq69uhNReJfISC&pullRequest=2326
const crypto = require('crypto');

Check warning on line 3 in src/controllers/instagramController.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer `node:crypto` over `crypto`.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HGNRest&issues=AaBKlccq69uhNReJfISD&open=AaBKlccq69uhNReJfISD&pullRequest=2326
const mongoose = require('mongoose');
const InstagramScheduledPost = require('../models/instagramScheduledPost');
const InstagramPostHistory = require('../models/instagramPostHistory');
const MetaToken = require('../models/metaToken');
const { publishInstagramPost } = require('../services/instagramServices');

const getInstagramCredentials = async () => {
const tokenDoc = await MetaToken.findOne({ platform: 'instagram' });

if (!tokenDoc || tokenDoc.expiresAt < new Date()) {
throw new Error('Instagram access token is missing or expired. Refresh required.');
}

return {
instagramAccountId: process.env.INSTAGRAM_ACCOUNT_ID, // this doesn't expire, fine to keep in env
accessToken: tokenDoc.accessToken,
};
};

const saveBase64Media = async (media) => {
if (!media || !media.base64) {
throw new Error('Media is required.');
}

const match = media.base64.match(/^data:(image\/[^;]+|video\/[^;]+);base64,(.+)$/);

if (!match) {
throw new Error('Invalid media data.');
}

const mimeType = match[1];
const base64Data = match[2];

const extension = mimeType.split('/')[1] || 'bin';

const fileName = `${crypto.randomUUID()}.${extension}`;

const uploadDirectory = path.join(process.cwd(), 'uploads', 'instagram');

fs.mkdirSync(uploadDirectory, {
recursive: true,
});

const filePath = path.join(uploadDirectory, fileName);

fs.writeFileSync(filePath, Buffer.from(base64Data, 'base64'));

/*
* This URL MUST be publicly accessible to Meta.
*
* Configure INSTAGRAM_MEDIA_BASE_URL to point
* to your public backend URL.
*/

const baseUrl = process.env.INSTAGRAM_MEDIA_BASE_URL;

if (!baseUrl) {
throw new Error('INSTAGRAM_MEDIA_BASE_URL is not configured.');
}

return {
mediaUrl: `${baseUrl}/uploads/instagram/${fileName}`,

mediaType: mimeType.startsWith('video') ? 'VIDEO' : 'IMAGE',
};
};

const createPost = async (req, res) => {
try {
const userId = req.user?._id;
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(401).json({ detail: 'Not authenticated' });
}

const { caption, media, altText } = req.body;

Check warning on line 78 in src/controllers/instagramController.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the declaration of the unused 'altText' variable.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HGNRest&issues=AaBKlccq69uhNReJfISE&open=AaBKlccq69uhNReJfISE&pullRequest=2326

Check warning on line 78 in src/controllers/instagramController.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this useless assignment to variable "altText".

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HGNRest&issues=AaBKlccq69uhNReJfISF&open=AaBKlccq69uhNReJfISF&pullRequest=2326

if (!caption || !caption.trim()) {
return res.status(400).json({
error: 'Caption is required.',
});
}

if (!media || !media.base64) {
return res.status(400).json({
error: 'Media is required.',
});
}

const { instagramAccountId, accessToken } = await getInstagramCredentials();

const uploadedMedia = await saveBase64Media(media);

const result = await publishInstagramPost({
instagramAccountId,
accessToken,
caption: caption.trim(),
mediaUrl: uploadedMedia.mediaUrl,
mediaType: uploadedMedia.mediaType,
});

await InstagramPostHistory.create({
userId,
caption: caption.trim(),
mediaUrl: uploadedMedia.mediaUrl,
mediaType: uploadedMedia.mediaType,
instagramMediaId: result.instagramMediaId,
permalink: result.permalink,
postedAt: new Date(),
status: 'published',
});

return res.status(200).json({
success: true,
creationId: result.creationId,
instagramMediaId: result.instagramMediaId,
permalink: result.permalink,
});
} catch (err) {
console.error('[Instagram] Create post error:', err.response?.data || err.message);

Check warning on line 122 in src/controllers/instagramController.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Change this code to not log user-controlled data.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HGNRest&issues=AaBuoZKCQFzF_RdrA7RH&open=AaBuoZKCQFzF_RdrA7RH&pullRequest=2326

return res.status(500).json({
error: err.response?.data?.error?.message || err.message || 'Failed to post to Instagram.',
});
}
};

const schedulePost = async (req, res) => {
try {
const userId = req.user?._id;
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(401).json({ detail: 'Not authenticated' });
}

const { caption, media, altText, scheduledTime } = req.body;

if (!caption || !caption.trim()) {
return res.status(400).json({
error: 'Caption is required.',
});
}

if (!media || !media.base64) {
return res.status(400).json({
error: 'Media is required.',
});
}

if (!scheduledTime) {
return res.status(400).json({
error: 'Scheduled time is required.',
});
}

const scheduledDate = new Date(scheduledTime);

if (Number.isNaN(scheduledDate.getTime()) || scheduledDate <= new Date()) {
return res.status(400).json({
error: 'Scheduled time must be in the future.',
});
}

const uploadedMedia = await saveBase64Media(media);

const post = await InstagramScheduledPost.create({
userId,
caption: caption.trim(),
mediaUrl: uploadedMedia.mediaUrl,
mediaType: uploadedMedia.mediaType,
mediaAltText: altText || null,
scheduledTime: scheduledDate,
status: 'scheduled',
});

return res.status(201).json({
message: 'Post scheduled.',
post,
});
} catch (err) {
console.error('[Instagram] Schedule error:', err.message);

return res.status(500).json({
error: err.message,
});
}
};

const getScheduledPosts = async (req, res) => {
try {
const userId = req.user?._id;
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(401).json({ detail: 'Not authenticated' });
}
const posts = await InstagramScheduledPost.find({
userId,
status: {
$in: ['scheduled', 'publishing', 'failed'],
},
})
.sort({ scheduledTime: 1 })
.lean();

return res.status(200).json(posts);
} catch (err) {
return res.status(500).json({
error: err.message,
});
}
};

const deleteScheduledPost = async (req, res) => {
try {
const userId = req.user?._id;
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(401).json({ detail: 'Not authenticated' });
}

const post = await InstagramScheduledPost.findOneAndDelete({
_id: req.params.id,
userId,
});

if (!post) {
return res.status(404).json({
error: 'Scheduled post not found.',
});
}

return res.status(200).json({
message: 'Deleted.',
});
} catch (err) {
return res.status(500).json({
error: err.message,
});
}
};

const getHistory = async (req, res) => {
try {
const userId = req.user?._id;
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(401).json({ detail: 'Not authenticated' });
}
const limit = Math.min(Number(req.query.limit) || 20, 100);

const history = await InstagramPostHistory.find({
userId,
})
.sort({ postedAt: -1 })
.limit(limit)
.lean();

return res.status(200).json(history);
} catch (err) {
return res.status(500).json({
error: err.message,
});
}
};

const retryScheduledPost = async (req, res) => {
try {
const userId = req.user?._id;
if (!mongoose.Types.ObjectId.isValid(userId)) {
return res.status(401).json({ detail: 'Not authenticated' });
}

const post = await InstagramScheduledPost.findOne({
_id: req.params.id,
userId,
});

if (!post) {
return res.status(404).json({
error: 'Scheduled post not found.',
});
}

post.status = 'scheduled';
post.lastError = null;

await post.save();

return res.status(200).json({
message: 'Post re-queued.',
post,
});
} catch (err) {
return res.status(500).json({
error: err.message,
});
}
};

module.exports = {
createPost,
schedulePost,
getScheduledPosts,
deleteScheduledPost,
getHistory,
retryScheduledPost,
};
11 changes: 11 additions & 0 deletions src/cronjobs/instagramSchedulerJob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
const cron = require('node-cron');
const { runInstagramScheduler } = require('../services/instagramScheduler');

// Run every minute
cron.schedule('* * * * *', async () => {
try {
await runInstagramScheduler();
} catch (err) {
console.error('[Instagram Scheduler] Error:', err.message);
}
});
16 changes: 16 additions & 0 deletions src/cronjobs/instagramTokenRefreshJob.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// cronjobs/instagramTokenRefreshJob.js
const cron = require('node-cron'); // or whatever scheduler this repo already uses — check cronjobs/userProfileJobs.js for the pattern
const refreshInstagramToken = require('../services/refreshInstagramToken');
const logger = require('../startup/logger');

module.exports = () => {

Check warning on line 6 in src/cronjobs/instagramTokenRefreshJob.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

The arrow function should be named.

See more on https://sonarcloud.io/project/issues?id=OneCommunityGlobal_HGNRest&issues=AaBKlchL69uhNReJfISP&open=AaBKlchL69uhNReJfISP&pullRequest=2326
// Run daily at 3am — refreshing well before the 60-day expiry
cron.schedule('0 3 * * *', async () => {
try {
const tokenDoc = await refreshInstagramToken();
logger.logInfo(`Instagram token refreshed, expires ${tokenDoc.expiresAt}`);
} catch (err) {
logger.logException(err, 'Instagram token refresh failed');
}
});
};
Loading
Loading