Skip to content

Commit bd2445f

Browse files
Merge pull request #1454 from OneCommunityGlobal/Manvitha-Linkedin-Autoposter
Sharadha taking over for Manvitha - linkedin autoposter backend
2 parents f3792e6 + d494cf5 commit bd2445f

File tree

4 files changed

+564
-0
lines changed

4 files changed

+564
-0
lines changed
Lines changed: 330 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,330 @@
1+
const axios = require('axios');
2+
const schedule = require('node-schedule');
3+
4+
const summarizeMediaFiles = (mediaFiles = []) =>
5+
mediaFiles.map(({ originalname, mimetype, size }) => ({
6+
name: originalname,
7+
type: mimetype,
8+
size,
9+
}));
10+
11+
const normalizeMediaFiles = (mediaFiles = []) =>
12+
mediaFiles.map((file) => ({
13+
buffer: file.buffer,
14+
originalname: file.originalname,
15+
mimetype: file.mimetype,
16+
size: file.size,
17+
}));
18+
19+
const validateScheduleTime = (scheduleTime) => {
20+
if (!scheduleTime) {
21+
return { scheduledDateTime: null };
22+
}
23+
24+
const scheduledDateTime = new Date(scheduleTime);
25+
if (Number.isNaN(scheduledDateTime.getTime())) {
26+
return { error: 'Schedule time is invalid' };
27+
}
28+
29+
if (scheduledDateTime <= new Date()) {
30+
return { error: 'Schedule time must be in the future' };
31+
}
32+
33+
return { scheduledDateTime };
34+
};
35+
36+
const publishToLinkedIn = async (content, mediaFiles, organizationUrn, accessToken) => {
37+
const uploadedAssets = await Promise.all(
38+
(mediaFiles || []).map(async (file) => {
39+
const isVideo = file.mimetype.includes('video');
40+
const recipes = isVideo
41+
? ['urn:li:digitalmediaRecipe:feedshare-video']
42+
: ['urn:li:digitalmediaRecipe:feedshare-image'];
43+
44+
const registerResponse = await axios.post(
45+
'https://api.linkedin.com/v2/assets?action=registerUpload',
46+
{
47+
registerUploadRequest: {
48+
recipes,
49+
owner: organizationUrn,
50+
serviceRelationships: [
51+
{
52+
relationshipType: 'OWNER',
53+
identifier: 'urn:li:userGeneratedContent',
54+
},
55+
],
56+
},
57+
},
58+
{
59+
headers: {
60+
Authorization: `Bearer ${accessToken}`,
61+
'Content-Type': 'application/json',
62+
},
63+
},
64+
);
65+
66+
const { uploadUrl } =
67+
registerResponse.data.value.uploadMechanism[
68+
'com.linkedin.digitalmedia.uploading.MediaUploadHttpRequest'
69+
];
70+
const { asset } = registerResponse.data.value;
71+
const parsedUploadUrl = new URL(uploadUrl);
72+
const uploadHostname = parsedUploadUrl.hostname.toLowerCase();
73+
const isTrustedLinkedInUploadHost =
74+
parsedUploadUrl.protocol === 'https:' &&
75+
(uploadHostname.endsWith('.linkedin.com') || uploadHostname.endsWith('.licdn.com'));
76+
77+
if (!isTrustedLinkedInUploadHost) {
78+
throw new Error('Received an invalid LinkedIn upload URL');
79+
}
80+
81+
await axios.put(parsedUploadUrl.toString(), file.buffer, {
82+
headers: {
83+
Authorization: `Bearer ${accessToken}`,
84+
'Content-Type': file.mimetype,
85+
'Content-Length': file.buffer.length,
86+
},
87+
});
88+
89+
return asset;
90+
}),
91+
);
92+
93+
let shareMediaCategory = 'NONE';
94+
if (uploadedAssets.length > 0) {
95+
shareMediaCategory = mediaFiles[0].mimetype.includes('video') ? 'VIDEO' : 'IMAGE';
96+
}
97+
98+
const postData = {
99+
author: organizationUrn,
100+
lifecycleState: 'PUBLISHED',
101+
specificContent: {
102+
'com.linkedin.ugc.ShareContent': {
103+
shareCommentary: {
104+
text: content,
105+
},
106+
shareMediaCategory,
107+
media: uploadedAssets.map((asset) => ({
108+
status: 'READY',
109+
media: asset,
110+
})),
111+
},
112+
},
113+
visibility: {
114+
'com.linkedin.ugc.MemberNetworkVisibility': 'PUBLIC',
115+
},
116+
};
117+
118+
const response = await axios.post('https://api.linkedin.com/v2/ugcPosts', postData, {
119+
headers: {
120+
Authorization: `Bearer ${accessToken}`,
121+
'Content-Type': 'application/json',
122+
'X-Restli-Protocol-Version': '2.0.0',
123+
},
124+
});
125+
126+
return response.data;
127+
};
128+
129+
const createScheduledJob = (
130+
jobId,
131+
content,
132+
scheduledDateTime,
133+
mediaFiles,
134+
organizationUrn,
135+
accessToken,
136+
scheduledJobs,
137+
) =>
138+
schedule.scheduleJob(scheduledDateTime, async () => {
139+
try {
140+
await publishToLinkedIn(content, mediaFiles, organizationUrn, accessToken);
141+
scheduledJobs.delete(jobId);
142+
} catch (error) {
143+
console.error('Scheduled LinkedIn post failed:', error.response?.data || error.message);
144+
}
145+
});
146+
147+
const linkedinPostController = () => {
148+
const scheduledJobs = new Map();
149+
150+
const getScheduledPosts = (req, res) => {
151+
try {
152+
const scheduledPosts = Array.from(scheduledJobs.entries()).map(([jobId, data]) => ({
153+
jobId,
154+
content: data.content,
155+
scheduleTime: data.scheduleTime,
156+
mediaFiles: summarizeMediaFiles(data.mediaFiles),
157+
}));
158+
159+
return res.status(200).json({
160+
success: true,
161+
scheduledPosts,
162+
});
163+
} catch (error) {
164+
console.error('Error getting scheduled posts:', error);
165+
return res.status(500).json({
166+
success: false,
167+
message: 'Failed to get scheduled posts',
168+
error: error.message,
169+
});
170+
}
171+
};
172+
173+
const postToLinkedin = async (req, res) => {
174+
try {
175+
const { content, scheduleTime } = req.body;
176+
const mediaFiles = normalizeMediaFiles(req.files || []);
177+
const { ORGANIZATION_URN: organizationUrn, LINKEDIN_ACCESS_TOKEN: accessToken } = process.env;
178+
179+
if (!content) {
180+
return res.status(400).json({
181+
success: false,
182+
message: 'Content is required',
183+
});
184+
}
185+
186+
if (!organizationUrn || !accessToken) {
187+
return res.status(400).json({
188+
success: false,
189+
message: 'Missing required environment variables.',
190+
});
191+
}
192+
193+
const { scheduledDateTime, error } = validateScheduleTime(scheduleTime);
194+
if (error) {
195+
return res.status(400).json({
196+
success: false,
197+
message: error,
198+
});
199+
}
200+
201+
if (scheduledDateTime) {
202+
const jobId = `linkedin-post-${Date.now()}`;
203+
const job = createScheduledJob(
204+
jobId,
205+
content,
206+
scheduledDateTime,
207+
mediaFiles,
208+
organizationUrn,
209+
accessToken,
210+
scheduledJobs,
211+
);
212+
213+
scheduledJobs.set(jobId, {
214+
job,
215+
content,
216+
scheduleTime: scheduledDateTime,
217+
mediaFiles,
218+
});
219+
220+
return res.status(200).json({
221+
success: true,
222+
message: 'Post scheduled successfully',
223+
scheduledTime: scheduledDateTime,
224+
jobId,
225+
});
226+
}
227+
228+
await publishToLinkedIn(content, mediaFiles, organizationUrn, accessToken);
229+
230+
return res.status(200).json({
231+
success: true,
232+
message: 'Posted successfully to LinkedIn',
233+
});
234+
} catch (error) {
235+
console.error('LinkedIn post error:', error.response?.data || error.message);
236+
return res.status(error.response?.status || 500).json({
237+
success: false,
238+
message: error.response?.data?.message || 'Failed to post to LinkedIn',
239+
error: error.response?.data || error.message,
240+
});
241+
}
242+
};
243+
244+
const deleteScheduledPost = (req, res) => {
245+
const { jobId } = req.params;
246+
247+
if (!scheduledJobs.has(jobId)) {
248+
return res.status(404).json({
249+
success: false,
250+
message: 'Scheduled post not found',
251+
});
252+
}
253+
254+
const { job } = scheduledJobs.get(jobId);
255+
if (job) {
256+
job.cancel();
257+
}
258+
259+
scheduledJobs.delete(jobId);
260+
261+
return res.status(200).json({
262+
success: true,
263+
message: 'Scheduled post deleted successfully',
264+
});
265+
};
266+
267+
const updateScheduledPost = (req, res) => {
268+
const { jobId } = req.params;
269+
const { content, scheduleTime } = req.body;
270+
const { ORGANIZATION_URN: organizationUrn, LINKEDIN_ACCESS_TOKEN: accessToken } = process.env;
271+
272+
if (!scheduledJobs.has(jobId)) {
273+
return res.status(404).json({
274+
success: false,
275+
message: 'Scheduled post not found',
276+
});
277+
}
278+
279+
const previousJob = scheduledJobs.get(jobId);
280+
const { scheduledDateTime, error } = validateScheduleTime(
281+
scheduleTime || previousJob.scheduleTime,
282+
);
283+
if (error) {
284+
return res.status(400).json({
285+
success: false,
286+
message: error,
287+
});
288+
}
289+
290+
if (previousJob.job) {
291+
previousJob.job.cancel();
292+
}
293+
294+
const mediaFiles =
295+
req.files && req.files.length > 0
296+
? normalizeMediaFiles(req.files)
297+
: previousJob.mediaFiles || [];
298+
const nextContent = content || previousJob.content;
299+
const job = createScheduledJob(
300+
jobId,
301+
nextContent,
302+
scheduledDateTime,
303+
mediaFiles,
304+
organizationUrn,
305+
accessToken,
306+
scheduledJobs,
307+
);
308+
309+
scheduledJobs.set(jobId, {
310+
job,
311+
content: nextContent,
312+
scheduleTime: scheduledDateTime,
313+
mediaFiles,
314+
});
315+
316+
return res.status(200).json({
317+
success: true,
318+
message: 'Scheduled post updated successfully',
319+
});
320+
};
321+
322+
return {
323+
postToLinkedin,
324+
getScheduledPosts,
325+
deleteScheduledPost,
326+
updateScheduledPost,
327+
};
328+
};
329+
330+
module.exports = linkedinPostController;

0 commit comments

Comments
 (0)