From 21ec5293407ee81bf2813dff481f86e658981171 Mon Sep 17 00:00:00 2001 From: Gilad Resisi Date: Wed, 8 Jul 2026 16:43:41 +0700 Subject: [PATCH] feat(agent): let the agent list and update scheduled posts Adds two agent/MCP tools so an agent (web-app chat or an external MCP client) can change already-scheduled posts, not just create them: - postsListTool: lists the user's upcoming (not-yet-published) posts - both drafts and scheduled posts whose publish time is still in the future. By default returns all of them in one call, so "list all my unpublished posts" needs no arguments. Past posts (including old forgotten drafts) are never listed. Optional "state" narrows to only future drafts or only future scheduled. Filterable by platform/channel, paginated, each item carries its state (draft/scheduled) and current provider settings so the agent can pick what to change. Backed by a new getAgentPostsList repository + service method. - updatePostTool: updates one post by its stable id - settings are merged (only the passed keys change), date and content optional. Refuses published/past posts, runs the same server-side validation as the dashboard, preserves the root post id (so the Temporal workflow identity is kept) and only restarts the workflow when the publish date actually changed on a queued post. Agent prompt guidance: changes to existing posts are always applied directly with updatePostTool; the agent must not open the "populated modal" (manualPosting) for an existing post since that only creates a new post (would duplicate). No delete tool exists (deletion is destructive); the prompt tells the agent never to offer deletion and, if asked, to defer it to the user in the Postiz app. External MCP agents can't delete either (no tool), so they infer it from the toolset. keepGroup: out-of-band updates keep the post's group id stable instead of rotating it (removed comments are swept by id instead). The group rotation on every edit would otherwise invalidate an open calendar that still holds the old group. The dashboard keeps its rotate-and-sweep behavior unchanged. Guard (defense-in-depth for the same stale-group race, which also predates this via multi-device dashboard edits/deletes): - getPostsByGroup throws 404 instead of a 500 when the group has no posts. - calendar editPost detects an empty group, shows a toast and reloads the calendar instead of crashing on posts[0]. Tested end-to-end, both the internal agent and an external MCP client: - Internal agent (web-app chat): "list my posts" returns the upcoming posts; asking to reschedule or change a setting is applied directly via updatePostTool - no delete offered, and no populated modal opened for an existing post. - postsListTool: default (all upcoming), platform and integrationId filters, draft/scheduled states, and pagination across pages (verified page/total/hasMore and no overlap with the page size temporarily lowered). - updatePostTool happy paths: a settings-only update leaves the Temporal workflow untouched (verified against the Temporal run list); a real date change restarts it (TERMINATE_EXISTING, exactly one new run); the post id and group stay stable with no duplicate; content grow/shrink and bulk multi-post updates apply correctly. - updatePostTool guards, each confirmed to reject and leave the post unchanged: a published post, a comment's id (must pass the root post), a non-existent id, and content that fails validation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/launches/calendar.tsx | 17 +- .../src/chat/load.tools.service.ts | 7 +- .../src/chat/tools/posts.list.tool.ts | 123 +++++++ .../src/chat/tools/tool.list.ts | 4 + .../src/chat/tools/update.post.tool.ts | 310 ++++++++++++++++++ .../database/prisma/posts/posts.repository.ts | 106 +++++- .../database/prisma/posts/posts.service.ts | 27 +- 7 files changed, 587 insertions(+), 7 deletions(-) create mode 100644 libraries/nestjs-libraries/src/chat/tools/posts.list.tool.ts create mode 100644 libraries/nestjs-libraries/src/chat/tools/update.post.tool.ts diff --git a/apps/frontend/src/components/launches/calendar.tsx b/apps/frontend/src/components/launches/calendar.tsx index 8bb1c633af..e8cf62a7f0 100644 --- a/apps/frontend/src/components/launches/calendar.tsx +++ b/apps/frontend/src/components/launches/calendar.tsx @@ -114,6 +114,21 @@ const usePostActions = (onMutate?: () => void) => { }; const data = await (await fetch(`/posts/group/${post.group}`)).json(); + + // The group rotates on every edit - if the post was edited elsewhere + // (another tab, the agent) this calendar entry is stale. + if (!data?.posts?.length) { + toaster.show( + t( + 'post_changed_elsewhere', + 'This post was changed or deleted, refreshing the calendar' + ), + 'warning' + ); + mutate(); + return; + } + const date = !isDuplicate ? null : (await (await fetch('/posts/find-slot')).json()).date; @@ -170,7 +185,7 @@ const usePostActions = (onMutate?: () => void) => { title: ``, }); }, - [integrations, fetch, modal, mutate] + [integrations, fetch, modal, mutate, toaster, t] ); const copyDebugJson = useCallback( diff --git a/libraries/nestjs-libraries/src/chat/load.tools.service.ts b/libraries/nestjs-libraries/src/chat/load.tools.service.ts index d252b52e52..19927f7f06 100644 --- a/libraries/nestjs-libraries/src/chat/load.tools.service.ts +++ b/libraries/nestjs-libraries/src/chat/load.tools.service.ts @@ -54,6 +54,8 @@ export class LoadToolsService { You are an agent that helps manage and schedule social media posts for users, you can: - Schedule posts into the future, or now, adding texts, images and videos + - List upcoming scheduled posts and drafts (postsListTool) + - Update scheduled posts that were not published yet: settings, date and content (updatePostTool) - Generate pictures for posts - Generate videos for posts - Generate text for posts @@ -76,12 +78,15 @@ export class LoadToolsService { - In every message I will send you the list of needed social medias (id and platform), if you already have the information use it, if not, use the integrationSchema tool to get it. - Make sure you always take the last information I give you about the socials, it might have changed. - Before scheduling a post, always make sure you ask the user confirmation by providing all the details of the post (text, images, videos, date, time, social media platform, account). + - When the user asks to change existing posts (for example "update all my future tiktok posts"), use postsListTool to find the matching upcoming posts (it is paginated - keep fetching while hasMore is true), show the user which posts you are about to change and what will change, and after they confirm run updatePostTool once per post. + - Changes to an EXISTING post (reschedule, edit text, change settings) must always be applied directly with updatePostTool - never open the "modal with populated content" for an existing post. That modal only creates a NEW post, so using it to edit would duplicate the post instead of changing it. If the user explicitly wants to edit a post in a visual modal, tell them to open that post in the Postiz calendar, because you can only open the modal for brand new posts. + - You can create, list and update posts, but you CANNOT delete posts - there is no delete capability. Never offer to delete a post. If the user asks you to delete a post, tell them you are not able to delete posts because deletion is a destructive action, and they should delete it themselves in the Postiz app (the calendar). You can still offer to update the post instead. - Between tools, we will reference things like: [output:name] and [input:name] to set the information right. - When outputting a date for the user, make sure it's human readable with time - The content of the post, HTML, Each line must be wrapped in

