Skip to content

Commit b631036

Browse files
giladresisiclaude
andcommitted
fix: require explicit opt-in to republish a published post
A schedule-type save targeting a PUBLISHED post silently requeues it: state back to QUEUE, releaseURL/releaseId nulled, workflow restarted - and since the workflow's initial sleep is max(0, publishDate - now), a save that carries (or leaves) a past date publishes again within seconds. When editing an existing published post the date field holds its old (past) date, so confirming a reschedule without touching the date is an accidental instant republish. The existing protection (2813ed0) is a frontend-only modal: it doesn't cover the public API or MCP agents at all, and even in the dashboard it never states that republishing can mean publishing right now. - changeDate now defaults action to 'update': clients that don't send an action can no longer requeue a post by accident - schedule/now saves (createPost) and action 'schedule' (changeDate) targeting a PUBLISHED post are rejected with a 400 explaining what happened and how to proceed, unless the new optional republish flag is sent - the error body is the confirmation dialog for automation - the dashboard modals send republish: true on explicit confirmation and now state the consequence (channel, date, recurring note) - new modal strings added to the locale files via lingo.dev No behavior change for DRAFT/QUEUE posts. Testing: 16 service-layer assertions against a stubbed repository + local Temporal (400 + message for schedule/now saves, rejection before any write, republish passthrough requeues and restarts the workflow, update stays date-only, QUEUE posts unaffected), plus a live end-to-end run in the dashboard: modal confirm -> republish: true -> requeue -> real publish to an Instagram channel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e7ad24a commit b631036

21 files changed

Lines changed: 225 additions & 25 deletions

File tree

apps/backend/src/api/routes/posts.controller.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -273,9 +273,12 @@ export class PostsController {
273273
@GetOrgFromRequest() org: Organization,
274274
@Param('id') id: string,
275275
@Body('date') date: string,
276-
@Body('action') action: 'schedule' | 'update' = 'schedule'
276+
// 'update' is the safe default: clients that don't send an action must
277+
// never requeue (and thereby republish) a post by accident
278+
@Body('action') action: 'schedule' | 'update' = 'update',
279+
@Body('republish') republish = false
277280
) {
278-
return this._postsService.changeDate(org.id, id, date, action);
281+
return this._postsService.changeDate(org.id, id, date, action, republish);
279282
}
280283

281284
@Post('/separate-posts')

