Add the edit function that allows editing triggers and lets copilot edit triggers too without losing previous jobs

feat: Add update functionality for recurring and scheduled job rules

- Implemented update actions for recurring job rules and scheduled job rules, allowing users to modify existing rules with new input and scheduling configurations.
- Enhanced the UI components to support editing of job rules, including forms for both creating and updating rules.
- Updated the repository interfaces and MongoDB implementations to handle the new update operations for job rules.

This update improves the flexibility of managing job rules within the application.
This commit is contained in:
tusharmagar 2025-10-06 11:25:06 +08:00
parent d484170e32
commit e2c8d0490a
18 changed files with 1307 additions and 390 deletions

View file

@ -6,6 +6,7 @@ import { IListRecurringJobRulesController } from "@/src/interface-adapters/contr
import { IFetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller"; import { IFetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller";
import { IToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller"; import { IToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
import { IDeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller"; import { IDeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
import { IUpdateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/update-recurring-job-rule.controller";
import { authCheck } from "./auth.actions"; import { authCheck } from "./auth.actions";
import { z } from "zod"; import { z } from "zod";
import { Message } from "@/app/lib/types/types"; import { Message } from "@/app/lib/types/types";
@ -15,6 +16,7 @@ const listRecurringJobRulesController = container.resolve<IListRecurringJobRules
const fetchRecurringJobRuleController = container.resolve<IFetchRecurringJobRuleController>('fetchRecurringJobRuleController'); const fetchRecurringJobRuleController = container.resolve<IFetchRecurringJobRuleController>('fetchRecurringJobRuleController');
const toggleRecurringJobRuleController = container.resolve<IToggleRecurringJobRuleController>('toggleRecurringJobRuleController'); const toggleRecurringJobRuleController = container.resolve<IToggleRecurringJobRuleController>('toggleRecurringJobRuleController');
const deleteRecurringJobRuleController = container.resolve<IDeleteRecurringJobRuleController>('deleteRecurringJobRuleController'); const deleteRecurringJobRuleController = container.resolve<IDeleteRecurringJobRuleController>('deleteRecurringJobRuleController');
const updateRecurringJobRuleController = container.resolve<IUpdateRecurringJobRuleController>('updateRecurringJobRuleController');
export async function createRecurringJobRule(request: { export async function createRecurringJobRule(request: {
projectId: string, projectId: string,
@ -89,3 +91,23 @@ export async function deleteRecurringJobRule(request: {
ruleId: request.ruleId, ruleId: request.ruleId,
}); });
} }
export async function updateRecurringJobRule(request: {
projectId: string,
ruleId: string,
input: {
messages: z.infer<typeof Message>[],
},
cron: string,
}) {
const user = await authCheck();
return await updateRecurringJobRuleController.execute({
caller: 'user',
userId: user.id,
projectId: request.projectId,
ruleId: request.ruleId,
input: request.input,
cron: request.cron,
});
}

View file

@ -5,6 +5,7 @@ import { ICreateScheduledJobRuleController } from "@/src/interface-adapters/cont
import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller"; import { IListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { IFetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller"; import { IFetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller";
import { IDeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller"; import { IDeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller";
import { IUpdateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/update-scheduled-job-rule.controller";
import { authCheck } from "./auth.actions"; import { authCheck } from "./auth.actions";
import { z } from "zod"; import { z } from "zod";
import { Message } from "@/app/lib/types/types"; import { Message } from "@/app/lib/types/types";
@ -13,6 +14,7 @@ const createScheduledJobRuleController = container.resolve<ICreateScheduledJobRu
const listScheduledJobRulesController = container.resolve<IListScheduledJobRulesController>('listScheduledJobRulesController'); const listScheduledJobRulesController = container.resolve<IListScheduledJobRulesController>('listScheduledJobRulesController');
const fetchScheduledJobRuleController = container.resolve<IFetchScheduledJobRuleController>('fetchScheduledJobRuleController'); const fetchScheduledJobRuleController = container.resolve<IFetchScheduledJobRuleController>('fetchScheduledJobRuleController');
const deleteScheduledJobRuleController = container.resolve<IDeleteScheduledJobRuleController>('deleteScheduledJobRuleController'); const deleteScheduledJobRuleController = container.resolve<IDeleteScheduledJobRuleController>('deleteScheduledJobRuleController');
const updateScheduledJobRuleController = container.resolve<IUpdateScheduledJobRuleController>('updateScheduledJobRuleController');
export async function createScheduledJobRule(request: { export async function createScheduledJobRule(request: {
projectId: string, projectId: string,
@ -72,4 +74,24 @@ export async function deleteScheduledJobRule(request: {
projectId: request.projectId, projectId: request.projectId,
ruleId: request.ruleId, ruleId: request.ruleId,
}); });
} }
export async function updateScheduledJobRule(request: {
projectId: string,
ruleId: string,
input: {
messages: z.infer<typeof Message>[],
},
scheduledTime: string,
}) {
const user = await authCheck();
return await updateScheduledJobRuleController.execute({
caller: 'user',
userId: user.id,
projectId: request.projectId,
ruleId: request.ruleId,
input: request.input,
scheduledTime: request.scheduledTime,
});
}

View file

@ -222,10 +222,12 @@ function AssistantMessage({
// Remove autoApplyEnabled and useEffect for auto-apply // Remove autoApplyEnabled and useEffect for auto-apply
const triggersRef = useRef<CopilotTriggerType[] | undefined>(triggers); const triggersRef = useRef<CopilotTriggerType[] | undefined>(triggers);
const pendingTriggerEditsRef = useRef<Map<string, CopilotTriggerType>>(new Map());
const triggerUpdateCallbackRef = useRef<typeof onTriggersUpdated>(onTriggersUpdated); const triggerUpdateCallbackRef = useRef<typeof onTriggersUpdated>(onTriggersUpdated);
useEffect(() => { useEffect(() => {
triggersRef.current = triggers; triggersRef.current = triggers;
pendingTriggerEditsRef.current.clear();
}, [triggers]); }, [triggers]);
useEffect(() => { useEffect(() => {
@ -378,14 +380,46 @@ function AssistantMessage({
return false; return false;
}, [dispatch, workflow.agents, workflow.tools]); }, [dispatch, workflow.agents, workflow.tools]);
const handleTriggerAction = useCallback(async (action: any): Promise<boolean> => { const handleTriggerAction = useCallback(async (action: any, actionIndex?: number): Promise<boolean> => {
const configType = action.config_type; const configType = action.config_type;
const actionType = action.action; const actionType = action.action;
const triggerList = triggersRef.current ?? []; const triggerList = triggersRef.current ?? [];
const key = `${configType}:${action.name}`;
const hasUpcomingReplacement = () => parsed.some((part, idx) =>
idx > (actionIndex ?? -1) &&
part.type === 'action' &&
part.action.config_type === configType &&
part.action.name === action.name &&
part.action.action === 'create_new'
);
try { try {
if (configType === 'one_time_trigger') { if (configType === 'one_time_trigger') {
if (actionType === 'create_new') { if (actionType === 'create_new') {
const pending = pendingTriggerEditsRef.current.get(key);
if (pending && pending.type === 'one_time') {
const scheduledTime = action.config_changes?.scheduledTime ?? pending.nextRunAt;
const input = action.config_changes?.input ?? pending.input;
if (!scheduledTime || !input) {
console.error('Missing data for one-time trigger update via replacement', action);
return false;
}
const { updateScheduledJobRule } = await loadScheduledJobActions();
await updateScheduledJobRule({
projectId,
ruleId: pending.id,
scheduledTime,
input,
});
pendingTriggerEditsRef.current.delete(key);
return true;
}
const { scheduledTime, input } = action.config_changes || {}; const { scheduledTime, input } = action.config_changes || {};
if (!scheduledTime || !input) { if (!scheduledTime || !input) {
console.error('Missing scheduledTime or input for one-time trigger', action); console.error('Missing scheduledTime or input for one-time trigger', action);
@ -410,9 +444,20 @@ function AssistantMessage({
return false; return false;
} }
const { fetchScheduledJobRule, deleteScheduledJobRule, createScheduledJobRule } = await loadScheduledJobActions(); const {
fetchScheduledJobRule,
deleteScheduledJobRule,
updateScheduledJobRule,
} = await loadScheduledJobActions();
if (actionType === 'delete') { if (actionType === 'delete') {
if (hasUpcomingReplacement()) {
pendingTriggerEditsRef.current.set(key, target);
return true;
}
pendingTriggerEditsRef.current.delete(key);
await deleteScheduledJobRule({ projectId, ruleId: target.id }); await deleteScheduledJobRule({ projectId, ruleId: target.id });
return true; return true;
} }
@ -432,27 +477,63 @@ function AssistantMessage({
return false; return false;
} }
const created = await createScheduledJobRule({ await updateScheduledJobRule({
projectId, projectId,
ruleId: target.id,
scheduledTime, scheduledTime,
input, input,
}); });
// Remove the previous rule only after successfully creating the updated one return true;
await deleteScheduledJobRule({ projectId, ruleId: target.id });
return Boolean(created?.id);
} }
} }
if (configType === 'recurring_trigger') { if (configType === 'recurring_trigger') {
if (actionType === 'create_new') { if (actionType === 'create_new') {
const pending = pendingTriggerEditsRef.current.get(key);
const {
createRecurringJobRule,
updateRecurringJobRule,
toggleRecurringJobRule,
} = await loadRecurringJobActions();
if (pending && pending.type === 'recurring') {
const cron = action.config_changes?.cron ?? pending.cron;
const input = action.config_changes?.input ?? pending.input;
if (!cron || !input) {
console.error('Missing data for recurring trigger update via replacement', action);
return false;
}
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: pending.id,
cron,
input,
});
const hasDisabledToggle = Object.prototype.hasOwnProperty.call(action.config_changes ?? {}, 'disabled');
if (hasDisabledToggle) {
const desiredDisabled = typeof action.config_changes?.disabled === 'boolean'
? action.config_changes.disabled
: pending.disabled;
if (typeof desiredDisabled === 'boolean' && desiredDisabled !== pending.disabled) {
await toggleRecurringJobRule({ ruleId: pending.id, disabled: desiredDisabled });
}
}
pendingTriggerEditsRef.current.delete(key);
return Boolean(updatedRule?.id);
}
const { cron, input } = action.config_changes || {}; const { cron, input } = action.config_changes || {};
if (!cron || !input) { if (!cron || !input) {
console.error('Missing cron or input for recurring trigger', action); console.error('Missing cron or input for recurring trigger', action);
return false; return false;
} }
const { createRecurringJobRule } = await loadRecurringJobActions();
await createRecurringJobRule({ await createRecurringJobRule({
projectId, projectId,
cron, cron,
@ -474,11 +555,18 @@ function AssistantMessage({
const { const {
fetchRecurringJobRule, fetchRecurringJobRule,
deleteRecurringJobRule, deleteRecurringJobRule,
createRecurringJobRule,
toggleRecurringJobRule, toggleRecurringJobRule,
updateRecurringJobRule,
} = await loadRecurringJobActions(); } = await loadRecurringJobActions();
if (actionType === 'delete') { if (actionType === 'delete') {
if (hasUpcomingReplacement()) {
pendingTriggerEditsRef.current.set(key, target);
return true;
}
pendingTriggerEditsRef.current.delete(key);
await deleteRecurringJobRule({ projectId, ruleId: target.id }); await deleteRecurringJobRule({ projectId, ruleId: target.id });
return true; return true;
} }
@ -513,16 +601,15 @@ function AssistantMessage({
return false; return false;
} }
const created = await createRecurringJobRule({ const updatedRule = await updateRecurringJobRule({
projectId, projectId,
ruleId: target.id,
cron, cron,
input, input,
}); });
await deleteRecurringJobRule({ projectId, ruleId: target.id }); if (hasDisabledToggle && desiredDisabled !== updatedRule.disabled) {
await toggleRecurringJobRule({ ruleId: target.id, disabled: desiredDisabled });
if (desiredDisabled !== created.disabled) {
await toggleRecurringJobRule({ ruleId: created.id, disabled: desiredDisabled });
} }
return true; return true;
@ -559,7 +646,7 @@ function AssistantMessage({
console.warn('Unhandled trigger action from Copilot applyAction', action); console.warn('Unhandled trigger action from Copilot applyAction', action);
return false; return false;
}, [projectId]); }, [projectId, parsed]);
const refreshTriggers = useCallback(async () => { const refreshTriggers = useCallback(async () => {
const callback = triggerUpdateCallbackRef.current; const callback = triggerUpdateCallbackRef.current;
@ -589,7 +676,7 @@ function AssistantMessage({
try { try {
const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger'; const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger';
const success = isTrigger const success = isTrigger
? await handleTriggerAction(action) ? await handleTriggerAction(action, actionIndex)
: applyAction(action); : applyAction(action);
if (success) { if (success) {
@ -625,7 +712,7 @@ function AssistantMessage({
try { try {
const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger'; const isTrigger = action.config_type === 'one_time_trigger' || action.config_type === 'recurring_trigger' || action.config_type === 'external_trigger';
const success = isTrigger const success = isTrigger
? await handleTriggerAction(action) ? await handleTriggerAction(action, actionIndex)
: applyAction(action); : applyAction(action);
if (success) { if (success) {

View file

@ -1,12 +1,15 @@
'use client'; 'use client';
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
import { createRecurringJobRule } from "@/app/actions/recurring-job-rules.actions"; import { createRecurringJobRule, updateRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { ArrowLeftIcon, PlusIcon, TrashIcon, InfoIcon } from "lucide-react"; import { ArrowLeftIcon, PlusIcon, TrashIcon, InfoIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
// Define a simpler message type for the form that only includes the fields we need // Define a simpler message type for the form that only includes the fields we need
type FormMessage = { type FormMessage = {
@ -14,6 +17,29 @@ type FormMessage = {
content: string; content: string;
}; };
type BackButtonConfig =
| { label: string; onClick: () => void }
| { label: string; href: string };
type FormSubmitPayload = {
messages: FormMessage[];
cron: string;
};
type RecurringJobRuleFormBaseProps = {
title: string;
description?: string;
submitLabel: string;
submittingLabel: string;
errorMessage: string;
backButton?: BackButtonConfig;
initialCron?: string;
initialMessages?: FormMessage[];
onSubmit: (payload: FormSubmitPayload) => Promise<unknown>;
onSuccess?: (result: unknown) => void;
successHref?: string;
};
const commonCronExamples = [ const commonCronExamples = [
{ label: "Every minute", value: "* * * * *" }, { label: "Every minute", value: "* * * * *" },
{ label: "Every 5 minutes", value: "*/5 * * * *" }, { label: "Every 5 minutes", value: "*/5 * * * *" },
@ -25,86 +51,112 @@ const commonCronExamples = [
{ label: "Monthly on the 1st at midnight", value: "0 0 1 * *" }, { label: "Monthly on the 1st at midnight", value: "0 0 1 * *" },
]; ];
export function CreateRecurringJobRuleForm({ const createEmptyMessage = (): FormMessage => ({ role: "user", content: "" });
projectId,
onBack, const normaliseMessages = (messages?: FormMessage[]): FormMessage[] => {
hasExistingTriggers = true if (!messages || messages.length === 0) {
}: { return [createEmptyMessage()];
projectId: string; }
onBack?: () => void;
hasExistingTriggers?: boolean; return messages.map((message) => ({ ...message }));
}) { };
const convertFormMessagesToMessages = (messages: FormMessage[]): z.infer<typeof Message>[] => {
return messages.map((msg) => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined,
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined,
};
});
};
function RecurringJobRuleFormBase({
title,
description,
submitLabel,
submittingLabel,
errorMessage,
backButton,
initialCron,
initialMessages,
onSubmit,
onSuccess,
successHref,
}: RecurringJobRuleFormBaseProps) {
const router = useRouter(); const router = useRouter();
const [messages, setMessages] = useState<FormMessage[]>(normaliseMessages(initialMessages));
const [cronExpression, setCronExpression] = useState(initialCron ?? "* * * * *");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [messages, setMessages] = useState<FormMessage[]>([
{ role: "user", content: "" }
]);
const [cronExpression, setCronExpression] = useState("* * * * *");
const [showCronHelp, setShowCronHelp] = useState(false); const [showCronHelp, setShowCronHelp] = useState(false);
useEffect(() => {
setMessages(normaliseMessages(initialMessages));
}, [initialMessages]);
useEffect(() => {
setCronExpression(initialCron ?? "* * * * *");
}, [initialCron]);
const addMessage = () => { const addMessage = () => {
setMessages([...messages, { role: "user", content: "" }]); setMessages((prev) => [...prev, createEmptyMessage()]);
}; };
const removeMessage = (index: number) => { const removeMessage = (index: number) => {
if (messages.length > 1) { setMessages((prev) => {
setMessages(messages.filter((_, i) => i !== index)); if (prev.length <= 1) {
} return prev;
}
return prev.filter((_, i) => i !== index);
});
}; };
const updateMessage = (index: number, field: keyof FormMessage, value: string) => { const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
const newMessages = [...messages]; setMessages((prev) => {
newMessages[index] = { ...newMessages[index], [field]: value }; const next = [...prev];
setMessages(newMessages); next[index] = { ...next[index], [field]: value };
return next;
});
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Validate required fields
if (!cronExpression.trim()) { if (!cronExpression.trim()) {
alert("Please enter a cron expression"); alert("Please enter a cron expression");
return; return;
} }
if (messages.some(msg => !msg.content?.trim())) { if (messages.some((msg) => !msg.content?.trim())) {
alert("Please fill in all message content"); alert("Please fill in all message content");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
// Convert FormMessage to the expected Message type const result = await onSubmit({
const convertedMessages = messages.map(msg => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined
};
});
await createRecurringJobRule({
projectId,
input: { messages: convertedMessages },
cron: cronExpression, cron: cronExpression,
messages,
}); });
if (onBack) {
onBack(); if (onSuccess) {
} else { onSuccess(result);
router.push(`/projects/${projectId}/manage-triggers?tab=recurring`); } else if (successHref) {
router.push(successHref);
} }
} catch (error) { } catch (error) {
console.error("Failed to create recurring job rule:", error); console.error(errorMessage, error);
alert("Failed to create recurring job rule"); alert(errorMessage);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -114,30 +166,39 @@ export function CreateRecurringJobRuleForm({
<Panel <Panel
title={ title={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{hasExistingTriggers && onBack ? ( {backButton ? (
<Button 'onClick' in backButton ? (
variant="secondary" <Button
size="sm" variant="secondary"
startContent={<ArrowLeftIcon className="w-4 h-4" />} size="sm"
className="whitespace-nowrap" startContent={<ArrowLeftIcon className="w-4 h-4" />}
onClick={onBack} className="whitespace-nowrap"
> onClick={backButton.onClick}
Back >
</Button> {backButton.label}
) : hasExistingTriggers ? (
<Link href={`/projects/${projectId}/manage-triggers?tab=recurring`}>
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap">
Back
</Button> </Button>
</Link> ) : (
<Link href={backButton.href}>
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{backButton.label}
</Button>
</Link>
)
) : null} ) : null}
<div> <div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CREATE RECURRING JOB RULE {title}
</div> </div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> {description ? (
Note: Triggers run only on the published version of your workflow. Publish any changes to make them active. <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
</p> {description}
</p>
) : null}
</div> </div>
</div> </div>
} }
@ -262,7 +323,7 @@ export function CreateRecurringJobRuleForm({
isLoading={loading} isLoading={loading}
className="px-6 py-2 whitespace-nowrap" className="px-6 py-2 whitespace-nowrap"
> >
{loading ? "Creating..." : "Create Rule"} {loading ? submittingLabel : submitLabel}
</Button> </Button>
</div> </div>
</form> </form>
@ -271,3 +332,99 @@ export function CreateRecurringJobRuleForm({
</Panel> </Panel>
); );
} }
export function CreateRecurringJobRuleForm({
projectId,
onBack,
hasExistingTriggers = true,
}: {
projectId: string;
onBack?: () => void;
hasExistingTriggers?: boolean;
}) {
const handleSubmit = async ({ cron, messages }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
await createRecurringJobRule({
projectId,
input: { messages: convertedMessages },
cron,
});
};
const handleSuccess = onBack ? () => onBack() : undefined;
const backButton: BackButtonConfig | undefined = hasExistingTriggers
? onBack
? { label: "Back", onClick: onBack }
: { label: "Back", href: `/projects/${projectId}/manage-triggers?tab=recurring` }
: undefined;
return (
<RecurringJobRuleFormBase
title="CREATE RECURRING JOB RULE"
description="Note: Triggers run only on the published version of your workflow. Publish any changes to make them active."
submitLabel="Create Rule"
submittingLabel="Creating..."
errorMessage="Failed to create recurring job rule"
backButton={backButton}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
successHref={onBack ? undefined : `/projects/${projectId}/manage-triggers?tab=recurring`}
/>
);
}
export function EditRecurringJobRuleForm({
projectId,
rule,
onCancel,
onUpdated,
}: {
projectId: string;
rule: z.infer<typeof RecurringJobRule>;
onCancel: () => void;
onUpdated?: (rule: z.infer<typeof RecurringJobRule>) => void;
}) {
const initialMessages = useMemo<FormMessage[]>(() => {
return rule.input.messages
.filter((message): message is Extract<z.infer<typeof Message>, { role: "system" | "user" | "assistant" }> => {
return message.role === "system" || message.role === "user" || message.role === "assistant";
})
.map((message) => ({
role: message.role,
content: message.content ?? "",
}));
}, [rule.input.messages]);
const handleSubmit = async ({ cron, messages }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const updatedRule = await updateRecurringJobRule({
projectId,
ruleId: rule.id,
input: { messages: convertedMessages },
cron,
});
return updatedRule;
};
const handleSuccess = (result: unknown) => {
if (result && typeof result === 'object' && onUpdated) {
onUpdated(result as z.infer<typeof RecurringJobRule>);
}
onCancel();
};
return (
<RecurringJobRuleFormBase
title="EDIT RECURRING JOB RULE"
description="Update the cron schedule and prompt messages for this trigger."
submitLabel="Save Changes"
submittingLabel="Saving..."
errorMessage="Failed to update recurring job rule"
backButton={{ label: "Cancel", onClick: onCancel }}
initialCron={rule.cron}
initialMessages={initialMessages}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
/>
);
}

View file

@ -5,12 +5,13 @@ import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
import { fetchRecurringJobRule, toggleRecurringJobRule, deleteRecurringJobRule } from "@/app/actions/recurring-job-rules.actions"; import { fetchRecurringJobRule, toggleRecurringJobRule, deleteRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { ArrowLeftIcon, PlayIcon, PauseIcon, ClockIcon, AlertCircleIcon, Trash2Icon } from "lucide-react"; import { ArrowLeftIcon, PlayIcon, PauseIcon, ClockIcon, AlertCircleIcon, Trash2Icon, PencilIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule"; import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { Spinner } from "@heroui/react"; import { Spinner } from "@heroui/react";
import { z } from "zod"; import { z } from "zod";
import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list"; import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list";
import { EditRecurringJobRuleForm } from "./create-recurring-job-rule-form";
export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) { export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string }) {
const router = useRouter(); const router = useRouter();
@ -19,6 +20,7 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
const [updating, setUpdating] = useState(false); const [updating, setUpdating] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editing, setEditing] = useState(false);
const jobsFilters = useMemo(() => ({ recurringJobRuleId: ruleId }), [ruleId]); const jobsFilters = useMemo(() => ({ recurringJobRuleId: ruleId }), [ruleId]);
@ -145,128 +147,161 @@ export function RecurringJobRuleView({ projectId, ruleId }: { projectId: string;
} }
rightActions={ rightActions={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button {editing ? (
onClick={handleToggleStatus} <Button
disabled={updating} onClick={() => setEditing(false)}
variant={rule.disabled ? "primary" : "secondary"} variant="secondary"
size="sm" size="sm"
isLoading={updating} className="whitespace-nowrap"
startContent={rule.disabled ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />} >
className="whitespace-nowrap" Cancel Edit
> </Button>
{rule.disabled ? 'Activate' : 'Pause'} ) : (
</Button> <>
<Button <Button
onClick={() => setShowDeleteConfirm(true)} onClick={() => setEditing(true)}
variant="secondary" variant="secondary"
size="sm" size="sm"
startContent={<Trash2Icon className="w-4 h-4" />} startContent={<PencilIcon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap" className="whitespace-nowrap"
> >
Delete Edit
</Button> </Button>
<Button
onClick={handleToggleStatus}
disabled={updating}
variant={rule.disabled ? "primary" : "secondary"}
size="sm"
isLoading={updating}
startContent={rule.disabled ? <PlayIcon className="w-4 h-4" /> : <PauseIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{rule.disabled ? 'Activate' : 'Pause'}
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
</>
)}
</div> </div>
} }
> >
<div className="h-full overflow-auto px-4 py-4"> <div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[800px] mx-auto space-y-6"> <div className="max-w-[800px] mx-auto space-y-6">
{/* Status */} {editing ? (
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4"> <EditRecurringJobRuleForm
<div className="flex items-center gap-2 mb-2"> projectId={projectId}
<div className={`w-3 h-3 rounded-full ${rule.disabled ? 'bg-red-500' : 'bg-green-500'}`} /> rule={rule}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100"> onCancel={() => setEditing(false)}
Status: {rule.disabled ? 'Disabled' : 'Active'} onUpdated={(updatedRule) => setRule(updatedRule)}
</span>
</div>
{rule.lastError && (
<div className="flex items-start gap-2 mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<AlertCircleIcon className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700 dark:text-red-300">
<strong>Last Error:</strong> {rule.lastError}
</div>
</div>
)}
</div>
{/* Schedule Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Schedule Information
</h3>
<div className="space-y-3">
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">Cron Expression:</span>
<code className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono">
{rule.cron}
</code>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Human Readable:</strong> {formatCronExpression(rule.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Next Run:</strong> {formatDate(rule.nextRunAt)}
</div>
{rule.lastProcessedAt && (
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Last Processed:</strong> {formatDate(rule.lastProcessedAt)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Messages
</h3>
<div className="space-y-3">
{rule.input.messages.map((message, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
message.role === 'system'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: message.role === 'user'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}>
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
))}
</div>
</div>
{/* Metadata */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Metadata
</h3>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><strong>Created:</strong> {formatDate(rule.createdAt)}</div>
{rule.updatedAt && (
<div><strong>Last Updated:</strong> {formatDate(rule.updatedAt)}</div>
)}
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div>
</div>
{/* Jobs Created by This Rule */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Jobs Created by This Rule
</h3>
<JobsList
projectId={projectId}
filters={jobsFilters}
showTitle={false}
/> />
</div> ) : (
<>
{/* Status */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<div className="flex items-center gap-2 mb-2">
<div className={`w-3 h-3 rounded-full ${rule.disabled ? 'bg-red-500' : 'bg-green-500'}`} />
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
Status: {rule.disabled ? 'Disabled' : 'Active'}
</span>
</div>
{rule.lastError && (
<div className="flex items-start gap-2 mt-2 p-2 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded">
<AlertCircleIcon className="w-4 h-4 text-red-500 mt-0.5 flex-shrink-0" />
<div className="text-sm text-red-700 dark:text-red-300">
<strong>Last Error:</strong> {rule.lastError}
</div>
</div>
)}
</div>
{/* Schedule Information */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Schedule Information
</h3>
<div className="space-y-3">
<div className="flex items-center gap-2">
<ClockIcon className="w-4 h-4 text-gray-500" />
<span className="text-sm text-gray-600 dark:text-gray-400">Cron Expression:</span>
<code className="px-2 py-1 bg-gray-100 dark:bg-gray-700 rounded text-sm font-mono">
{rule.cron}
</code>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Human Readable:</strong> {formatCronExpression(rule.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Next Run:</strong> {formatDate(rule.nextRunAt)}
</div>
{rule.lastProcessedAt && (
<div className="text-sm text-gray-600 dark:text-gray-400">
<strong>Last Processed:</strong> {formatDate(rule.lastProcessedAt)}
</div>
)}
</div>
</div>
{/* Messages */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Messages
</h3>
<div className="space-y-3">
{rule.input.messages.map((message, index) => (
<div key={index} className="border border-gray-200 dark:border-gray-600 rounded-lg p-3">
<div className="flex items-center gap-2 mb-2">
<span className={`px-2 py-1 rounded text-xs font-medium ${
message.role === 'system'
? 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'
: message.role === 'user'
? 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200'
}`}>
{message.role.charAt(0).toUpperCase() + message.role.slice(1)}
</span>
</div>
<div className="text-sm text-gray-700 dark:text-gray-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
))}
</div>
</div>
{/* Metadata */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Metadata
</h3>
<div className="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<div><strong>Created:</strong> {formatDate(rule.createdAt)}</div>
{rule.updatedAt && (
<div><strong>Last Updated:</strong> {formatDate(rule.updatedAt)}</div>
)}
<div><strong>Rule ID:</strong> <code className="bg-gray-100 dark:bg-gray-700 px-1 rounded">{rule.id}</code></div>
</div>
</div>
{/* Jobs Created by This Rule */}
<div className="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-3">
Jobs Created by This Rule
</h3>
<JobsList
projectId={projectId}
filters={jobsFilters}
showTitle={false}
/>
</div>
</>
)}
</div> </div>
</div> </div>
</Panel> </Panel>

View file

@ -1,132 +1,197 @@
'use client'; 'use client';
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common"; import { Panel } from "@/components/common/panel-common";
import { createScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions"; import { createScheduledJobRule, updateScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions";
import { ArrowLeftIcon, PlusIcon, TrashIcon } from "lucide-react"; import { ArrowLeftIcon, PlusIcon, TrashIcon } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import { DatePicker } from "@heroui/react"; import { DatePicker } from "@heroui/react";
import { ZonedDateTime, now, getLocalTimeZone } from "@internationalized/date"; import { ZonedDateTime, now, getLocalTimeZone, parseAbsoluteToLocal } from "@internationalized/date";
import { z } from "zod";
import { Message } from "@/app/lib/types/types";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
// Define a simpler message type for the form that only includes the fields we need
type FormMessage = { type FormMessage = {
role: "system" | "user" | "assistant"; role: "system" | "user" | "assistant";
content: string; content: string;
}; };
export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTriggers = true }: { projectId: string; onBack?: () => void; hasExistingTriggers?: boolean }) { type BackButtonConfig =
const router = useRouter(); | { label: string; onClick: () => void }
const [loading, setLoading] = useState(false); | { label: string; href: string };
const [messages, setMessages] = useState<FormMessage[]>([
{ role: "user", content: "" }
]);
// Set default to 30 minutes from now with timezone info
const getDefaultDateTime = () => {
const localTimeZone = getLocalTimeZone();
const currentTime = now(localTimeZone);
const thirtyMinutesFromNow = currentTime.add({ minutes: 30 });
return thirtyMinutesFromNow;
};
const [scheduledDateTime, setScheduledDateTime] = useState<ZonedDateTime | null>(getDefaultDateTime()); type FormSubmitPayload = {
messages: FormMessage[];
scheduledDateTime: ZonedDateTime;
};
type ScheduledJobRuleFormBaseProps = {
title: string;
description?: string;
submitLabel: string;
submittingLabel: string;
errorMessage: string;
backButton?: BackButtonConfig;
initialMessages?: FormMessage[];
initialDateTime?: ZonedDateTime | null;
placeholderDateTime: ZonedDateTime;
minDateTime: ZonedDateTime;
onSubmit: (payload: FormSubmitPayload) => Promise<unknown>;
onSuccess?: (result: unknown) => void;
successHref?: string;
};
const createEmptyMessage = (): FormMessage => ({ role: "user", content: "" });
const normaliseMessages = (messages?: FormMessage[]): FormMessage[] => {
if (!messages || messages.length === 0) {
return [createEmptyMessage()];
}
return messages.map((message) => ({ ...message }));
};
const convertFormMessagesToMessages = (messages: FormMessage[]): z.infer<typeof Message>[] => {
return messages.map((msg) => {
if (msg.role === "assistant") {
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined,
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined,
};
});
};
function ScheduledJobRuleFormBase({
title,
description,
submitLabel,
submittingLabel,
errorMessage,
backButton,
initialMessages,
initialDateTime,
placeholderDateTime,
minDateTime,
onSubmit,
onSuccess,
successHref,
}: ScheduledJobRuleFormBaseProps) {
const router = useRouter();
const [messages, setMessages] = useState<FormMessage[]>(normaliseMessages(initialMessages));
const [scheduledDateTime, setScheduledDateTime] = useState<ZonedDateTime | null>(initialDateTime ?? placeholderDateTime);
const [loading, setLoading] = useState(false);
useEffect(() => {
setMessages(normaliseMessages(initialMessages));
}, [initialMessages]);
useEffect(() => {
setScheduledDateTime(initialDateTime ?? placeholderDateTime);
}, [initialDateTime, placeholderDateTime]);
const addMessage = () => { const addMessage = () => {
setMessages([...messages, { role: "user", content: "" }]); setMessages((prev) => [...prev, createEmptyMessage()]);
}; };
const removeMessage = (index: number) => { const removeMessage = (index: number) => {
if (messages.length > 1) { setMessages((prev) => {
setMessages(messages.filter((_, i) => i !== index)); if (prev.length <= 1) {
} return prev;
}
return prev.filter((_, i) => i !== index);
});
}; };
const updateMessage = (index: number, field: keyof FormMessage, value: string) => { const updateMessage = (index: number, field: keyof FormMessage, value: string) => {
const newMessages = [...messages]; setMessages((prev) => {
newMessages[index] = { ...newMessages[index], [field]: value }; const next = [...prev];
setMessages(newMessages); next[index] = { ...next[index], [field]: value };
return next;
});
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
// Validate required fields
if (!scheduledDateTime) { if (!scheduledDateTime) {
alert("Please select date and time"); alert("Please select date and time");
return; return;
} }
if (messages.some(msg => !msg.content?.trim())) { if (messages.some((msg) => !msg.content?.trim())) {
alert("Please fill in all message content"); alert("Please fill in all message content");
return; return;
} }
setLoading(true); setLoading(true);
try { try {
// Convert FormMessage to the expected Message type const result = await onSubmit({
const convertedMessages = messages.map(msg => { messages,
if (msg.role === "assistant") { scheduledDateTime,
return {
role: msg.role,
content: msg.content,
agentName: null,
responseType: "internal" as const,
timestamp: undefined
};
}
return {
role: msg.role,
content: msg.content,
timestamp: undefined
};
}); });
// Convert ZonedDateTime to ISO string (already in UTC) if (onSuccess) {
const scheduledTimeString = scheduledDateTime.toDate().toISOString(); onSuccess(result);
} else if (successHref) {
await createScheduledJobRule({ router.push(successHref);
projectId,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
if (onBack) {
onBack();
} else {
router.push(`/projects/${projectId}/manage-triggers?tab=scheduled`);
} }
} catch (error) { } catch (error) {
console.error("Failed to create scheduled job rule:", error); console.error(errorMessage, error);
alert("Failed to create scheduled job rule"); alert(errorMessage);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
return ( return (
<Panel <Panel
title={ title={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{hasExistingTriggers && onBack ? ( {backButton ? (
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap" onClick={onBack}> 'onClick' in backButton ? (
Back <Button
</Button> variant="secondary"
) : hasExistingTriggers ? ( size="sm"
<Link href={`/projects/${projectId}/manage-triggers?tab=scheduled`}> startContent={<ArrowLeftIcon className="w-4 h-4" />}
<Button variant="secondary" size="sm" startContent={<ArrowLeftIcon className="w-4 h-4" />} className="whitespace-nowrap"> className="whitespace-nowrap"
Back onClick={backButton.onClick}
>
{backButton.label}
</Button> </Button>
</Link> ) : (
<Link href={backButton.href}>
<Button
variant="secondary"
size="sm"
startContent={<ArrowLeftIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
{backButton.label}
</Button>
</Link>
)
) : null} ) : null}
<div> <div>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100"> <div className="text-sm font-medium text-gray-900 dark:text-gray-100">
CREATE SCHEDULED JOB RULE {title}
</div> </div>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400"> {description ? (
Note: Triggers run only on the published version of your workflow. Publish any changes to make them active. <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
</p> {description}
</p>
) : null}
</div> </div>
</div> </div>
} }
@ -142,8 +207,8 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
<DatePicker <DatePicker
value={scheduledDateTime} value={scheduledDateTime}
onChange={setScheduledDateTime} onChange={setScheduledDateTime}
placeholderValue={getDefaultDateTime()} placeholderValue={placeholderDateTime}
minValue={now(getLocalTimeZone())} minValue={minDateTime}
granularity="minute" granularity="minute"
isRequired isRequired
className="w-full" className="w-full"
@ -214,7 +279,7 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
isLoading={loading} isLoading={loading}
className="px-6 py-2 whitespace-nowrap" className="px-6 py-2 whitespace-nowrap"
> >
{loading ? "Creating..." : "Create Rule"} {loading ? submittingLabel : submitLabel}
</Button> </Button>
</div> </div>
</form> </form>
@ -223,3 +288,111 @@ export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTrigg
</Panel> </Panel>
); );
} }
export function CreateScheduledJobRuleForm({ projectId, onBack, hasExistingTriggers = true }: { projectId: string; onBack?: () => void; hasExistingTriggers?: boolean }) {
const timeZone = useMemo(() => getLocalTimeZone(), []);
const minDateTime = useMemo(() => now(timeZone), [timeZone]);
const defaultDateTime = useMemo(() => now(timeZone).add({ minutes: 30 }), [timeZone]);
const handleSubmit = async ({ messages, scheduledDateTime }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
await createScheduledJobRule({
projectId,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
};
const handleSuccess = onBack ? () => onBack() : undefined;
const backButton: BackButtonConfig | undefined = hasExistingTriggers
? onBack
? { label: "Back", onClick: onBack }
: { label: "Back", href: `/projects/${projectId}/manage-triggers?tab=scheduled` }
: undefined;
return (
<ScheduledJobRuleFormBase
title="CREATE SCHEDULED JOB RULE"
description="Note: Triggers run only on the published version of your workflow. Publish any changes to make them active."
submitLabel="Create Rule"
submittingLabel="Creating..."
errorMessage="Failed to create scheduled job rule"
backButton={backButton}
initialDateTime={defaultDateTime}
placeholderDateTime={defaultDateTime}
minDateTime={minDateTime}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
successHref={onBack ? undefined : `/projects/${projectId}/manage-triggers?tab=scheduled`}
/>
);
}
export function EditScheduledJobRuleForm({
projectId,
rule,
onCancel,
onUpdated,
}: {
projectId: string;
rule: z.infer<typeof ScheduledJobRule>;
onCancel: () => void;
onUpdated?: (rule: z.infer<typeof ScheduledJobRule>) => void;
}) {
const timeZone = useMemo(() => getLocalTimeZone(), []);
const initialDateTime = useMemo(() => parseAbsoluteToLocal(rule.nextRunAt), [rule.nextRunAt]);
const nowDateTime = useMemo(() => now(timeZone), [timeZone]);
const minDateTime = useMemo(() => {
return initialDateTime.compare(nowDateTime) < 0 ? initialDateTime : nowDateTime;
}, [initialDateTime, nowDateTime]);
const initialMessages = useMemo<FormMessage[]>(() => {
return rule.input.messages
.filter((message): message is Extract<z.infer<typeof Message>, { role: "system" | "user" | "assistant" }> => {
return message.role === "system" || message.role === "user" || message.role === "assistant";
})
.map((message) => ({
role: message.role,
content: message.content ?? "",
}));
}, [rule.input.messages]);
const handleSubmit = async ({ messages, scheduledDateTime }: FormSubmitPayload) => {
const convertedMessages = convertFormMessagesToMessages(messages);
const scheduledTimeString = scheduledDateTime.toDate().toISOString();
const updatedRule = await updateScheduledJobRule({
projectId,
ruleId: rule.id,
input: { messages: convertedMessages },
scheduledTime: scheduledTimeString,
});
return updatedRule;
};
const handleSuccess = (result: unknown) => {
if (result && typeof result === 'object' && onUpdated) {
onUpdated(result as z.infer<typeof ScheduledJobRule>);
}
onCancel();
};
return (
<ScheduledJobRuleFormBase
title="EDIT SCHEDULED JOB RULE"
description="Update the scheduled run time and prompt messages for this trigger."
submitLabel="Save Changes"
submittingLabel="Saving..."
errorMessage="Failed to update scheduled job rule"
backButton={{ label: "Cancel", onClick: onCancel }}
initialMessages={initialMessages}
initialDateTime={initialDateTime}
placeholderDateTime={initialDateTime}
minDateTime={minDateTime}
onSubmit={handleSubmit}
onSuccess={handleSuccess}
/>
);
}

View file

@ -9,8 +9,9 @@ import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { z } from "zod"; import { z } from "zod";
import Link from "next/link"; import Link from "next/link";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { ArrowLeftIcon, Trash2Icon } from "lucide-react"; import { ArrowLeftIcon, Trash2Icon, PencilIcon } from "lucide-react";
import { MessageDisplay } from "@/app/lib/components/message-display"; import { MessageDisplay } from "@/app/lib/components/message-display";
import { EditScheduledJobRuleForm } from "./create-scheduled-job-rule-form";
export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string; }) { export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string; ruleId: string; }) {
const router = useRouter(); const router = useRouter();
@ -18,6 +19,7 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [editing, setEditing] = useState(false);
useEffect(() => { useEffect(() => {
let ignore = false; let ignore = false;
@ -92,15 +94,37 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
} }
rightActions={ rightActions={
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Button {editing ? (
onClick={() => setShowDeleteConfirm(true)} <Button
variant="secondary" onClick={() => setEditing(false)}
size="sm" variant="secondary"
startContent={<Trash2Icon className="w-4 h-4" />} size="sm"
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap" className="whitespace-nowrap"
> >
Delete Cancel Edit
</Button> </Button>
) : (
<>
<Button
onClick={() => setEditing(true)}
variant="secondary"
size="sm"
startContent={<PencilIcon className="w-4 h-4" />}
className="whitespace-nowrap"
>
Edit
</Button>
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
startContent={<Trash2Icon className="w-4 h-4" />}
className="bg-red-50 hover:bg-red-100 text-red-700 dark:bg-red-950 dark:hover:bg-red-900 dark:text-red-400 border border-red-200 dark:border-red-800 whitespace-nowrap"
>
Delete
</Button>
</>
)}
</div> </div>
} }
> >
@ -114,74 +138,85 @@ export function ScheduledJobRuleView({ projectId, ruleId }: { projectId: string;
)} )}
{!loading && rule && ( {!loading && rule && (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
{/* Rule Metadata */} {editing ? (
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700"> <EditScheduledJobRuleForm
<div className="grid grid-cols-2 gap-4 text-sm"> projectId={projectId}
<div> rule={rule}
<span className="font-semibold text-gray-700 dark:text-gray-300">Rule ID:</span> onCancel={() => setEditing(false)}
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.id}</span> onUpdated={(updatedRule) => setRule(updatedRule)}
</div> />
<div> ) : (
<span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span> <>
<span className={`ml-2 font-mono ${getStatusColor(rule.status, rule.processedAt || null)}`}> {/* Rule Metadata */}
{getStatusText(rule.status, rule.processedAt || null)} <div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
</span> <div className="grid grid-cols-2 gap-4 text-sm">
</div> <div>
<div> <span className="font-semibold text-gray-700 dark:text-gray-300">Rule ID:</span>
<span className="font-semibold text-gray-700 dark:text-gray-300">Next Run:</span> <span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.id}</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.nextRunAt)}
</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.createdAt)}
</span>
</div>
{rule.processedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Processed:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.processedAt)}
</span>
</div> </div>
)} <div>
{rule.output?.jobId && ( <span className="font-semibold text-gray-700 dark:text-gray-300">Status:</span>
<div> <span className={`ml-2 font-mono ${getStatusColor(rule.status, rule.processedAt || null)}`}>
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span> {getStatusText(rule.status, rule.processedAt || null)}
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400"> </span>
<Link </div>
href={`/projects/${projectId}/jobs/${rule.output.jobId}`} <div>
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300" <span className="font-semibold text-gray-700 dark:text-gray-300">Next Run:</span>
> <span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{rule.output.jobId} {formatDateTime(rule.nextRunAt)}
</Link> </span>
</span> </div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Created:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.createdAt)}
</span>
</div>
{rule.processedAt && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Processed:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
{formatDateTime(rule.processedAt)}
</span>
</div>
)}
{rule.output?.jobId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Job ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">
<Link
href={`/projects/${projectId}/jobs/${rule.output.jobId}`}
className="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-300"
>
{rule.output.jobId}
</Link>
</span>
</div>
)}
{rule.workerId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Worker ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.workerId}</span>
</div>
)}
</div> </div>
)} </div>
{rule.workerId && (
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Worker ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{rule.workerId}</span>
</div>
)}
</div>
</div>
{/* Messages */} {/* Messages */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100"> <h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Messages Messages
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
{rule.input.messages.map((message, index) => ( {rule.input.messages.map((message, index) => (
<div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700"> <div key={index} className="bg-white dark:bg-gray-800 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<MessageDisplay message={message} index={index} /> <MessageDisplay message={message} index={index} />
</div>
))}
</div> </div>
))} </div>
</div> </>
</div> )}
</div> </div>
)} )}
</div> </div>

View file

@ -73,10 +73,12 @@ import { CreateScheduledJobRuleUseCase } from "@/src/application/use-cases/sched
import { FetchScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/fetch-scheduled-job-rule.use-case"; import { FetchScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/fetch-scheduled-job-rule.use-case";
import { ListScheduledJobRulesUseCase } from "@/src/application/use-cases/scheduled-job-rules/list-scheduled-job-rules.use-case"; import { ListScheduledJobRulesUseCase } from "@/src/application/use-cases/scheduled-job-rules/list-scheduled-job-rules.use-case";
import { DeleteScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/delete-scheduled-job-rule.use-case"; import { DeleteScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/delete-scheduled-job-rule.use-case";
import { UpdateScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/update-scheduled-job-rule.use-case";
import { CreateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/create-scheduled-job-rule.controller"; import { CreateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/create-scheduled-job-rule.controller";
import { FetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller"; import { FetchScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/fetch-scheduled-job-rule.controller";
import { ListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller"; import { ListScheduledJobRulesController } from "@/src/interface-adapters/controllers/scheduled-job-rules/list-scheduled-job-rules.controller";
import { DeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller"; import { DeleteScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/delete-scheduled-job-rule.controller";
import { UpdateScheduledJobRuleController } from "@/src/interface-adapters/controllers/scheduled-job-rules/update-scheduled-job-rule.controller";
// Recurring Job Rules // Recurring Job Rules
import { MongoDBRecurringJobRulesRepository } from "@/src/infrastructure/repositories/mongodb.recurring-job-rules.repository"; import { MongoDBRecurringJobRulesRepository } from "@/src/infrastructure/repositories/mongodb.recurring-job-rules.repository";
@ -85,11 +87,13 @@ import { FetchRecurringJobRuleUseCase } from "@/src/application/use-cases/recurr
import { ListRecurringJobRulesUseCase } from "@/src/application/use-cases/recurring-job-rules/list-recurring-job-rules.use-case"; import { ListRecurringJobRulesUseCase } from "@/src/application/use-cases/recurring-job-rules/list-recurring-job-rules.use-case";
import { ToggleRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/toggle-recurring-job-rule.use-case"; import { ToggleRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/toggle-recurring-job-rule.use-case";
import { DeleteRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/delete-recurring-job-rule.use-case"; import { DeleteRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/delete-recurring-job-rule.use-case";
import { UpdateRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/update-recurring-job-rule.use-case";
import { CreateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/create-recurring-job-rule.controller"; import { CreateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/create-recurring-job-rule.controller";
import { FetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller"; import { FetchRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/fetch-recurring-job-rule.controller";
import { ListRecurringJobRulesController } from "@/src/interface-adapters/controllers/recurring-job-rules/list-recurring-job-rules.controller"; import { ListRecurringJobRulesController } from "@/src/interface-adapters/controllers/recurring-job-rules/list-recurring-job-rules.controller";
import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller"; import { ToggleRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/toggle-recurring-job-rule.controller";
import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller"; import { DeleteRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/delete-recurring-job-rule.controller";
import { UpdateRecurringJobRuleController } from "@/src/interface-adapters/controllers/recurring-job-rules/update-recurring-job-rule.controller";
// API Keys // API Keys
import { CreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case"; import { CreateApiKeyUseCase } from "@/src/application/use-cases/api-keys/create-api-key.use-case";
@ -238,10 +242,12 @@ container.register({
createScheduledJobRuleUseCase: asClass(CreateScheduledJobRuleUseCase).singleton(), createScheduledJobRuleUseCase: asClass(CreateScheduledJobRuleUseCase).singleton(),
fetchScheduledJobRuleUseCase: asClass(FetchScheduledJobRuleUseCase).singleton(), fetchScheduledJobRuleUseCase: asClass(FetchScheduledJobRuleUseCase).singleton(),
listScheduledJobRulesUseCase: asClass(ListScheduledJobRulesUseCase).singleton(), listScheduledJobRulesUseCase: asClass(ListScheduledJobRulesUseCase).singleton(),
updateScheduledJobRuleUseCase: asClass(UpdateScheduledJobRuleUseCase).singleton(),
deleteScheduledJobRuleUseCase: asClass(DeleteScheduledJobRuleUseCase).singleton(), deleteScheduledJobRuleUseCase: asClass(DeleteScheduledJobRuleUseCase).singleton(),
createScheduledJobRuleController: asClass(CreateScheduledJobRuleController).singleton(), createScheduledJobRuleController: asClass(CreateScheduledJobRuleController).singleton(),
fetchScheduledJobRuleController: asClass(FetchScheduledJobRuleController).singleton(), fetchScheduledJobRuleController: asClass(FetchScheduledJobRuleController).singleton(),
listScheduledJobRulesController: asClass(ListScheduledJobRulesController).singleton(), listScheduledJobRulesController: asClass(ListScheduledJobRulesController).singleton(),
updateScheduledJobRuleController: asClass(UpdateScheduledJobRuleController).singleton(),
deleteScheduledJobRuleController: asClass(DeleteScheduledJobRuleController).singleton(), deleteScheduledJobRuleController: asClass(DeleteScheduledJobRuleController).singleton(),
// recurring job rules // recurring job rules
@ -251,11 +257,13 @@ container.register({
fetchRecurringJobRuleUseCase: asClass(FetchRecurringJobRuleUseCase).singleton(), fetchRecurringJobRuleUseCase: asClass(FetchRecurringJobRuleUseCase).singleton(),
listRecurringJobRulesUseCase: asClass(ListRecurringJobRulesUseCase).singleton(), listRecurringJobRulesUseCase: asClass(ListRecurringJobRulesUseCase).singleton(),
toggleRecurringJobRuleUseCase: asClass(ToggleRecurringJobRuleUseCase).singleton(), toggleRecurringJobRuleUseCase: asClass(ToggleRecurringJobRuleUseCase).singleton(),
updateRecurringJobRuleUseCase: asClass(UpdateRecurringJobRuleUseCase).singleton(),
deleteRecurringJobRuleUseCase: asClass(DeleteRecurringJobRuleUseCase).singleton(), deleteRecurringJobRuleUseCase: asClass(DeleteRecurringJobRuleUseCase).singleton(),
createRecurringJobRuleController: asClass(CreateRecurringJobRuleController).singleton(), createRecurringJobRuleController: asClass(CreateRecurringJobRuleController).singleton(),
fetchRecurringJobRuleController: asClass(FetchRecurringJobRuleController).singleton(), fetchRecurringJobRuleController: asClass(FetchRecurringJobRuleController).singleton(),
listRecurringJobRulesController: asClass(ListRecurringJobRulesController).singleton(), listRecurringJobRulesController: asClass(ListRecurringJobRulesController).singleton(),
toggleRecurringJobRuleController: asClass(ToggleRecurringJobRuleController).singleton(), toggleRecurringJobRuleController: asClass(ToggleRecurringJobRuleController).singleton(),
updateRecurringJobRuleController: asClass(UpdateRecurringJobRuleController).singleton(),
deleteRecurringJobRuleController: asClass(DeleteRecurringJobRuleController).singleton(), deleteRecurringJobRuleController: asClass(DeleteRecurringJobRuleController).singleton(),
// projects // projects
@ -344,4 +352,4 @@ container.register({
// users // users
// --- // ---
usersRepository: asClass(MongoDBUsersRepository).singleton(), usersRepository: asClass(MongoDBUsersRepository).singleton(),
}); });

View file

@ -0,0 +1,70 @@
const RANGE_SEPARATOR = "-";
const STEP_SEPARATOR = "/";
export function isValidCronExpression(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) {
return false;
}
const [minute, hour, day, month, dayOfWeek] = parts;
const validatePart = (part: string, max: number): boolean => {
if (part === "*") {
return true;
}
if (part.includes(STEP_SEPARATOR)) {
const [range, step] = part.split(STEP_SEPARATOR);
if (!step) {
return false;
}
const stepValue = Number(step);
if (!Number.isInteger(stepValue) || stepValue <= 0) {
return false;
}
if (range === "*") {
return stepValue <= max;
}
return validatePart(range, max);
}
if (part.includes(RANGE_SEPARATOR)) {
const [start, end] = part.split(RANGE_SEPARATOR);
if (start === undefined || end === undefined) {
return false;
}
const startValue = Number(start);
const endValue = Number(end);
if (!Number.isInteger(startValue) || !Number.isInteger(endValue)) {
return false;
}
if (startValue > endValue) {
return false;
}
return startValue >= 0 && endValue <= max;
}
const value = Number(part);
if (!Number.isInteger(value)) {
return false;
}
return value >= 0 && value <= max;
};
return (
validatePart(minute, 59) &&
validatePart(hour, 23) &&
validatePart(day, 31) &&
validatePart(month, 12) &&
validatePart(dayOfWeek, 7)
);
}

View file

@ -17,6 +17,15 @@ export const ListedRecurringRuleItem = RecurringJobRule.omit({
input: true, input: true,
}); });
/**
* Schema for updating a recurring job rule.
*/
export const UpdateRecurringRuleSchema = RecurringJobRule
.pick({
input: true,
cron: true,
});
/** /**
* Repository interface for managing recurring job rules in the system. * Repository interface for managing recurring job rules in the system.
* *
@ -82,6 +91,16 @@ export interface IRecurringJobRulesRepository {
*/ */
toggle(id: string, disabled: boolean): Promise<z.infer<typeof RecurringJobRule>>; toggle(id: string, disabled: boolean): Promise<z.infer<typeof RecurringJobRule>>;
/**
* Updates a recurring job rule with new input and cron expression.
*
* @param id - The unique identifier of the recurring job rule to update
* @param data - The update data containing input messages and cron expression
* @returns Promise resolving to the updated recurring job rule
* @throws {NotFoundError} if the recurring job rule doesn't exist
*/
update(id: string, data: z.infer<typeof UpdateRecurringRuleSchema>): Promise<z.infer<typeof RecurringJobRule>>;
/** /**
* Deletes a recurring job rule by its unique identifier. * Deletes a recurring job rule by its unique identifier.
* *

View file

@ -24,6 +24,17 @@ export const UpdateJobSchema = ScheduledJobRule.pick({
output: true, output: true,
}); });
/**
* Schema for updating a scheduled job rule's next run configuration.
*/
export const UpdateScheduledRuleSchema = ScheduledJobRule
.pick({
input: true,
})
.extend({
scheduledTime: z.string().datetime(),
});
/** /**
* Repository interface for managing scheduled job rules in the system. * Repository interface for managing scheduled job rules in the system.
* *
@ -69,6 +80,16 @@ export interface IScheduledJobRulesRepository {
*/ */
update(id: string, data: z.infer<typeof UpdateJobSchema>): Promise<z.infer<typeof ScheduledJobRule>>; update(id: string, data: z.infer<typeof UpdateJobSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
/**
* Updates a scheduled job rule with new input and scheduled time.
*
* @param id - The unique identifier of the scheduled job rule to update
* @param data - The update data containing input messages and scheduled time
* @returns Promise resolving to the updated scheduled job rule
* @throws {NotFoundError} if the scheduled job rule doesn't exist
*/
updateRule(id: string, data: z.infer<typeof UpdateScheduledRuleSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
/** /**
* Releases a scheduled job rule after it has been executed. * Releases a scheduled job rule after it has been executed.
* *
@ -103,4 +124,4 @@ export interface IScheduledJobRulesRepository {
* @returns Promise resolving to void * @returns Promise resolving to void
*/ */
deleteByProjectId(projectId: string): Promise<void>; deleteByProjectId(projectId: string): Promise<void>;
} }

View file

@ -5,6 +5,7 @@ import { IProjectActionAuthorizationPolicy } from '../../policies/project-action
import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface'; import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface';
import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule'; import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule';
import { Message } from '@/app/lib/types/types'; import { Message } from '@/app/lib/types/types';
import { isValidCronExpression } from '@/src/application/lib/utils/is-valid-cron-expression';
const inputSchema = z.object({ const inputSchema = z.object({
caller: z.enum(["user", "api"]), caller: z.enum(["user", "api"]),
@ -42,7 +43,7 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> { async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
// Validate cron expression // Validate cron expression
if (!this.isValidCronExpression(request.cron)) { if (!isValidCronExpression(request.cron)) {
throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek'); throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek');
} }
@ -66,31 +67,4 @@ export class CreateRecurringJobRuleUseCase implements ICreateRecurringJobRuleUse
return rule; return rule;
} }
private isValidCronExpression(cron: string): boolean {
const parts = cron.split(' ');
if (parts.length !== 5) {
return false;
}
// Basic validation - in production you'd want more sophisticated validation
const [minute, hour, day, month, dayOfWeek] = parts;
// Check if parts are valid
const isValidPart = (part: string) => {
if (part === '*') return true;
if (part.includes('/')) {
const [range, step] = part.split('/');
if (range === '*' || (parseInt(step) > 0 && parseInt(step) <= 59)) return true;
return false;
}
if (part.includes('-')) {
const [start, end] = part.split('-');
return !isNaN(parseInt(start)) && !isNaN(parseInt(end)) && parseInt(start) <= parseInt(end);
}
return !isNaN(parseInt(part));
};
return isValidPart(minute) && isValidPart(hour) && isValidPart(day) && isValidPart(month) && isValidPart(dayOfWeek);
}
} }

View file

@ -0,0 +1,69 @@
import { BadRequestError, NotFoundError } from '@/src/entities/errors/common';
import { z } from "zod";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
import { IRecurringJobRulesRepository } from '../../repositories/recurring-job-rules.repository.interface';
import { RecurringJobRule } from '@/src/entities/models/recurring-job-rule';
import { Message } from '@/app/lib/types/types';
import { isValidCronExpression } from '@/src/application/lib/utils/is-valid-cron-expression';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(Message),
}),
cron: z.string(),
});
export interface IUpdateRecurringJobRuleUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>>;
}
export class UpdateRecurringJobRuleUseCase implements IUpdateRecurringJobRuleUseCase {
private readonly recurringJobRulesRepository: IRecurringJobRulesRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
recurringJobRulesRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
recurringJobRulesRepository: IRecurringJobRulesRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.recurringJobRulesRepository = recurringJobRulesRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
if (!isValidCronExpression(request.cron)) {
throw new BadRequestError('Invalid cron expression. Expected format: minute hour day month dayOfWeek');
}
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId: request.projectId,
});
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
const rule = await this.recurringJobRulesRepository.fetch(request.ruleId);
if (!rule || rule.projectId !== request.projectId) {
throw new NotFoundError('Recurring job rule not found');
}
return await this.recurringJobRulesRepository.update(request.ruleId, {
input: request.input,
cron: request.cron,
});
}
}

View file

@ -0,0 +1,64 @@
import { NotFoundError } from '@/src/entities/errors/common';
import { z } from "zod";
import { IUsageQuotaPolicy } from '../../policies/usage-quota.policy.interface';
import { IProjectActionAuthorizationPolicy } from '../../policies/project-action-authorization.policy';
import { IScheduledJobRulesRepository } from '../../repositories/scheduled-job-rules.repository.interface';
import { ScheduledJobRule } from '@/src/entities/models/scheduled-job-rule';
import { Message } from '@/app/lib/types/types';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(Message),
}),
scheduledTime: z.string().datetime(),
});
export interface IUpdateScheduledJobRuleUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
}
export class UpdateScheduledJobRuleUseCase implements IUpdateScheduledJobRuleUseCase {
private readonly scheduledJobRulesRepository: IScheduledJobRulesRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
scheduledJobRulesRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
scheduledJobRulesRepository: IScheduledJobRulesRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.scheduledJobRulesRepository = scheduledJobRulesRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId: request.projectId,
});
await this.usageQuotaPolicy.assertAndConsumeProjectAction(request.projectId);
const rule = await this.scheduledJobRulesRepository.fetch(request.ruleId);
if (!rule || rule.projectId !== request.projectId) {
throw new NotFoundError('Scheduled job rule not found');
}
return await this.scheduledJobRulesRepository.updateRule(request.ruleId, {
input: request.input,
scheduledTime: request.scheduledTime,
});
}
}

View file

@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { Filter, ObjectId } from "mongodb"; import { Filter, ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb"; import { db } from "@/app/lib/mongodb";
import { CreateRecurringRuleSchema, IRecurringJobRulesRepository, ListedRecurringRuleItem } from "@/src/application/repositories/recurring-job-rules.repository.interface"; import { CreateRecurringRuleSchema, IRecurringJobRulesRepository, ListedRecurringRuleItem, UpdateRecurringRuleSchema } from "@/src/application/repositories/recurring-job-rules.repository.interface";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule"; import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
import { NotFoundError } from "@/src/entities/errors/common"; import { NotFoundError } from "@/src/entities/errors/common";
import { PaginatedList } from "@/src/entities/common/paginated-list"; import { PaginatedList } from "@/src/entities/common/paginated-list";
@ -208,6 +208,31 @@ export class MongoDBRecurringJobRulesRepository implements IRecurringJobRulesRep
return await this.updateNextRunAt(id, result.cron); return await this.updateNextRunAt(id, result.cron);
} }
/**
* Updates a recurring job rule with new input and schedule.
*/
async update(id: string, data: z.infer<typeof UpdateRecurringRuleSchema>): Promise<z.infer<typeof RecurringJobRule>> {
const now = new Date().toISOString();
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(id) },
{
$set: {
input: data.input,
cron: data.cron,
updatedAt: now,
},
},
{ returnDocument: "after" },
);
if (!result) {
throw new NotFoundError(`Recurring job rule ${id} not found`);
}
return await this.updateNextRunAt(id, data.cron);
}
/** /**
* Deletes a recurring job rule by its unique identifier. * Deletes a recurring job rule by its unique identifier.
*/ */

View file

@ -1,7 +1,7 @@
import { z } from "zod"; import { z } from "zod";
import { Filter, ObjectId } from "mongodb"; import { Filter, ObjectId } from "mongodb";
import { db } from "@/app/lib/mongodb"; import { db } from "@/app/lib/mongodb";
import { CreateRuleSchema, IScheduledJobRulesRepository, ListedRuleItem, UpdateJobSchema } from "@/src/application/repositories/scheduled-job-rules.repository.interface"; import { CreateRuleSchema, IScheduledJobRulesRepository, ListedRuleItem, UpdateJobSchema, UpdateScheduledRuleSchema } from "@/src/application/repositories/scheduled-job-rules.repository.interface";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule"; import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { NotFoundError } from "@/src/entities/errors/common"; import { NotFoundError } from "@/src/entities/errors/common";
import { PaginatedList } from "@/src/entities/common/paginated-list"; import { PaginatedList } from "@/src/entities/common/paginated-list";
@ -138,6 +138,41 @@ export class MongoDBScheduledJobRulesRepository implements IScheduledJobRulesRep
return this.convertDocToModel(result); return this.convertDocToModel(result);
} }
/**
* Reconfigures a scheduled job rule's input and next run time.
*/
async updateRule(id: string, data: z.infer<typeof UpdateScheduledRuleSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
const scheduledDate = new Date(data.scheduledTime);
const nextRunAtSeconds = Math.floor(scheduledDate.getTime() / 1000);
const nextRunAt = Math.floor(nextRunAtSeconds / 60) * 60;
const now = new Date().toISOString();
const result = await this.collection.findOneAndUpdate(
{ _id: new ObjectId(id) },
{
$set: {
input: data.input,
nextRunAt,
status: "pending",
workerId: null,
lastWorkerId: null,
updatedAt: now,
},
$unset: {
output: "",
processedAt: "",
},
},
{ returnDocument: "after" },
);
if (!result) {
throw new NotFoundError(`Scheduled job rule ${id} not found`);
}
return this.convertDocToModel(result);
}
/** /**
* Updates a scheduled job rule with new status and output data. * Updates a scheduled job rule with new status and output data.
*/ */

View file

@ -0,0 +1,50 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IUpdateRecurringJobRuleUseCase } from "@/src/application/use-cases/recurring-job-rules/update-recurring-job-rule.use-case";
import { RecurringJobRule } from "@/src/entities/models/recurring-job-rule";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(z.any()),
}),
cron: z.string(),
});
export interface IUpdateRecurringJobRuleController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>>;
}
export class UpdateRecurringJobRuleController implements IUpdateRecurringJobRuleController {
private readonly updateRecurringJobRuleUseCase: IUpdateRecurringJobRuleUseCase;
constructor({
updateRecurringJobRuleUseCase,
}: {
updateRecurringJobRuleUseCase: IUpdateRecurringJobRuleUseCase,
}) {
this.updateRecurringJobRuleUseCase = updateRecurringJobRuleUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof RecurringJobRule>> {
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, ruleId, input, cron } = result.data;
return await this.updateRecurringJobRuleUseCase.execute({
caller,
userId,
apiKey,
projectId,
ruleId,
input,
cron,
});
}
}

View file

@ -0,0 +1,51 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IUpdateScheduledJobRuleUseCase } from "@/src/application/use-cases/scheduled-job-rules/update-scheduled-job-rule.use-case";
import { ScheduledJobRule } from "@/src/entities/models/scheduled-job-rule";
import { Message } from "@/app/lib/types/types";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
projectId: z.string(),
ruleId: z.string(),
input: z.object({
messages: z.array(Message),
}),
scheduledTime: z.string().datetime(),
});
export interface IUpdateScheduledJobRuleController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>>;
}
export class UpdateScheduledJobRuleController implements IUpdateScheduledJobRuleController {
private readonly updateScheduledJobRuleUseCase: IUpdateScheduledJobRuleUseCase;
constructor({
updateScheduledJobRuleUseCase,
}: {
updateScheduledJobRuleUseCase: IUpdateScheduledJobRuleUseCase,
}) {
this.updateScheduledJobRuleUseCase = updateScheduledJobRuleUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ScheduledJobRule>> {
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, projectId, ruleId, input, scheduledTime } = result.data;
return await this.updateScheduledJobRuleUseCase.execute({
caller,
userId,
apiKey,
projectId,
ruleId,
input,
scheduledTime,
});
}
}