here is the possible tags: h1, h2, h3, u, strong, li, ul, p (you can\'t have u and strong together), don't use a "code" box ${renderArray( [ - 'If the user confirm, ask if they would like to get a modal with populated content without scheduling the post yet or if they want to schedule it right away.', + 'When scheduling a NEW post, if the user confirms, ask whether they would like a modal with the populated content (without scheduling yet) or to schedule it right away. This modal is ONLY for brand new posts - never offer it for changes to an existing post; apply those directly with updatePostTool.', ], !!ui )} diff --git a/libraries/nestjs-libraries/src/chat/tools/posts.list.tool.ts b/libraries/nestjs-libraries/src/chat/tools/posts.list.tool.ts new file mode 100644 index 0000000000..0619c366e6 --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/tools/posts.list.tool.ts @@ -0,0 +1,123 @@ +import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface'; +import { createTool } from '@mastra/core/tools'; +import { z } from 'zod'; +import { Injectable } from '@nestjs/common'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; +import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); + +const parseSettings = (settings: string | null) => { + try { + return JSON.parse(settings || '{}'); + } catch (err) { + return {}; + } +}; + +@Injectable() +export class PostsListTool implements AgentToolInterface { + constructor(private _postsService: PostsService) {} + name = 'postsListTool'; + + run() { + return createTool({ + id: 'postsListTool', + mcp: { + annotations: { + title: 'List Upcoming Posts', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + }, + }, + description: ` +List the organization's upcoming (not-yet-published) posts - both drafts and scheduled posts whose publish time is still in the future. By default returns all of them, so "list all my unpublished posts" is a single call with no arguments. +Only future posts are returned; past posts (including old forgotten drafts) are never listed. +Optionally narrow with "state" to only drafts or only scheduled posts. +Each item has a "state" (draft or scheduled) and its current provider settings, so you can tell which posts need a change. +Use this to find the posts the user wants to inspect or update - pass the returned "id" to updatePostTool. +Results are paginated (20 per page), use "page" and "hasMore" to fetch all of them. +`, + inputSchema: z.object({ + platform: z + .string() + .optional() + .describe( + 'Filter by platform identifier, for example: tiktok, linkedin, x' + ), + integrationId: z + .string() + .optional() + .describe('Filter by a specific integration (channel) id'), + state: z + .enum(['scheduled', 'draft']) + .optional() + .describe( + 'Omit to get all upcoming posts (future drafts + future scheduled). "scheduled" = only future scheduled posts. "draft" = only future drafts. Past posts are never returned.' + ), + page: z.number().optional().describe('Page number, starting at 0'), + }), + outputSchema: z.object({ + output: z.object({ + posts: z.array( + z.object({ + id: z + .string() + .describe('Pass this to updatePostTool to update the post'), + publishDate: z.string().describe('UTC time'), + state: z.enum(['draft', 'scheduled']), + platform: z.string(), + integrationId: z.string(), + integrationName: z.string(), + content: z.string(), + settings: z.any(), + }) + ), + total: z.number(), + page: z.number(), + hasMore: z.boolean(), + }), + }), + execute: async (inputData, context) => { + checkAuth(inputData, context); + const organizationId = JSON.parse( + (context?.requestContext as any)?.get('organization') as string + ).id; + + const list = await this._postsService.getAgentPostsList( + organizationId, + { + integrationId: inputData.integrationId, + platform: inputData.platform, + state: inputData.state, + page: inputData.page, + } + ); + + return { + output: { + posts: list.posts.map((p) => ({ + id: p.id, + publishDate: dayjs(p.publishDate) + .utc() + .format('YYYY-MM-DDTHH:mm:ss'), + state: p.state === 'DRAFT' ? 'draft' : 'scheduled', + platform: p.integration.providerIdentifier, + integrationId: p.integration.id, + integrationName: p.integration.name, + content: p.content || '', + settings: parseSettings(p.settings), + })), + total: list.total, + page: list.page, + hasMore: list.hasMore, + }, + }; + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts index b188daee47..787219c7bd 100644 --- a/libraries/nestjs-libraries/src/chat/tools/tool.list.ts +++ b/libraries/nestjs-libraries/src/chat/tools/tool.list.ts @@ -8,6 +8,8 @@ import { GenerateImageTool } from '@gitroom/nestjs-libraries/chat/tools/generate import { IntegrationListTool } from '@gitroom/nestjs-libraries/chat/tools/integration.list.tool'; import { GroupListTool } from '@gitroom/nestjs-libraries/chat/tools/group.list.tool'; import { UploadFromUrlTool } from '@gitroom/nestjs-libraries/chat/tools/upload.from.url.tool'; +import { PostsListTool } from '@gitroom/nestjs-libraries/chat/tools/posts.list.tool'; +import { UpdatePostTool } from '@gitroom/nestjs-libraries/chat/tools/update.post.tool'; export const toolList = [ IntegrationListTool, @@ -15,6 +17,8 @@ export const toolList = [ IntegrationValidationTool, IntegrationTriggerTool, IntegrationSchedulePostTool, + PostsListTool, + UpdatePostTool, GenerateVideoOptionsTool, VideoFunctionTool, GenerateVideoTool, diff --git a/libraries/nestjs-libraries/src/chat/tools/update.post.tool.ts b/libraries/nestjs-libraries/src/chat/tools/update.post.tool.ts new file mode 100644 index 0000000000..a86e3bba4c --- /dev/null +++ b/libraries/nestjs-libraries/src/chat/tools/update.post.tool.ts @@ -0,0 +1,310 @@ +import { AgentToolInterface } from '@gitroom/nestjs-libraries/chat/agent.tool.interface'; +import { createTool } from '@mastra/core/tools'; +import { z } from 'zod'; +import { Injectable } from '@nestjs/common'; +import { PostsService } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.service'; +import { makeId } from '@gitroom/nestjs-libraries/services/make.is'; +import { AllProvidersSettings } from '@gitroom/nestjs-libraries/dtos/posts/providers-settings/all.providers.settings'; +import { checkAuth } from '@gitroom/nestjs-libraries/chat/auth.context'; +import { buildSettings } from '@gitroom/nestjs-libraries/chat/tools/integration.schedule.post'; +import { + ValidUrlExtension, + ValidUrlPath, +} from '@gitroom/helpers/utils/valid.url.path'; +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; + +dayjs.extend(utc); + +const validUrlExtension = new ValidUrlExtension(); +const validUrlPath = new ValidUrlPath(); + +// Same URL validation as MediaDto (valid.url.path) - each attachment must +// point to an allowed upload domain and a supported file extension. +const attachmentUrl = z + .string() + .refine((url) => validUrlPath.validate(url, {} as any), { + message: validUrlPath.defaultMessage({} as any), + }) + .refine((url) => validUrlExtension.validate(url, {} as any), { + message: validUrlExtension.defaultMessage({} as any), + }); + +@Injectable() +export class UpdatePostTool implements AgentToolInterface { + constructor(private _postsService: PostsService) {} + name = 'updatePostTool'; + + run() { + return createTool({ + id: 'updatePostTool', + mcp: { + annotations: { + title: 'Update Scheduled Post', + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + description: ` +Update an existing scheduled post (or draft) that was NOT published yet - its publish time hasn't arrived. +Find the post with postsListTool first and pass its "id" here. +You can change the settings (merged into the existing ones - only pass the keys you want to change), +the publish date, and/or the content. Anything you don't pass stays as it is. +To update multiple posts, call this tool once per post. +If the tool returns errors, rerun it with the right parameters, don't ask again, just run it. +`, + inputSchema: z.object({ + id: z + .string() + .describe('The "id" of the post, taken from postsListTool'), + date: z + .string() + .optional() + .describe( + 'New date for the post in UTC time, only if the user wants to change it' + ), + settings: z + .array( + z.object({ + key: z.string().describe('Name of the settings key to change'), + value: z + .any() + .describe( + 'New value of the key, always prefer the id then label if possible' + ), + }) + ) + .optional() + .describe( + 'Settings keys to change, merged into the existing settings. This relies on the integrationSchema tool [input:settings]' + ), + postsAndComments: z + .array( + z.object({ + content: z + .string() + .describe( + "The content of the post, HTML, Each line must be wrapped in

here is the possible tags: h1, h2, h3, u, strong, li, ul, p (you can't have u and strong together)" + ), + attachments: z + .array(attachmentUrl) + .describe('The image of the post (URLS)'), + }) + ) + .optional() + .describe( + 'Only to replace the content: first item is the post, every other item is the comments. Omit to keep the existing content' + ), + }), + outputSchema: z.object({ + output: z + .object({ + postId: z.string(), + publishDate: z.string(), + }) + .or(z.object({ errors: z.string() })), + }), + execute: async (inputData, context) => { + checkAuth(inputData, context); + const organizationId = JSON.parse( + (context?.requestContext as any)?.get('organization') as string + ).id; + + // Ordered as post -> comments, root includes integration and tags. + const ordered = await this._postsService.getPostsRecursively( + inputData.id, + true, + organizationId, + true + ); + + const [root] = ordered; + if (!root) { + return { output: { errors: 'Post not found' } }; + } + + if (root.parentPostId) { + return { + output: { + errors: + 'This id belongs to a comment, pass the id of the main post', + }, + }; + } + + if (root.state !== 'QUEUE' && root.state !== 'DRAFT') { + return { + output: { + errors: + 'Only scheduled posts that were not published yet (or drafts) can be updated', + }, + }; + } + + if ( + root.state === 'QUEUE' && + dayjs.utc(root.publishDate).isBefore(dayjs.utc()) + ) { + return { + output: { + errors: + 'The publish time of this post already passed, it cannot be updated', + }, + }; + } + + if (inputData.date && dayjs.utc(inputData.date).isBefore(dayjs.utc())) { + return { + output: { errors: 'The new date must be in the future (UTC)' }, + }; + } + + const integration = (root as any).integration; + + let existingSettings: AllProvidersSettings; + try { + existingSettings = JSON.parse(root.settings || '{}'); + } catch (err) { + existingSettings = {} as AllProvidersSettings; + } + + const settings = buildSettings( + integration.providerIdentifier, + inputData.settings || [], + { + ...existingSettings, + __type: integration.providerIdentifier, + } as AllProvidersSettings + ); + + // Keep the existing post ids (by position) so the posts are updated in + // place and the running publish workflow is restarted, not duplicated. + const value = inputData.postsAndComments?.length + ? inputData.postsAndComments.map( + (p: { content: string; attachments: string[] }, index: number) => ({ + id: ordered[index]?.id, + content: p.content, + delay: ordered[index]?.delay || 0, + image: (p.attachments || []).map((path: string) => ({ + id: makeId(10), + path, + })), + }) + ) + : ordered.map((p) => { + let image = []; + try { + image = JSON.parse(p.image || '[]'); + } catch (err) {} + return { + id: p.id, + content: p.content, + delay: p.delay || 0, + image, + }; + }); + + // Same server-side validation as the dashboard / schedule tool. + const [validation] = await this._postsService.validatePosts( + organizationId, + [ + { + integration: { id: integration.id }, + settings, + value: value.map((p: { content: string; image: any[] }) => ({ + content: p.content, + image: p.image, + })), + }, + ] + ); + + if (validation.emptyContent) { + return { + output: { + errors: `${validation.name}: Your post should have at least one character or one image.`, + }, + }; + } + + if (root.state !== 'DRAFT') { + if (!validation.valid) { + return { + output: { + errors: `${validation.name}: ${ + validation.settingsError || 'Please fix your settings' + }, please fix it, and try updatePostTool again.`, + }, + }; + } + + if (validation.errors !== true) { + return { + output: { + errors: `${validation.name}: ${validation.errors}, please fix it, and try updatePostTool again.`, + }, + }; + } + + if (validation.tooLong) { + return { + output: { + errors: `${validation.name}: The maximum characters is ${validation.maximumCharacters}, please fix it, and try updatePostTool again.`, + }, + }; + } + } + + const date = + inputData.date || + dayjs.utc(root.publishDate).format('YYYY-MM-DDTHH:mm:ss'); + + // The publish workflow reads settings and content from the DB at + // publish time, but the publish date only once at start - so restart + // it (type "schedule") only when the date actually changed. "update" + // keeps the current state and leaves the running workflow alone. + const dateChanged = + !!inputData.date && + !dayjs.utc(inputData.date).isSame(dayjs.utc(root.publishDate)); + + const [output] = await this._postsService.createPost( + organizationId, + { + date, + type: dateChanged && root.state === 'QUEUE' ? 'schedule' : 'update', + shortLink: false, + tags: ((root as any).tags || []).map((t: any) => ({ + value: t.tag.name, + label: t.tag.name, + })), + posts: [ + { + integration, + group: root.group, + settings, + value, + }, + ], + } as any, + 'MCP', + // Keep the group stable: the user may have the calendar open while + // the agent updates the post, and the calendar links posts by group. + true + ); + + if (!output) { + return { output: { errors: 'Failed to update the post' } }; + } + + return { + output: { + postId: output.postId, + publishDate: date, + }, + }; + }, + }); + } +} diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts index 2a3b2b2059..2f2eb21e93 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.repository.ts @@ -320,6 +320,83 @@ export class PostsRepository { }; } + // Used by the agent (chat / MCP) tools: the posts a user can still act on, + // with their settings, so the agent can identify and update them. + async getAgentPostsList( + orgId: string, + filter: { + integrationId?: string; + platform?: string; + state?: 'scheduled' | 'draft'; + page?: number; + } + ) { + const limit = 20; + const page = filter.page || 0; + const skip = page * limit; + + const now = dayjs.utc().toDate(); + // Only upcoming posts: both drafts and scheduled ("QUEUE") are limited to a + // publish date still in the future. Past drafts (often forgotten) are left + // out so this tool never surfaces stale clutter. Default = both states. + const stateFilter = + filter.state === 'draft' + ? { state: State.DRAFT } + : filter.state === 'scheduled' + ? { state: State.QUEUE } + : { state: { in: [State.DRAFT, State.QUEUE] } }; + + const where = { + organizationId: orgId, + deletedAt: null as Date | null, + parentPostId: null as string | null, + intervalInDays: null as number | null, + publishDate: { gte: now }, + ...stateFilter, + integration: { + deletedAt: null as any, + organizationId: orgId, + ...(filter.integrationId ? { id: filter.integrationId } : {}), + ...(filter.platform ? { providerIdentifier: filter.platform } : {}), + }, + }; + + const [posts, total] = await Promise.all([ + this._post.model.post.findMany({ + where, + skip, + take: limit, + orderBy: { + publishDate: 'asc', + }, + select: { + id: true, + content: true, + publishDate: true, + state: true, + group: true, + settings: true, + integration: { + select: { + id: true, + providerIdentifier: true, + name: true, + }, + }, + }, + }), + this._post.model.post.count({ where }), + ]); + + return { + posts, + total, + page, + limit, + hasMore: skip + posts.length < total, + }; + } + async deletePost(orgId: string, group: string) { await this._post.model.post.updateMany({ where: { @@ -517,10 +594,15 @@ export class PostsRepository { body: PostBody, tags: { value: string; label: string }[], creationMethod: CreationMethod, - inter?: number + inter?: number, + // Keep the existing group instead of rotating it, so open clients + // (calendar) holding the group stay valid. Used by out-of-band updates + // (agent / MCP); the dashboard keeps the rotate-and-sweep behavior. + keepGroup = false ) { const posts: Post[] = []; const uuid = uuidv4(); + const group = keepGroup && body.group ? body.group : uuid; for (const value of body.value) { const updateData = (type: 'create' | 'update') => ({ @@ -548,7 +630,7 @@ export class PostsRepository { : {}), content: value.content, delay: value.delay || 0, - group: uuid, + group, intervalInDays: inter ? +inter : null, approvedSubmitForOrder: APPROVED_SUBMIT_FOR_ORDER.NO, ...(type === 'create' ? { creationMethod } : {}), @@ -639,11 +721,29 @@ export class PostsRepository { )?.id! : undefined; - if (body.group) { + if (body.group && !keepGroup) { + await this._post.model.post.updateMany({ + where: { + group: body.group, + deletedAt: null, + }, + data: { + parentPostId: null, + deletedAt: new Date(), + }, + }); + } + + // Same sweep of rows dropped from the group (removed comments), but by id + // since the updated rows still carry the old group value. + if (body.group && keepGroup) { await this._post.model.post.updateMany({ where: { group: body.group, deletedAt: null, + id: { + notIn: posts.map((p) => p.id), + }, }, data: { parentPostId: null, diff --git a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts index 57f73373ec..0399a94bbd 100644 --- a/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts +++ b/libraries/nestjs-libraries/src/database/prisma/posts/posts.service.ts @@ -1,6 +1,7 @@ import { BadRequestException, Injectable, + NotFoundException, ValidationPipe, } from '@nestjs/common'; import { PostsRepository } from '@gitroom/nestjs-libraries/database/prisma/posts/posts.repository'; @@ -341,6 +342,18 @@ export class PostsService { ); } + async getAgentPostsList( + orgId: string, + filter: { + integrationId?: string; + platform?: string; + state?: 'scheduled' | 'draft'; + page?: number; + } + ) { + return this._postRepository.getAgentPostsList(orgId, filter); + } + async updateMedia(id: string, imagesList: any[], convertToJPEG = false) { try { let imageUpdateNeeded = false; @@ -490,6 +503,14 @@ export class PostsService { async getPostsByGroup(orgId: string, group: string) { const convertToJPEG = false; const loadAll = await this._postRepository.getPostsByGroup(orgId, group); + + // The group rotates on every edit, so a stale client (calendar open while + // the post was edited elsewhere, e.g. by the agent) can ask for a group + // that no longer has posts. + if (!loadAll.length) { + throw new NotFoundException('Post not found'); + } + const posts = this.arrangePostsByGroup(loadAll, undefined); return { @@ -876,7 +897,8 @@ export class PostsService { async createPost( orgId: string, body: CreatePostDto, - creationMethod: CreationMethod + creationMethod: CreationMethod, + keepGroup = false ): Promise { const postList = []; for (const post of body.posts) { @@ -907,7 +929,8 @@ export class PostsService { post, body.tags, creationMethod, - body.inter + body.inter, + keepGroup ); if (!posts?.length) {