From ec0301cb67a06a4f139b647a97341d021c3d2237 Mon Sep 17 00:00:00 2001 From: Ido Shamun <1993245+idoshamun@users.noreply.github.com> Date: Fri, 3 Jul 2026 13:43:46 +0300 Subject: [PATCH] feat(posts): support scheduling all post types --- __tests__/posts.ts | 173 +++++++++++++++++++++++++++--- __tests__/workers/cdc/primary.ts | 64 +++++++++++ __tests__/workers/postUpdated.ts | 92 +++++++++++++++- src/common/post.ts | 25 ++--- src/common/twitterSocial.ts | 44 ++++++-- src/entity/posts/utils.ts | 22 +++- src/schema/posts.ts | 63 +++++++++-- src/workers/cdc/primary.ts | 30 +++++- src/workers/postUpdated/shared.ts | 24 +++-- 9 files changed, 480 insertions(+), 57 deletions(-) diff --git a/__tests__/posts.ts b/__tests__/posts.ts index 407bbc20f7..41f007118e 100644 --- a/__tests__/posts.ts +++ b/__tests__/posts.ts @@ -57,6 +57,7 @@ import { DataSource, DeepPartial, In, IsNull, Not } from 'typeorm'; import createOrGetConnection from '../src/db'; import { createSquadWelcomePost, + defaultImage, DEFAULT_POST_TITLE, notifyContentRequested, notifyView, @@ -2851,10 +2852,23 @@ describe('mutation reportPost', () => { describe('mutation sharePost', () => { const MUTATION = /* GraphQL */ ` - mutation SharePost($sourceId: ID!, $id: ID!, $commentary: String) { - sharePost(sourceId: $sourceId, id: $id, commentary: $commentary) { + mutation SharePost( + $sourceId: ID! + $id: ID! + $commentary: String + $scheduledAt: DateTime + ) { + sharePost( + sourceId: $sourceId + id: $id + commentary: $commentary + scheduledAt: $scheduledAt + ) { id titleHtml + flags { + scheduledAt + } } } `; @@ -2929,6 +2943,28 @@ describe('mutation sharePost', () => { expect(post.title).toEqual('My comment'); }); + it('should schedule shared post', async () => { + loggedUser = '1'; + const scheduledAt = new Date(Date.now() + 60_000).toISOString(); + const res = await client.mutate(MUTATION, { + variables: { ...variables, scheduledAt }, + }); + expect(res.errors).toBeFalsy(); + expect(res.data.sharePost.flags.scheduledAt).toEqual(scheduledAt); + + const post = await con + .getRepository(SharePost) + .findOneByOrFail({ id: res.data.sharePost.id }); + expect(post).toMatchObject({ + authorId: '1', + sharedPostId: 'p1', + title: 'My comment', + visible: false, + visibleAt: null, + }); + expect(post.flags.scheduledAt).toEqual(scheduledAt); + }); + it('should create invisible share and still return mutation result', async () => { loggedUser = '1'; const res = await client.mutate(MUTATION, { @@ -3627,7 +3663,7 @@ describe('mutation createPostInMultipleSources', () => { it('should throw error when content exceeds maximum length', async () => { loggedUser = '1'; - const longContent = 'a'.repeat(10001); // exceeds 10000 character limit + const longContent = 'a'.repeat(20_001); return testMutationErrorCode( client, { @@ -4367,13 +4403,13 @@ describe('post creation', () => { }: { mutation: string; variables: Record; - assertData: (data: TData) => void; + assertData: (data: TData) => void | Promise; }): Promise => { loggedUser = '1'; const res = await client.mutate(mutation, { variables }); expect(res.errors).toBeFalsy(); - assertData(res.data as TData); + await assertData(res.data as TData); }; it('should allow createFreeformPost for incomplete profile', async () => { @@ -4470,6 +4506,61 @@ describe('post creation', () => { }); }); + it('should schedule createPollPost for incomplete profile', async () => { + await setupWritableSource('s-scheduled-poll'); + const scheduledAt = new Date(Date.now() + 60_000).toISOString(); + + await expectSuccessfulPostCreation<{ + createPollPost: { id: string; flags: { scheduledAt: string } }; + }>({ + mutation: ` + mutation CreatePollPost( + $sourceId: ID! + $title: String! + $options: [PollOptionInput!]! + $duration: Int + $scheduledAt: DateTime + ) { + createPollPost( + sourceId: $sourceId + title: $title + options: $options + duration: $duration + scheduledAt: $scheduledAt + ) { + id + flags { + scheduledAt + } + } + } + `, + variables: { + sourceId: 's-scheduled-poll', + title: 'Scheduled poll title', + duration: 3, + scheduledAt, + options: [ + { text: 'Option 1', order: 0 }, + { text: 'Option 2', order: 1 }, + ], + }, + assertData: async (data) => { + expect(data.createPollPost.flags.scheduledAt).toEqual(scheduledAt); + + const post = await con + .getRepository(PollPost) + .findOneByOrFail({ id: data.createPollPost.id }); + expect(post.visible).toBe(false); + expect(post.visibleAt).toBeNull(); + expect(post.flags.scheduledAt).toEqual(scheduledAt); + expect(post.endsAt?.toISOString()).toEqual( + addDays(new Date(scheduledAt), 3).toISOString(), + ); + }, + }); + }); + it('should allow createPostInMultipleSources for incomplete profile', async () => { await con.getRepository(SourceMember).save({ userId: '1', @@ -4517,6 +4608,7 @@ describe('mutation submitExternalLink', () => { $commentary: String $title: String $image: String + $scheduledAt: DateTime ) { submitExternalLink( sourceId: $sourceId @@ -4524,6 +4616,7 @@ describe('mutation submitExternalLink', () => { commentary: $commentary title: $title image: $image + scheduledAt: $scheduledAt ) { _ } @@ -4631,6 +4724,32 @@ describe('mutation submitExternalLink', () => { await checkSharedPostExpectation(true); }); + it('should schedule external link share', async () => { + loggedUser = '1'; + const scheduledAt = new Date(Date.now() + 60_000).toISOString(); + const url = 'https://scheduled.daily.dev'; + const res = await client.mutate(MUTATION, { + variables: { + ...variables, + url, + title: 'Scheduled external link title', + scheduledAt, + }, + }); + expect(res.errors).toBeFalsy(); + + const articlePost = await con + .getRepository(ArticlePost) + .findOneByOrFail({ url }); + const sharedPost = await con + .getRepository(SharePost) + .findOneByOrFail({ sharedPostId: articlePost.id }); + + expect(sharedPost.visible).toBe(false); + expect(sharedPost.visibleAt).toBeNull(); + expect(sharedPost.flags.scheduledAt).toEqual(scheduledAt); + }); + it('should share existing post to squad', async () => { loggedUser = '1'; const res = await client.mutate(MUTATION, { @@ -5375,9 +5494,7 @@ describe('mutation checkLinkPreview', () => { expect(res.errors).toBeFalsy(); expect(res.data.checkLinkPreview.title).toEqual(sampleResponse.title); - expect(res.data.checkLinkPreview.image).toEqual( - pickImageUrl({ createdAt: new Date() }), - ); + expect(defaultImage.urls).toContain(res.data.checkLinkPreview.image); expect(res.data.checkLinkPreview.id).toBeFalsy(); }); @@ -5611,11 +5728,27 @@ describe('mutation createFreeformPost', () => { it('should list scheduled posts', async () => { loggedUser = '1'; const scheduledAt = new Date(Date.now() + 60_000).toISOString(); + const shareScheduledAt = new Date(Date.now() + 120_000).toISOString(); const createRes = await client.mutate(MUTATION, { variables: { ...params, scheduledAt }, }); expect(createRes.errors).toBeFalsy(); + await con.getRepository(SharePost).save({ + id: 'sched-share', + shortId: 'sched-share', + authorId: '1', + sourceId: 'a', + sharedPostId: 'p1', + title: 'Scheduled share', + type: PostType.Share, + visible: false, + visibleAt: null, + flags: { + visible: false, + scheduledAt: shareScheduledAt, + }, + }); const res = await client.query(/* GraphQL */ ` query ScheduledPosts { @@ -5624,6 +5757,7 @@ describe('mutation createFreeformPost', () => { node { id title + type flags { scheduledAt } @@ -5639,11 +5773,22 @@ describe('mutation createFreeformPost', () => { node: { id: createRes.data.createFreeformPost.id, title: params.title, + type: PostType.Freeform, flags: { scheduledAt, }, }, }, + { + node: { + id: 'sched-share', + title: 'Scheduled share', + type: PostType.Share, + flags: { + scheduledAt: shareScheduledAt, + }, + }, + }, ]); }); @@ -5716,11 +5861,11 @@ describe('mutation createFreeformPost', () => { ); }); - it('should return an error if content exceeds 10000 characters', async () => { + it('should return an error if content exceeds 20000 characters', async () => { loggedUser = '1'; - const content = 'Hello World! Start your squad journey here'; // 42 chars - const sample = new Array(240).fill(content); // 42*240 = 10_080 + const content = 'Hello World! Start your squad journey here'; + const sample = new Array(480).fill(content); return testMutationErrorCode( client, @@ -7167,11 +7312,11 @@ describe('mutation editPost', () => { ); }); - it('should return an error if content exceeds 10000 characters', async () => { + it('should return an error if content exceeds 20000 characters', async () => { loggedUser = '1'; - const content = 'Hello World! Start your squad journey here'; // 42 chars - const sample = new Array(240).fill(content); // 42*240 = 10_080 + const content = 'Hello World! Start your squad journey here'; + const sample = new Array(480).fill(content); return testMutationErrorCode( client, diff --git a/__tests__/workers/cdc/primary.ts b/__tests__/workers/cdc/primary.ts index 0ae4b713c3..1216d8cb45 100644 --- a/__tests__/workers/cdc/primary.ts +++ b/__tests__/workers/cdc/primary.ts @@ -8118,6 +8118,7 @@ describe('poll post', () => { type: PostType.Poll, authorId: pollAuthorId, createdAt, + visible: true, }, op: 'c', table: 'post', @@ -8134,6 +8135,69 @@ describe('poll post', () => { expect(userAchievement!.unlockedAt).not.toBeNull(); }); + it('should not unlock the poll achievement on scheduled poll creation', async () => { + const pollId = randomUUID(); + const createdAt = new Date('2021-09-22T07:15:51.247Z').getTime() * 1000; + + await expectSuccessfulBackground( + worker, + mockChangeMessage({ + after: { + id: pollId, + type: PostType.Poll, + authorId: pollAuthorId, + createdAt, + visible: false, + }, + op: 'c', + table: 'post', + }), + ); + + const userAchievement = await con.getRepository(UserAchievement).findOneBy({ + achievementId: pollAchievementId, + userId: pollAuthorId, + }); + + expect(userAchievement).toBeNull(); + }); + + it('should unlock the poll achievement when scheduled poll becomes visible', async () => { + const pollId = randomUUID(); + const createdAt = new Date('2021-09-22T07:15:51.247Z').getTime() * 1000; + + await expectSuccessfulBackground( + worker, + mockChangeMessage({ + before: { + id: pollId, + type: PostType.Poll, + authorId: pollAuthorId, + createdAt, + visible: false, + }, + after: { + id: pollId, + type: PostType.Poll, + authorId: pollAuthorId, + createdAt, + visible: true, + }, + op: 'u', + table: 'post', + }), + ); + + const userAchievement = await con.getRepository(UserAchievement).findOneBy({ + achievementId: pollAchievementId, + userId: pollAuthorId, + }); + + expect(userAchievement).not.toBeNull(); + expect(userAchievement!.progress).toEqual(1); + expect(userAchievement!.unlockedAt).not.toBeNull(); + }); + it('should cancel entity reminder workflow when poll is deleted', async () => { const pollId = randomUUID(); const createdAt = new Date('2021-09-22T07:15:51.247Z').getTime() * 1000; // Convert to debezium microseconds diff --git a/__tests__/workers/postUpdated.ts b/__tests__/workers/postUpdated.ts index 900c5fbb8b..133fdb7068 100644 --- a/__tests__/workers/postUpdated.ts +++ b/__tests__/workers/postUpdated.ts @@ -43,6 +43,7 @@ import { generateShortId } from '../../src/ids'; import contentPublishedChannelsFixture from '../fixture/contentPublishedChannels.json'; import twitterSocialThreadPayloadFixture from '../fixture/twitterSocialThreadPayload.json'; import { remoteConfig } from '../../src/remoteConfig'; +import { getScheduledPostFlags } from '../../src/common/postScheduling'; jest.mock('../../src/common/googleCloud', () => ({ ...(jest.requireActual('../../src/common/googleCloud') as Record< @@ -141,15 +142,16 @@ const createDefaultQuestions = async (postId: string) => { await repo.save(DEFAULT_QUESTIONS.map((question) => ({ postId, question }))); }; -const createSharedPost = async (id = 'sp1') => { +const createSharedPost = async (id = 'sp1', args: Partial = {}) => { const post = await con.getRepository(Post).findOneBy({ id: 'p1' }); await con.getRepository(SharePost).save({ ...post, + ...args, id, shortId: `short-${id}`, sharedPostId: 'p1', type: PostType.Share, - visible: false, + visible: args.visible ?? false, }); }; @@ -358,6 +360,38 @@ it('should update all the post related shared posts to visible', async () => { expect(sharedPost2?.visibleAt).toEqual(new Date('2023-01-05T12:00:00.000Z')); }); +it('should not update scheduled shared posts to visible', async () => { + const scheduledAt = new Date('2023-01-06T12:00:00.000Z'); + await createSharedPost(); + await createSharedPost('sp2', { + flags: getScheduledPostFlags(scheduledAt), + }); + + await expectSuccessfulBackground(worker, { + id: 'f99a445f-e2fb-48e8-959c-e02a17f5e816', + post_id: 'p1', + updated_at: new Date('01-05-2023 12:00:00'), + title: 'test', + }); + + const sharedPost = await con + .getRepository(SharePost) + .findOneBy({ id: 'sp1' }); + expect(sharedPost?.visible).toEqual(true); + expect(sharedPost?.flags.visible).toEqual(true); + expect(sharedPost?.visibleAt).toEqual(new Date('2023-01-05T12:00:00.000Z')); + + const scheduledSharedPost = await con + .getRepository(SharePost) + .findOneBy({ id: 'sp2' }); + expect(scheduledSharedPost?.visible).toEqual(false); + expect(scheduledSharedPost?.visibleAt).toBeNull(); + expect(scheduledSharedPost?.flags).toMatchObject({ + scheduledAt: scheduledAt.toISOString(), + visible: false, + }); +}); + it('should update post and not modify keywords', async () => { await createDefaultUser(); await createDefaultKeywords(); @@ -923,6 +957,60 @@ it('should reuse existing referenced social post for quote when status id alread }); }); +it('should preserve scheduled visibility when reusing referenced social post', async () => { + const yggdrasilId = randomUUID(); + const scheduledAt = new Date('2023-01-06T12:00:00.000Z'); + const referencedPostId = 'twref2003'; + + await con.getRepository(SocialTwitterPost).save({ + id: referencedPostId, + shortId: referencedPostId, + type: PostType.SocialTwitter, + url: 'https://x.com/devrelweekly/status/2003', + canonicalUrl: 'https://x.com/devrelweekly/status/2003', + title: null, + sourceId: UNKNOWN_SOURCE, + visible: false, + visibleAt: null, + flags: { + ...getScheduledPostFlags(scheduledAt), + private: true, + }, + }); + + await expectSuccessfulBackground(worker, { + id: yggdrasilId, + content_type: PostType.SocialTwitter, + url: 'https://x.com/dailydotdev/status/10023', + source_id: 'a', + extra: { + sub_type: 'quote', + content: 'Main quote tweet #3', + reference: { + url: 'https://x.com/devrelweekly/status/2003', + content: 'Referenced quoted tweet', + author_username: 'devrelweekly', + }, + }, + }); + + const referencedPost = await con.getRepository(Post).findOneByOrFail({ + id: referencedPostId, + }); + + expect(referencedPost).toMatchObject({ + visible: false, + visibleAt: null, + flags: { + scheduledAt: scheduledAt.toISOString(), + visible: false, + private: true, + sentAnalyticsReport: true, + showOnFeed: false, + }, + }); +}); + it('should create referenced social post for repost and set sharedPostId', async () => { const yggdrasilId = randomUUID(); diff --git a/src/common/post.ts b/src/common/post.ts index e3bf95c88b..e53a02c35e 100644 --- a/src/common/post.ts +++ b/src/common/post.ts @@ -300,6 +300,7 @@ interface CreatePollPostArgs { authorId: string; duration?: number | null; pollOptions: CreatePollOption[]; + scheduledAt?: Date | null; }; } @@ -308,24 +309,27 @@ export const createPollPost = async ({ ctx, args, }: CreatePollPostArgs) => { - const { pollOptions, ...restArgs } = args; + const { pollOptions, scheduledAt: rawScheduledAt, ...restArgs } = args; + const scheduledAt = validatePostScheduledAt(rawScheduledAt); const { private: privacy } = await con.getRepository(Source).findOneByOrFail({ id: restArgs.sourceId, type: In([SourceType.Squad, SourceType.User]), }); + const startsAt = scheduledAt ?? new Date(); const createdPost = con.getRepository(PollPost).create({ ...restArgs, shortId: restArgs.id, - endsAt: restArgs?.duration ? addDays(new Date(), restArgs.duration) : null, - visible: true, + endsAt: restArgs?.duration ? addDays(startsAt, restArgs.duration) : null, + visible: !scheduledAt, private: privacy, - visibleAt: new Date(), + visibleAt: scheduledAt ? null : new Date(), origin: PostOrigin.UserGenerated, contentCuration: ['poll'], flags: { - visible: true, + visible: !scheduledAt, private: privacy, + ...(scheduledAt ? getScheduledPostFlags(scheduledAt) : {}), }, }); @@ -611,7 +615,7 @@ export interface PollOptionInput { export interface CreatePollPostProps extends Pick< CreatePostArgs, - 'title' | 'sourceId' + 'title' | 'sourceId' | 'scheduledAt' > { options: PollOptionInput[]; duration: number; @@ -619,7 +623,7 @@ export interface CreatePollPostProps extends Pick< export interface CreateMultipleSourcePostProps extends - Omit, + Omit, Pick { sharedPostId?: string; externalLink?: string; @@ -642,7 +646,6 @@ export const postInMultipleSourcesArgsSchema = z commentary: z.string().max(MAX_TITLE_LENGTH).optional(), image: z.custom>(), imageUrl: z.httpUrl().optional(), - scheduledAt: z.coerce.date().nullish(), sourceIds: z.array(z.string()).min(1).max(MAX_MULTIPLE_POST_SOURCE_LIMIT), sharedPostId: z.string().optional(), externalLink: z.httpUrl().optional(), @@ -710,12 +713,6 @@ export const createPostIntoSourceId = async ( ): Promise> => { const type = getMultipleSourcesPostType(args); - if (args.scheduledAt && type !== PostType.Freeform) { - throw new ValidationError( - 'Scheduling is only supported for freeform posts', - ); - } - switch (type) { case PostType.Share: { await ctx.con diff --git a/src/common/twitterSocial.ts b/src/common/twitterSocial.ts index 6a60da736c..07c9fff750 100644 --- a/src/common/twitterSocial.ts +++ b/src/common/twitterSocial.ts @@ -6,12 +6,14 @@ import { Post, PostOrigin, PostType } from '../entity/posts/Post'; import { SocialTwitterPost } from '../entity/posts/SocialTwitterPost'; import { generateTitleHtml } from '../entity/posts/utils'; import { markdown } from './markdown'; +import { getPostScheduledAt } from './postScheduling'; import { type TwitterSocialMedia, type TwitterSocialPayload, type TwitterSocialSubType, twitterSocialPayloadSchema, } from './schema/socialTwitter'; +import { updateFlagsStatement } from './utils'; export interface TwitterMappedPostFields { type: PostType.SocialTwitter; @@ -488,10 +490,13 @@ export const upsertTwitterReferencedPost = async ({ .createQueryBuilder() .from(Post, 'post') .select('post.id', 'id') + .addSelect('post.flags', 'flags') + .addSelect('post.visible', 'visible') + .addSelect('post.visibleAt', 'visibleAt') .where('post.url = :url OR post."canonicalUrl" = :url', { url: referenceUrl, }) - .getRawOne<{ id: string }>(); + .getRawOne>(); if (existingPost?.id) { const fields = await buildReferencePostFields({ @@ -499,7 +504,20 @@ export const upsertTwitterReferencedPost = async ({ reference, language, }); - await entityManager.getRepository(Post).update(existingPost.id, fields); + const scheduledAt = getPostScheduledAt(existingPost); + await entityManager.getRepository(Post).update(existingPost.id, { + ...fields, + ...(scheduledAt + ? { + visible: existingPost.visible, + visibleAt: existingPost.visibleAt, + flags: updateFlagsStatement({ + ...fields.flags, + visible: existingPost.visible, + }), + } + : {}), + }); return existingPost.id; } @@ -509,10 +527,13 @@ export const upsertTwitterReferencedPost = async ({ .createQueryBuilder() .from(Post, 'post') .select('post.id', 'id') + .addSelect('post.flags', 'flags') + .addSelect('post.visible', 'visible') + .addSelect('post.visibleAt', 'visibleAt') .where('post.url ~ :statusRegex OR post."canonicalUrl" ~ :statusRegex', { statusRegex, }) - .getRawOne<{ id: string }>(); + .getRawOne>(); if (existingByStatusId?.id) { const fields = await buildReferencePostFields({ @@ -520,9 +541,20 @@ export const upsertTwitterReferencedPost = async ({ reference, language, }); - await entityManager - .getRepository(Post) - .update(existingByStatusId.id, fields); + const scheduledAt = getPostScheduledAt(existingByStatusId); + await entityManager.getRepository(Post).update(existingByStatusId.id, { + ...fields, + ...(scheduledAt + ? { + visible: existingByStatusId.visible, + visibleAt: existingByStatusId.visibleAt, + flags: updateFlagsStatement({ + ...fields.flags, + visible: existingByStatusId.visible, + }), + } + : {}), + }); return existingByStatusId.id; } } diff --git a/src/entity/posts/utils.ts b/src/entity/posts/utils.ts index 629317b6b3..b8db8adaf5 100644 --- a/src/entity/posts/utils.ts +++ b/src/entity/posts/utils.ts @@ -46,6 +46,10 @@ import { ContentEmbedReferenceType, } from '../ContentEmbed'; import { Comment } from '../Comment'; +import { + getScheduledPostFlags, + validatePostScheduledAt, +} from '../../common/postScheduling'; export type PostStats = { numPosts: number; @@ -309,6 +313,7 @@ export interface ExternalLink extends Partial { export interface SubmitExternalLinkArgs extends ExternalLink { sourceId: string; commentary: string; + scheduledAt?: Date | null; } interface CreateExternalLinkArgs { @@ -324,6 +329,7 @@ interface CreateExternalLinkArgs { authorId: string; sourceId?: string; originalUrl: string; + scheduledAt?: Date | null; }; } @@ -384,6 +390,7 @@ export const createExternalLink = async ({ sourceId, commentary, originalUrl, + scheduledAt, } = args; validateCommentary(commentary!); const isVisible = !!title; @@ -435,6 +442,7 @@ export const createExternalLink = async ({ sourceId, commentary, visible: isVisible, + scheduledAt, }, }); } @@ -456,6 +464,7 @@ export interface SharePostArgs { commentary?: string | null; visible?: boolean; title?: string; + scheduledAt?: Date | null; } interface CreateSharePostArgs { @@ -477,9 +486,17 @@ export const determineSharedPostId = (post: Post | SharePost): string => { export const createSharePost = async ({ con, ctx, - args: { authorId: userId, sourceId, postId, commentary, visible = true }, + args: { + authorId: userId, + sourceId, + postId, + commentary, + visible = true, + scheduledAt: rawScheduledAt, + }, }: CreateSharePostArgs): Promise => { const strippedCommentary = await validateCommentary(commentary!); + const scheduledAt = validatePostScheduledAt(rawScheduledAt); try { const mentions = await getMentions(con, commentary!, userId, sourceId); @@ -501,7 +518,7 @@ export const createSharePost = async ({ originalPost.url && originalPost.yggdrasilId, ); - const isVisible = isBrokenOriginalPost ? false : visible; + const isVisible = !scheduledAt && !isBrokenOriginalPost && visible; const id = await generateShortId(); @@ -523,6 +540,7 @@ export const createSharePost = async ({ sentAnalyticsReport: true, private: privacy, visible: isVisible, + ...(scheduledAt ? getScheduledPostFlags(scheduledAt) : {}), }, type: PostType.Share, } as DeepPartial); diff --git a/src/schema/posts.ts b/src/schema/posts.ts index 4e2f8c9657..bde67a7376 100644 --- a/src/schema/posts.ts +++ b/src/schema/posts.ts @@ -1473,10 +1473,6 @@ export const typeDefs = /* GraphQL */ ` """ imageUrl: String """ - Time the post should go live - """ - scheduledAt: DateTime - """ ID of the post to share """ sharedPostId: ID @@ -1764,6 +1760,10 @@ export const typeDefs = /* GraphQL */ ` Commentary for the share """ commentary: String + """ + Time the post should go live + """ + scheduledAt: DateTime ): EmptyResponse @auth @rateLimit(limit: 1, duration: 30) """ @@ -1782,6 +1782,10 @@ export const typeDefs = /* GraphQL */ ` Source to share the post to """ sourceId: ID! + """ + Time the post should go live + """ + scheduledAt: DateTime ): Post @auth @rateLimit(limit: 1, duration: 30) """ @@ -1936,6 +1940,10 @@ export const typeDefs = /* GraphQL */ ` Duration in days """ duration: Int + """ + Time the post should go live + """ + scheduledAt: DateTime ): Post! @auth @rateLimit(limit: 1, duration: 30) } @@ -2613,9 +2621,6 @@ export const resolvers: IResolvers = { }) .andWhere(`${builder.alias}.visible = false`) .andWhere(`${builder.alias}.flags->>'scheduledAt' IS NOT NULL`) - .andWhere(`${builder.alias}.type = :type`, { - type: PostType.Freeform, - }) .orderBy(`${builder.alias}.flags->>'scheduledAt'`, 'ASC') .addOrderBy(`${builder.alias}.id`, 'ASC') .limit(page.limit) @@ -3113,7 +3118,14 @@ export const resolvers: IResolvers = { }, submitExternalLink: async ( _, - { sourceId, commentary, url, title, image }: SubmitExternalLinkArgs, + { + sourceId, + commentary, + url, + title, + image, + scheduledAt, + }: SubmitExternalLinkArgs, ctx: AuthContext, ): Promise => { if (!isValidHttpUrl(url)) { @@ -3150,6 +3162,7 @@ export const resolvers: IResolvers = { postId: existingPost.id, commentary, visible: existingPost.visible, + scheduledAt, }, }); return { _: true }; @@ -3168,6 +3181,7 @@ export const resolvers: IResolvers = { image, commentary, originalUrl: url, + scheduledAt, }, }); }); @@ -3179,7 +3193,13 @@ export const resolvers: IResolvers = { id, commentary, sourceId, - }: { id: string; commentary: string; sourceId: string }, + scheduledAt, + }: { + id: string; + commentary: string; + sourceId: string; + scheduledAt?: Date | null; + }, ctx: AuthContext, info, ): Promise => { @@ -3220,11 +3240,20 @@ export const resolvers: IResolvers = { postId: sharedPostId, commentary, visible: post.visible, + scheduledAt, }, }); if (!newPost.visible) { - return newPost as unknown as GQLPost; + return withInvisiblePosts(ctx, (graphormCtx) => + graphorm.queryOneOrFail(graphormCtx, info, (builder) => ({ + ...builder, + queryBuilder: builder.queryBuilder.where( + `"${builder.alias}"."id" = :id`, + { id: newPost.id }, + ), + })), + ); } return getPostById(ctx, info, newPost.id); @@ -3587,6 +3616,7 @@ export const resolvers: IResolvers = { sourceId: args.sourceId, duration: args.duration, authorId: ctx.userId, + scheduledAt: args.scheduledAt, pollOptions: args.options.map((option) => ctx.con.getRepository(PollOption).create({ text: option.text, @@ -3598,7 +3628,18 @@ export const resolvers: IResolvers = { }, }); - return getPostById(ctx, info, savedPost.id); + return withInvisiblePosts( + ctx, + (graphormCtx) => + graphorm.queryOneOrFail(graphormCtx, info, (builder) => ({ + ...builder, + queryBuilder: builder.queryBuilder.where( + `"${builder.alias}"."id" = :id`, + { id: savedPost.id }, + ), + })), + !!args.scheduledAt, + ); }, votePoll: async ( _, diff --git a/src/workers/cdc/primary.ts b/src/workers/cdc/primary.ts index 778a0922c4..11681b483e 100644 --- a/src/workers/cdc/primary.ts +++ b/src/workers/cdc/primary.ts @@ -1167,7 +1167,7 @@ const onPostChange = async ( const after = poll.payload.after!; const endsAt = after.endsAt as number | undefined; - if (after.authorId) { + if (after.authorId && after.visible) { await checkAchievementProgress( con, logger, @@ -1205,6 +1205,34 @@ const onPostChange = async ( AchievementEventType.PostFreeform, ); } + if ( + data.payload.after!.type === PostType.Share && + data.payload.after!.authorId + ) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.authorId, + AchievementEventType.PostShare, + ); + await checkQuestProgress({ + con, + logger, + userId: data.payload.after!.authorId, + eventType: QuestEventType.PostShare, + }); + } + if ( + data.payload.after!.type === PostType.Poll && + data.payload.after!.authorId + ) { + await checkAchievementProgress( + con, + logger, + data.payload.after!.authorId, + AchievementEventType.PollCreate, + ); + } } else { // Trigger message only if the post is already visible and the conte was edited const freeform = data as ChangeMessage; diff --git a/src/workers/postUpdated/shared.ts b/src/workers/postUpdated/shared.ts index 1f745d3185..d68970a63d 100644 --- a/src/workers/postUpdated/shared.ts +++ b/src/workers/postUpdated/shared.ts @@ -26,6 +26,7 @@ import { } from '../../errors'; import { generateShortId } from '../../ids'; import { updateFlagsStatement } from '../../common'; +import { getPostScheduledAt } from '../../common/postScheduling'; import { counters } from '../../telemetry'; import { BriefPost } from '../../entity/posts/BriefPost'; import { DigestPost } from '../../entity/posts/DigestPost'; @@ -257,12 +258,17 @@ export const updatePost = async ({ data.id = databasePost.id; data.sourceId = data.sourceId || databasePost.sourceId; + const isScheduledPost = !!getPostScheduledAt(databasePost); const updateBecameVisible = - !databasePost.visible && getPostVisible({ post: { title } }); + !isScheduledPost && + !databasePost.visible && + getPostVisible({ post: { title } }); data.visible = updateBecameVisible || databasePost.visible; - if (data.visible && !databasePost.visibleAt) { + if (isScheduledPost) { + data.visibleAt = databasePost.visibleAt; + } else if (data.visible && !databasePost.visibleAt) { data.visibleAt = data.metadataChangedAt; } @@ -332,9 +338,11 @@ export const updatePost = async ({ ); if (updateBecameVisible) { - await entityManager.getRepository(SharePost).update( - { sharedPostId: data.id }, - { + await entityManager + .getRepository(SharePost) + .createQueryBuilder() + .update() + .set({ visible: true, visibleAt: data.visibleAt, private: data.private, @@ -343,8 +351,10 @@ export const updatePost = async ({ private: data.private, visible: true, }), - }, - ); + }) + .where('"sharedPostId" = :sharedPostId', { sharedPostId: data.id }) + .andWhere(`flags->>'scheduledAt' IS NULL`) + .execute(); } if (databasePost.tagsStr !== data.tagsStr) {