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