apps/frontend/src/components/launches/calendar.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -679,8 +679,18 @@ export const CalendarColumn: FC<{
679679
<div className="flex flex-col">
680680
<div className="text-[20px] mb-[20px]">
681681
{t(
682-
'post_already_published_drag',
683-
'This post was already published, what do you want to do?'
682+
'post_already_published_republish_warning',
683+
'This post was already published. Republishing will publish it again to'
684+
)}{' '}
685+
{post.integration?.name}{' '}
686+
{t('republish_at', 'at')} {getDate.format('DD/MM/YYYY HH:mm')}.
687+
{(!!item.interval || !!post.intervalInDays) && (
688+
<div className="mt-[10px]">
689+
{t(
690+
'republish_recurring_note',
691+
'This is a recurring post: your changes apply to all future recurrences starting now.'
692+
)}
693+
</div>
684694
)}
685695
</div>
686696
<div className="flex w-full gap-[10px]">
@@ -730,6 +740,9 @@ export const CalendarColumn: FC<{
730740
body: JSON.stringify({
731741
date: getDate.utc().format('YYYY-MM-DDTHH:mm:ss'),
732742
action,
743+
// published posts always confirm via the modal before reaching here;
744+
// for QUEUE posts the flag is a no-op on the server
745+
...(action === 'schedule' ? { republish: true } : {}),
733746
}),
734747
});
735748
if (status !== 500) {

apps/frontend/src/components/new-launch/manage.modal.tsx

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,19 +192,39 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
192192

193193
const schedule = useCallback(
194194
(type: 'draft' | 'now' | 'schedule' | 'update') => async () => {
195+
let republish = false;
195196
if (
196197
(type === 'now' || type === 'schedule') &&
197198
(existingData?.posts?.[0]?.state === 'PUBLISHED' ||
198199
(existingData?.posts?.[0]?.state === 'QUEUE' &&
199200
dayjs().isAfter(date.utc())))
200201
) {
202+
const channels = selectedIntegrations
203+
.map((p) => p.integration.name)
204+
.join(', ');
205+
const isRecurring =
206+
!!repeater || !!existingData?.posts?.[0]?.intervalInDays;
207+
201208
const whatToDo = await new Promise((resolve) => {
202209
modal.openModal({
203-
title: 'What do you want to do?',
210+
title: t('what_do_you_want_to_do', 'What do you want to do?'),
204211
children: (
205212
<div className="flex flex-col">
206213
<div className="text-[20px] mb-[20px]">
207-
This post was already published, what do you want to do?
214+
{t(
215+
'post_already_published_republish_warning',
216+
'This post was already published. Republishing will publish it again to'
217+
)}{' '}
218+
{channels} {t('republish_at', 'at')}{' '}
219+
{date.format('DD/MM/YYYY HH:mm')}.
220+
{isRecurring && (
221+
<div className="mt-[10px]">
222+
{t(
223+
'republish_recurring_note',
224+
'This is a recurring post: your changes apply to all future recurrences starting now.'
225+
)}
226+
</div>
227+
)}
208228
</div>
209229
<div className="flex w-full gap-[10px]">
210230
<div className="flex-1 flex">
@@ -213,7 +233,10 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
213233
className="flex-1"
214234
onClick={() => resolve('update')}
215235
>
216-
Just update the post details
236+
{t(
237+
'just_update_post_details',
238+
'Just update the post details'
239+
)}
217240
</Button>
218241
</div>
219242
<div className="flex-1 flex">
@@ -222,7 +245,7 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
222245
className="flex-1"
223246
onClick={() => resolve('republish')}
224247
>
225-
Republish the post
248+
{t('republish_the_post', 'Republish the post')}
226249
</Button>
227250
</div>
228251
</div>
@@ -234,6 +257,10 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
234257
if (whatToDo === 'update') {
235258
type = 'update';
236259
}
260+
261+
if (whatToDo === 'republish') {
262+
republish = true;
263+
}
237264
}
238265

239266
setLoading(true);
@@ -385,6 +412,7 @@ export const ManageModal: FC<AddEditModalProps> = (props) => {
385412

386413
const data = {
387414
type,
415+
...(republish ? { republish } : {}),
388416
...(repeater ? { inter: repeater } : {}),
389417
tags,
390418
shortLink,

i18n.lock

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -735,3 +735,10 @@ checksums:
735735
cancel_coupon_title: 6a02a23e494728ddb694f591be65900d
736736
cancel_coupon_failed: 7bbb5c2646ba919b7df0879225eba918
737737
cancel_coupon_success: ded654a3b6c5fdfe7bdd23e9b5ad711c
738+
what_do_you_want_to_do: c2c352197ed13cee73ed85f9706b35de
739+
just_update_post_details: fb0d9cba2fe76d06571fc30dc8db0f13
740+
reschedule_post: 103d1943e23d4f71b22439a3b03885dd
741+
republish_the_post: 6c16f02f8d70ced4cc6ccc1112968704
742+
post_already_published_republish_warning: b552a207c31d6381fb831da005dac9ed
743+
republish_at: 282cf74a2c63ff46388104f9853bfaf8
744+
republish_recurring_note: e0eafbc730f5e8d5f9f5cb5aab48fe2d

libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,13 +873,46 @@ export class PostsService {
873873
return '';
874874
}
875875

876+
// A schedule-type save targeting an already-PUBLISHED post republishes it to
877+
// the platform: require the explicit `republish` opt-in instead. The message
878+
// doubles as the confirmation dialog for API/MCP automation.
879+
private guardAgainstRepublish(
880+
post: { state: State; publishDate: Date; integration?: { providerIdentifier: string } } | null,
881+
source: 'createPost' | 'changeDate'
882+
) {
883+
if (post?.state !== 'PUBLISHED') {
884+
return;
885+
}
886+
887+
const howToUpdate =
888+
source === 'createPost' ? `use type 'update'` : `use action 'update'`;
889+
890+
throw new BadRequestException(
891+
`This post was already published on ${dayjs
892+
.utc(post.publishDate)
893+
.format('YYYY-MM-DD HH:mm')} UTC. Saving it this way would publish it again to ${
894+
post.integration?.providerIdentifier || 'the channel'
895+
}. To edit without republishing, ${howToUpdate}. To intentionally publish again, pass republish: true.`
896+
);
897+
}
898+
876899
async createPost(
877900
orgId: string,
878901
body: CreatePostDto,
879902
creationMethod: CreationMethod
880903
): Promise<any[]> {
881904
const postList = [];
882905
for (const post of body.posts) {
906+
if (
907+
(body.type === 'schedule' || body.type === 'now') &&
908+
!body.republish &&
909+
post.value?.[0]?.id
910+
) {
911+
this.guardAgainstRepublish(
912+
await this._postRepository.getPostById(post.value[0].id, orgId),
913+
'createPost'
914+
);
915+
}
883916
const provider = this._integrationManager.getSocialIntegration(
884917
(post.settings as any)?.__type
885918
);
@@ -970,10 +1003,15 @@ export class PostsService {
9701003
orgId: string,
9711004
id: string,
9721005
date: string,
973-
action: 'schedule' | 'update' = 'schedule'
1006+
action: 'schedule' | 'update' = 'schedule',
1007+
republish = false
9741008
) {
9751009
const getPostById = await this._postRepository.getPostById(id, orgId);
9761010

1011+
if (action === 'schedule' && !republish) {
1012+
this.guardAgainstRepublish(getPostById, 'changeDate');
1013+
}
1014+
9771015
// schedule: Set status to QUEUE and change date (reschedule the post)
9781016
// update: Just change the date without changing the status
9791017
const newDate = await this._postRepository.changeDate(

libraries/nestjs-libraries/src/dtos/posts/create.post.dto.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,12 @@ export class CreatePostDto {
107107
@IsNumber()
108108
inter?: number;
109109

110+
// explicit opt-in to publish an already-PUBLISHED post again; without it a
111+
// schedule/now save targeting a published post is rejected
112+
@IsOptional()
113+
@IsBoolean()
114+
republish?: boolean;
115+
110116
@IsDefined()
111117
@IsDateString()
112118
date: string;

libraries/react-shared-libraries/src/translation/locales/ar/translation.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,5 +730,12 @@
730730
"yes_cancel_coupon": "نعم، إلغاء القسيمة",
731731
"cancel_coupon_title": "إلغاء القسيمة؟",
732732
"cancel_coupon_failed": "تعذّر إلغاء القسيمة",
733-
"cancel_coupon_success": "تم إلغاء القسيمة"
733+
"cancel_coupon_success": "تم إلغاء القسيمة",
734+
"what_do_you_want_to_do": "ماذا تريد أن تفعل؟",
735+
"just_update_post_details": "فقط قم بتحديث تفاصيل المنشور",
736+
"reschedule_post": "إعادة جدولة المنشور",
737+
"republish_the_post": "إعادة نشر المنشور",
738+
"post_already_published_republish_warning": "تم نشر هذا المنشور بالفعل. إعادة النشر ستقوم بنشره مرة أخرى على",
739+
"republish_at": "في",
740+
"republish_recurring_note": "هذا منشور متكرر: ستنطبق تغييراتك على جميع التكرارات المستقبلية بدءًا من الآن."
734741
}

libraries/react-shared-libraries/src/translation/locales/bn/translation.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,5 +730,12 @@
730730
"yes_cancel_coupon": "হ্যাঁ, কুপন বাতিল করুন",
731731
"cancel_coupon_title": "কুপন বাতিল করবেন?",
732732
"cancel_coupon_failed": "কুপন বাতিল করা যায়নি",
733-
"cancel_coupon_success": "কুপন বাতিল হয়েছে"
733+
"cancel_coupon_success": "কুপন বাতিল হয়েছে",
734+
"what_do_you_want_to_do": "আপনি কী করতে চান?",
735+
"just_update_post_details": "শুধুমাত্র পোস্টের বিবরণ আপডেট করুন",
736+
"reschedule_post": "পোস্ট পুনঃনির্ধারণ করুন",
737+
"republish_the_post": "পোস্টটি পুনঃপ্রকাশ করুন",
738+
"post_already_published_republish_warning": "এই পোস্টটি ইতিমধ্যে প্রকাশিত হয়েছে। পুনঃপ্রকাশ করলে এটি আবার প্রকাশিত হবে",
739+
"republish_at": "সময়",
740+
"republish_recurring_note": "এটি একটি পুনরাবৃত্তিমূলক পোস্ট: আপনার পরিবর্তনসমূহ এখন থেকে শুরু করে সকল ভবিষ্যৎ পুনরাবৃত্তিতে প্রয়োগ হবে।"
734741
}

libraries/react-shared-libraries/src/translation/locales/de/translation.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -730,5 +730,12 @@
730730
"yes_cancel_coupon": "Ja, Gutschein stornieren",
731731
"cancel_coupon_title": "Gutschein stornieren?",
732732
"cancel_coupon_failed": "Der Gutschein konnte nicht storniert werden",
733-
"cancel_coupon_success": "Gutschein storniert"
733+
"cancel_coupon_success": "Gutschein storniert",
734+
"what_do_you_want_to_do": "Was möchten Sie tun?",
735+
"just_update_post_details": "Nur die Post-Details aktualisieren",
736+
"reschedule_post": "Beitrag neu terminieren",
737+
"republish_the_post": "Beitrag erneut veröffentlichen",
738+
"post_already_published_republish_warning": "Dieser Beitrag wurde bereits veröffentlicht. Durch erneutes Veröffentlichen wird er erneut veröffentlicht auf",
739+
"republish_at": "um",
740+
"republish_recurring_note": "Dies ist ein wiederkehrender Beitrag: Ihre Änderungen gelten ab jetzt für alle zukünftigen Wiederholungen."
734741
}

libraries/react-shared-libraries/src/translation/locales/en/translation.json

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -732,5 +732,12 @@
732732
"yes_cancel_coupon": "Yes, cancel coupon",
733733
"cancel_coupon_title": "Cancel Coupon?",
734734
"cancel_coupon_failed": "Could not cancel the coupon",
735-
"cancel_coupon_success": "Coupon cancelled"
736-
}
735+
"cancel_coupon_success": "Coupon cancelled",
736+
"what_do_you_want_to_do": "What do you want to do?",
737+
"just_update_post_details": "Just update the post details",
738+
"reschedule_post": "Reschedule the post",
739+
"republish_the_post": "Republish the post",
740+
"post_already_published_republish_warning": "This post was already published. Republishing will publish it again to",
741+
"republish_at": "at",
742+
"republish_recurring_note": "This is a recurring post: your changes apply to all future recurrences starting now."
743+
}

0 commit comments

Comments
 (0)