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) {