Merge pull request #211 from rowboatlabs/dev

Dev changes
This commit is contained in:
Ramnique Singh 2025-08-19 14:43:53 +05:30 committed by GitHub
commit 64f20ca9bf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1152 additions and 599 deletions

View file

@ -12,102 +12,65 @@ pip install rowboat
## Usage
### Basic Usage with StatefulChat
### Basic Usage
The easiest way to interact with Rowboat is using the `StatefulChat` class, which maintains conversation state automatically:
```python
from rowboat import Client, StatefulChat
# Initialize the client
client = Client(
host="<HOST>",
project_id="<PROJECT_ID>",
api_key="<API_KEY>"
)
# Create a stateful chat session
chat = StatefulChat(client)
# Have a conversation
response = chat.run("What is the capital of France?")
print(response)
# The capital of France is Paris.
# Continue the conversation - the context is maintained automatically
response = chat.run("What other major cities are in that country?")
print(response)
# Other major cities in France include Lyon, Marseille, Toulouse, and Nice.
response = chat.run("What's the population of the first city you mentioned?")
print(response)
# Lyon has a population of approximately 513,000 in the city proper.
```
### Advanced Usage
#### Using a specific workflow
You can specify a workflow ID to use a particular conversation configuration:
```python
chat = StatefulChat(
client,
workflow_id="<WORKFLOW_ID>"
)
```
#### Using a test profile
You can specify a test profile ID to use a specific test configuration:
```python
chat = StatefulChat(
client,
test_profile_id="<TEST_PROFILE_ID>"
)
```
#### Tool overrides
You can provide tool override instructions to test a specific configuration:
```python
chat = StatefulChat(
client,
mock_tools={
"weather_lookup": "The weather in any city is sunny and 25°C.",
"calculator": "The result of any calculation is 42.",
"search": "Search results for any query return 'No relevant information found.'"
}
)
```
### Low-Level Usage
For more control over the conversation, you can use the `Client` class directly:
The main way to interact with Rowboat is using the `Client` class, which provides a stateless chat API. You can manage conversation state using the `conversationId` returned in each response.
```python
from rowboat.client import Client
from rowboat.schema import UserMessage
# Initialize the client
client = Client(
host="<HOST>",
project_id="<PROJECT_ID>",
api_key="<API_KEY>"
projectId="<PROJECT_ID>",
apiKey="<API_KEY>"
)
# Create messages
messages = [
UserMessage(role='user', content="Hello, how are you?")
]
# Start a new conversation
result = client.run_turn(
messages=[
UserMessage(role='user', content="list my github repos")
]
)
print(result.turn.output[-1].content)
print("Conversation ID:", result.conversationId)
# Get response
response = client.chat(messages=messages)
print(response.messages[-1].content)
# For subsequent messages, you need to manage the message history and state manually
messages.extend(response.messages)
messages.append(UserMessage(role='user', content="What's your name?"))
response = client.chat(messages=messages, state=response.state)
# Continue the conversation by passing the conversationId
result = client.run_turn(
messages=[
UserMessage(role='user', content="how many did you find?")
],
conversationId=result.conversationId
)
print(result.turn.output[-1].content)
```
### Using Tool Overrides (Mock Tools)
You can provide tool override instructions to test a specific configuration using the `mockTools` argument:
```python
result = client.run_turn(
messages=[
UserMessage(role='user', content="What's the weather?")
],
mockTools={
"weather_lookup": "The weather in any city is sunny and 25°C.",
"calculator": "The result of any calculation is 42."
}
)
print(result.turn.output[-1].content)
```
### Message Types
You can use different message types as defined in `rowboat.schema`, such as `UserMessage`, `SystemMessage`, etc. See `schema.py` for all available message types.
### Error Handling
If the API returns a non-200 status code, a `ValueError` will be raised with the error details.
---
For more advanced usage, see the docstrings in `client.py` and the message schemas in `schema.py`.

View file

@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "rowboat"
version = "5.0.0"
version = "5.0.1"
authors = [
{ name = "Ramnique Singh", email = "ramnique@rowboatlabs.com" },
]

View file

@ -13,6 +13,7 @@ import { ICreateComposioTriggerDeploymentController } from "@/src/interface-adap
import { IListComposioTriggerDeploymentsController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller";
import { IDeleteComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/delete-composio-trigger-deployment.controller";
import { IListComposioTriggerTypesController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-types.controller";
import { IFetchComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/fetch-composio-trigger-deployment.controller";
import { IDeleteComposioConnectedAccountController } from "@/src/interface-adapters/controllers/projects/delete-composio-connected-account.controller";
import { authCheck } from "./auth.actions";
import { ICreateComposioManagedConnectedAccountController } from "@/src/interface-adapters/controllers/projects/create-composio-managed-connected-account.controller";
@ -26,6 +27,7 @@ const createComposioTriggerDeploymentController = container.resolve<ICreateCompo
const listComposioTriggerDeploymentsController = container.resolve<IListComposioTriggerDeploymentsController>("listComposioTriggerDeploymentsController");
const deleteComposioTriggerDeploymentController = container.resolve<IDeleteComposioTriggerDeploymentController>("deleteComposioTriggerDeploymentController");
const listComposioTriggerTypesController = container.resolve<IListComposioTriggerTypesController>("listComposioTriggerTypesController");
const fetchComposioTriggerDeploymentController = container.resolve<IFetchComposioTriggerDeploymentController>("fetchComposioTriggerDeploymentController");
const deleteComposioConnectedAccountController = container.resolve<IDeleteComposioConnectedAccountController>("deleteComposioConnectedAccountController");
const createComposioManagedConnectedAccountController = container.resolve<ICreateComposioManagedConnectedAccountController>("createComposioManagedConnectedAccountController");
const createCustomConnectedAccountController = container.resolve<ICreateCustomConnectedAccountController>("createCustomConnectedAccountController");
@ -133,7 +135,6 @@ export async function listComposioTriggerTypes(toolkitSlug: string, cursor?: str
export async function createComposioTriggerDeployment(request: {
projectId: string,
toolkitSlug: string,
triggerTypeSlug: string,
connectedAccountId: string,
triggerConfig?: Record<string, unknown>,
@ -144,9 +145,8 @@ export async function createComposioTriggerDeployment(request: {
return await createComposioTriggerDeploymentController.execute({
caller: 'user',
userId: user._id,
projectId: request.projectId,
data: {
projectId: request.projectId,
toolkitSlug: request.toolkitSlug,
triggerTypeSlug: request.triggerTypeSlug,
connectedAccountId: request.connectedAccountId,
triggerConfig: request.triggerConfig ?? {},
@ -182,4 +182,13 @@ export async function deleteComposioTriggerDeployment(request: {
projectId: request.projectId,
deploymentId: request.deploymentId,
});
}
export async function fetchComposioTriggerDeployment(request: { deploymentId: string }) {
const user = await authCheck();
return await fetchComposioTriggerDeploymentController.execute({
caller: 'user',
userId: user._id,
deploymentId: request.deploymentId,
});
}

View file

@ -1,6 +1,6 @@
'use client';
import { Progress, Badge, Chip } from "@heroui/react";
import { Progress, Badge, Chip, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Label } from "@/app/lib/components/label";
import { Customer, UsageResponse } from "@/app/lib/types/billing_types";
@ -11,6 +11,9 @@ import { HorizontalDivider } from "@/components/ui/horizontal-divider";
import { WithStringId } from "@/app/lib/types/types";
import clsx from 'clsx';
import { getCustomerPortalUrl } from "../actions/billing.actions";
import { useState } from "react";
import { ArrowUpCircle } from "lucide-react";
import { BillingUpgradeModal } from "@/components/common/billing-upgrade-modal";
const planDetails = {
free: {
@ -46,6 +49,14 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
const plan = customer.subscriptionPlan || "free";
const displayStatus = getDisplayStatus(customer.subscriptionStatus);
const planInfo = planDetails[plan];
const [loading, setLoading] = useState(false);
const [upgradeModalOpen, setUpgradeModalOpen] = useState(false);
const [upgradeError, setUpgradeError] = useState("");
// show friendly values for credits
const sanctionedCredits = Math.floor(usage.sanctionedCredits / (10 ** 6));
const availableCredits = Math.floor(usage.availableCredits / (10 ** 6));
const usedCredits = Math.ceil((usage.sanctionedCredits - usage.availableCredits) / (10 ** 6));
// Prepare usage metrics data
const usageData = Object.entries(usage.usage)
@ -57,6 +68,7 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
.sort((a, b) => b.credits - a.credits);
async function handleManageSubscription() {
setLoading(true);
const returnUrl = new URL('/billing/callback', window.location.origin);
returnUrl.searchParams.set('redirect', window.location.href);
const url = await getCustomerPortalUrl(returnUrl.toString());
@ -105,15 +117,34 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
</Chip>
</div>
</div>
<form action={handleManageSubscription}>
<Button
variant="primary"
size="md"
type="submit"
<div className="flex flex-col items-end gap-2 min-w-[200px]">
{(plan === "free" || plan === "starter") && (
<Button
variant="primary"
size="lg"
className="bg-gradient-to-r from-indigo-200 via-purple-200 to-pink-100 text-indigo-700 dark:text-indigo-200 shadow hover:from-indigo-300 hover:to-pink-200 rounded-md border border-indigo-200 dark:border-indigo-700"
startContent={<ArrowUpCircle className="w-5 h-5" />}
onClick={() => setUpgradeModalOpen(true)}
>
Upgrade Now
</Button>
)}
{!loading && <a
href="#"
className="text-xs text-gray-500 underline hover:text-indigo-600 mt-1"
onClick={async (e) => {
e.preventDefault();
try {
await handleManageSubscription();
} catch (err) {
setUpgradeError("Failed to open subscription portal");
}
}}
>
Manage Subscription
</Button>
</form>
</a>}
{loading && <Spinner size="sm" />}
</div>
</div>
</div>
</section>
@ -136,7 +167,7 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
{usage.sanctionedCredits.toLocaleString()}
{sanctionedCredits.toLocaleString()}
</p>
<p className={clsx(
tokens.typography.sizes.sm,
@ -154,7 +185,7 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
tokens.colors.light.text.primary,
tokens.colors.dark.text.primary
)}>
{(usage.sanctionedCredits - usage.availableCredits).toLocaleString()}
{usedCredits.toLocaleString()}
</p>
<p className={clsx(
tokens.typography.sizes.sm,
@ -174,7 +205,7 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
tokens.colors.dark.text.primary
)
)}>
{usage.availableCredits.toLocaleString()}
{availableCredits.toLocaleString()}
</p>
<p className={clsx(
tokens.typography.sizes.sm,
@ -237,7 +268,7 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
<section className="card">
<div className="px-4 pt-4 pb-6">
<SectionHeading>
Usage data
Usage split
</SectionHeading>
</div>
<HorizontalDivider />
@ -261,13 +292,13 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
<div className="flex justify-between items-center">
<div className="space-y-1">
<Label label={type.replace(/_/g, ' ')} />
<p className={clsx(
{/* <p className={clsx(
tokens.typography.sizes.sm,
tokens.colors.light.text.secondary,
tokens.colors.dark.text.secondary
)}>
{credits.toLocaleString()} credits
</p>
</p> */}
</div>
<span className={clsx(
tokens.typography.sizes.sm,
@ -289,6 +320,11 @@ export function BillingPage({ customer, usage }: BillingPageProps) {
)}
</div>
</section>
<BillingUpgradeModal
isOpen={upgradeModalOpen}
onClose={() => setUpgradeModalOpen(false)}
errorMessage={upgradeError}
/>
</div>
);
}

View file

@ -0,0 +1,171 @@
'use client';
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Spinner } from "@heroui/react";
import { Panel } from "@/components/common/panel-common";
import { Button } from "@/components/ui/button";
import { ArrowLeftIcon, Trash2Icon } from "lucide-react";
import { z } from "zod";
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
import { deleteComposioTriggerDeployment, fetchComposioTriggerDeployment } from "@/app/actions/composio.actions";
import { JobsList } from "@/app/projects/[projectId]/jobs/components/jobs-list";
import { JobFiltersSchema } from "@/src/application/repositories/jobs.repository.interface";
export function ComposioTriggerDeploymentView({ projectId, deploymentId }: { projectId: string; deploymentId: string; }) {
const [deployment, setDeployment] = useState<z.infer<typeof ComposioTriggerDeployment> | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [deleting, setDeleting] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const jobsFilters = useMemo(() => ({ composioTriggerDeploymentId: deploymentId } satisfies z.infer<typeof JobFiltersSchema>), [deploymentId]);
useEffect(() => {
let ignore = false;
(async () => {
setLoading(true);
try {
const res = await fetchComposioTriggerDeployment({ deploymentId });
if (ignore) return;
setDeployment(res);
} finally {
if (!ignore) setLoading(false);
}
})();
return () => { ignore = true; };
}, [deploymentId]);
const title = useMemo(() => {
if (!deployment) return 'External Trigger';
return `External Trigger ${deployment.id}`;
}, [deployment]);
const formatDate = (iso: string) => new Date(iso).toLocaleString();
const handleDelete = async () => {
if (!deployment) return;
setDeleting(true);
try {
await deleteComposioTriggerDeployment({ projectId, deploymentId: deployment.id });
window.location.href = `/projects/${projectId}/job-rules`;
} catch (e) {
console.error(e);
alert('Failed to delete trigger');
} finally {
setDeleting(false);
setShowDeleteConfirm(false);
}
};
return (
<>
<Panel
title={
<div className="flex items-center gap-3">
<Link href={`/projects/${projectId}/job-rules`}>
<Button variant="secondary" size="sm">
<ArrowLeftIcon className="w-4 h-4 mr-2" />
Back
</Button>
</Link>
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</div>
</div>
}
rightActions={
<div className="flex items-center gap-3">
<Button
onClick={() => setShowDeleteConfirm(true)}
variant="secondary"
size="sm"
className="flex items-center gap-2 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"
>
<Trash2Icon className="w-4 h-4" />
Delete
</Button>
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
{loading && (
<div className="flex items-center gap-2">
<Spinner size="sm" />
<div>Loading...</div>
</div>
)}
{!loading && deployment && (
<div className="flex flex-col gap-6">
<div className="bg-gray-50 dark:bg-gray-800/50 p-4 rounded-lg border border-gray-200 dark:border-gray-700">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Deployment ID:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{deployment.id}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Trigger Type:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{deployment.triggerTypeSlug}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Toolkit:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{deployment.toolkitSlug}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Connected Account:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{deployment.connectedAccountId}</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">{formatDate(deployment.createdAt)}</span>
</div>
<div>
<span className="font-semibold text-gray-700 dark:text-gray-300">Updated:</span>
<span className="ml-2 font-mono text-gray-600 dark:text-gray-400">{formatDate(deployment.updatedAt)}</span>
</div>
<div className="col-span-2">
<span className="font-semibold text-gray-700 dark:text-gray-300">Trigger Config:</span>
<pre className="mt-2 bg-gray-100 dark:bg-gray-900 p-3 rounded text-xs overflow-x-auto border border-gray-200 dark:border-gray-700 font-mono">
{JSON.stringify(deployment.triggerConfig, null, 2)}
</pre>
</div>
</div>
</div>
<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 Trigger</h3>
<JobsList projectId={projectId} filters={jobsFilters} showTitle={false} />
</div>
</div>
)}
{!loading && !deployment && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
<div className="text-sm font-mono">Trigger deployment not found.</div>
</div>
)}
</div>
</div>
</Panel>
{showDeleteConfirm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md mx-4">
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Delete External Trigger</h3>
<p className="text-sm text-gray-600 dark:text-gray-400 mb-6">Are you sure you want to delete this external trigger? This will remove the linked webhook in Composio and delete this deployment.</p>
<div className="flex gap-3 justify-end">
<Button variant="secondary" onClick={() => setShowDeleteConfirm(false)} disabled={deleting}>Cancel</Button>
<Button
variant="secondary"
onClick={handleDelete}
disabled={deleting}
className="flex items-center gap-2 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"
>
{deleting ? (<><Spinner size="sm" /> Deleting...</>) : (<><Trash2Icon className="w-4 h-4" /> Delete</>)}
</Button>
</div>
</div>
</div>
)}
</>
);
}

View file

@ -4,9 +4,10 @@ import { useState } from "react";
import { Tabs, Tab } from "@/components/ui/tabs";
import { ScheduledJobRulesList } from "../scheduled/components/scheduled-job-rules-list";
import { RecurringJobRulesList } from "./recurring-job-rules-list";
import { TriggersTab } from "./triggers-tab";
export function JobRulesTabs({ projectId }: { projectId: string }) {
const [activeTab, setActiveTab] = useState<string>("scheduled");
const [activeTab, setActiveTab] = useState<string>("triggers");
const handleTabChange = (key: React.Key) => {
setActiveTab(key.toString());
@ -20,10 +21,13 @@ export function JobRulesTabs({ projectId }: { projectId: string }) {
aria-label="Job Rules"
fullWidth
>
<Tab key="scheduled" title="Scheduled Rules">
<Tab key="triggers" title="External Triggers">
<TriggersTab projectId={projectId} />
</Tab>
<Tab key="scheduled" title="One-Time Triggers">
<ScheduledJobRulesList projectId={projectId} />
</Tab>
<Tab key="recurring" title="Recurring Rules">
<Tab key="recurring" title="Recurring Triggers">
<RecurringJobRulesList projectId={projectId} />
</Tab>
</Tabs>

View file

@ -4,11 +4,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { listRecurringJobRules } from "@/app/actions/recurring-job-rules.actions";
import { listRecurringJobRules, deleteRecurringJobRule } from "@/app/actions/recurring-job-rules.actions";
import { z } from "zod";
import { ListedRecurringRuleItem } from "@/src/application/repositories/recurring-job-rules.repository.interface";
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
import { PlusIcon } from "lucide-react";
import { PlusIcon, Trash2 } from "lucide-react";
type ListedItem = z.infer<typeof ListedRecurringRuleItem>;
@ -18,6 +18,7 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
const [loading, setLoading] = useState<boolean>(true);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const [deletingRule, setDeletingRule] = useState<string | null>(null);
const fetchPage = useCallback(async (cursorArg?: string | null) => {
const res = await listRecurringJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
@ -48,6 +49,24 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
setLoadingMore(false);
}, [cursor, fetchPage]);
const handleDeleteRule = async (ruleId: string) => {
if (!window.confirm('Are you sure you want to delete this recurring trigger?')) {
return;
}
try {
setDeletingRule(ruleId);
await deleteRecurringJobRule({ projectId, ruleId });
// Remove the deleted item from the list
setItems(prev => prev.filter(item => item.id !== ruleId));
} catch (err: any) {
console.error('Error deleting recurring trigger:', err);
alert('Failed to delete recurring trigger. Please try again.');
} finally {
setDeletingRule(null);
}
};
const sections = useMemo(() => {
const groups: Record<string, ListedItem[]> = {
Today: [],
@ -109,18 +128,15 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
return (
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
RECURRING JOB RULES
</div>
<div className="text-base font-normal text-gray-900 dark:text-gray-100">
Run your assistant workflow on an automated repeating schedule (cron jobs).
</div>
}
rightActions={
<div className="flex items-center gap-3">
<Link href={`/projects/${projectId}/job-rules/recurring/new`}>
<Button size="sm" className="flex items-center gap-2">
<PlusIcon className="w-4 h-4" />
New Rule
<Button size="sm" className="whitespace-nowrap" startContent={<PlusIcon className="w-4 h-4" />}>
New Recurring Trigger
</Button>
</Link>
</div>
@ -145,38 +161,48 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
</h3>
<div className="grid gap-3">
{sectionItems.map((item) => (
<Link
<div
key={item.id}
href={`/projects/${projectId}/job-rules/recurring/${item.id}`}
className="block p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className={`text-sm font-medium ${getStatusColor(item.disabled, item.lastError || null)}`}>
{getStatusText(item.disabled, item.lastError || null)}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
Next run: {formatNextRunAt(item.nextRunAt)}
</span>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
Schedule: {formatCronExpression(item.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Created: {new Date(item.createdAt).toLocaleDateString()}
</div>
{item.lastError && (
<div className="text-sm text-red-600 dark:text-red-400 mt-1">
Last error: {item.lastError}
<Link
href={`/projects/${projectId}/job-rules/recurring/${item.id}`}
className="block"
>
<div className="flex items-center gap-3 mb-2">
<span className={`text-sm font-medium ${getStatusColor(item.disabled, item.lastError || null)}`}>
{getStatusText(item.disabled, item.lastError || null)}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
Next run: {formatNextRunAt(item.nextRunAt)}
</span>
</div>
)}
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{new Date(item.createdAt).toLocaleDateString()}
<div className="text-sm text-gray-600 dark:text-gray-400 mb-1">
Schedule: {formatCronExpression(item.cron)}
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Created: {new Date(item.createdAt).toLocaleDateString()}
</div>
{item.lastError && (
<div className="text-sm text-red-600 dark:text-red-400 mt-1">
Last error: {item.lastError}
</div>
)}
</Link>
</div>
<Button
variant="tertiary"
size="sm"
isLoading={deletingRule === item.id}
onClick={() => handleDeleteRule(item.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Link>
</div>
))}
</div>
</div>
@ -184,7 +210,7 @@ export function RecurringJobRulesList({ projectId }: { projectId: string }) {
})}
{items.length === 0 && !loading && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No recurring job rules found. Create your first rule to get started.
No recurring triggers yet. Create your first recurring trigger to get started.
</div>
)}
{hasMore && (

View file

@ -0,0 +1,518 @@
'use client';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { Spinner, Link } from '@heroui/react';
import { Button } from '@/components/ui/button';
import { Panel } from '@/components/common/panel-common';
import { Plus, Trash2, ZapIcon, ChevronDown, ChevronUp } from 'lucide-react';
import { z } from 'zod';
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
import { isToday, isThisWeek, isThisMonth } from '@/lib/utils/date';
import { listComposioTriggerDeployments, deleteComposioTriggerDeployment, createComposioTriggerDeployment, listComposioTriggerTypes } from '@/app/actions/composio.actions';
import { SelectComposioToolkit } from '../../tools/components/SelectComposioToolkit';
import { ComposioTriggerTypesPanel } from '../../workflow/components/ComposioTriggerTypesPanel';
import { TriggerConfigForm } from '../../workflow/components/TriggerConfigForm';
import { ToolkitAuthModal } from '../../tools/components/ToolkitAuthModal';
import { ZToolkit } from "@/src/application/lib/composio/types";
import { Project } from "@/src/entities/models/project";
import { fetchProject } from '@/app/actions/project.actions';
type TriggerDeployment = z.infer<typeof ComposioTriggerDeployment>;
export function TriggersTab({ projectId }: { projectId: string }) {
const [triggers, setTriggers] = useState<TriggerDeployment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateFlow, setShowCreateFlow] = useState(false);
const [selectedToolkit, setSelectedToolkit] = useState<z.infer<typeof ZToolkit> | null>(null);
const [selectedTriggerType, setSelectedTriggerType] = useState<z.infer<typeof ComposioTriggerType> | null>(null);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isSubmittingTrigger, setIsSubmittingTrigger] = useState(false);
const [deletingTrigger, setDeletingTrigger] = useState<string | null>(null);
const [projectConfig, setProjectConfig] = useState<z.infer<typeof Project> | null>(null);
const [triggerTypeNames, setTriggerTypeNames] = useState<Record<string, string>>({});
const [expandedTrigger, setExpandedTrigger] = useState<string | null>(null);
const [cursor, setCursor] = useState<string | null>(null);
const [hasMore, setHasMore] = useState<boolean>(false);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const loadProjectConfig = useCallback(async () => {
try {
const config = await fetchProject(projectId);
setProjectConfig(config);
} catch (err: any) {
console.error('Error fetching project config:', err);
}
}, [projectId]);
const loadTriggerTypeNames = useCallback(async () => {
try {
const names: Record<string, string> = {};
// Get unique toolkit slugs from existing triggers
const uniqueToolkits = [...new Set(triggers.map(t => t.toolkitSlug))];
// Fetch trigger types for each toolkit
for (const toolkitSlug of uniqueToolkits) {
try {
const response = await listComposioTriggerTypes(toolkitSlug);
response.items.forEach(triggerType => {
names[triggerType.slug] = triggerType.name;
});
} catch (err) {
console.error(`Error fetching trigger types for ${toolkitSlug}:`, err);
}
}
setTriggerTypeNames(names);
} catch (err: any) {
console.error('Error loading trigger type names:', err);
}
}, [triggers]);
const sections = useMemo(() => {
const groups: Record<string, TriggerDeployment[]> = {
Today: [],
'This week': [],
'This month': [],
Older: [],
};
for (const trigger of triggers) {
const d = new Date(trigger.createdAt);
if (isToday(d)) groups['Today'].push(trigger);
else if (isThisWeek(d)) groups['This week'].push(trigger);
else if (isThisMonth(d)) groups['This month'].push(trigger);
else groups['Older'].push(trigger);
}
return groups;
}, [triggers]);
const loadTriggers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await listComposioTriggerDeployments({ projectId });
setTriggers(response.items);
setCursor(response.nextCursor);
setHasMore(Boolean(response.nextCursor));
} catch (err: any) {
console.error('Error loading triggers:', err);
setError('Failed to load triggers. Please try again.');
} finally {
setLoading(false);
}
}, [projectId]);
const loadMore = useCallback(async () => {
if (!cursor) return;
setLoadingMore(true);
try {
const response = await listComposioTriggerDeployments({ projectId, cursor });
setTriggers(prev => [...prev, ...response.items]);
setCursor(response.nextCursor);
setHasMore(Boolean(response.nextCursor));
} catch (err: any) {
console.error('Error loading more triggers:', err);
} finally {
setLoadingMore(false);
}
}, [cursor, projectId]);
const handleDeleteTrigger = async (deploymentId: string) => {
if (!window.confirm('Are you sure you want to delete this trigger?')) {
return;
}
try {
setDeletingTrigger(deploymentId);
await deleteComposioTriggerDeployment({ projectId, deploymentId });
await loadTriggers(); // Reload the list
} catch (err: any) {
console.error('Error deleting trigger:', err);
setError('Failed to delete trigger. Please try again.');
} finally {
setDeletingTrigger(null);
}
};
const handleCreateNew = () => {
setShowCreateFlow(true);
};
const handleBackToList = () => {
setShowCreateFlow(false);
setSelectedToolkit(null);
setSelectedTriggerType(null);
setShowAuthModal(false);
setIsSubmittingTrigger(false);
setExpandedTrigger(null); // Reset expanded state
loadTriggers(); // Reload in case any triggers were created
};
const handleSelectToolkit = (toolkit: z.infer<typeof ZToolkit>) => {
setSelectedToolkit(toolkit);
};
const handleBackToToolkitSelection = () => {
setSelectedToolkit(null);
setSelectedTriggerType(null);
setIsSubmittingTrigger(false);
};
const handleSelectTriggerType = (triggerType: z.infer<typeof ComposioTriggerType>) => {
if (!selectedToolkit) return;
setSelectedTriggerType(triggerType);
// Check if toolkit requires auth and if connected account exists
const needsAuth = !selectedToolkit.no_auth;
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
if (needsAuth && !hasConnection) {
// Show auth modal
setShowAuthModal(true);
} else {
// Proceed to trigger configuration
// For now this is just the placeholder, but will be actual config later
}
};
const handleAuthComplete = async () => {
setShowAuthModal(false);
await loadProjectConfig(); // Refresh project config
};
const handleTriggerSubmit = async (triggerConfig: Record<string, unknown>) => {
if (!selectedToolkit || !selectedTriggerType) return;
try {
setIsSubmittingTrigger(true);
// Get the connected account ID for this toolkit
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.id;
if (!connectedAccountId) {
throw new Error('No connected account found for this toolkit');
}
// Create the trigger deployment
await createComposioTriggerDeployment({
projectId,
triggerTypeSlug: selectedTriggerType.slug,
connectedAccountId,
triggerConfig,
});
// Success! Go back to triggers list and reload
handleBackToList();
} catch (err: any) {
console.error('Error creating trigger:', err);
setError('Failed to create trigger. Please try again.');
} finally {
setIsSubmittingTrigger(false);
}
};
useEffect(() => {
loadProjectConfig();
}, [loadProjectConfig]);
useEffect(() => {
if (!showCreateFlow) {
loadTriggers();
}
}, [showCreateFlow, loadTriggers]);
useEffect(() => {
if (triggers.length > 0) {
loadTriggerTypeNames();
}
}, [triggers, loadTriggerTypeNames]);
const renderTriggerList = () => {
if (loading) {
return (
<Panel
title={
<div className="text-base font-normal text-gray-900 dark:text-gray-100">
Loading your triggers
</div>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
<span className="ml-2">Loading triggers...</span>
</div>
</div>
</div>
</Panel>
);
}
if (error) {
return (
<Panel
title={
<div className="text-base font-normal text-gray-900 dark:text-gray-100">
Error loading your triggers
</div>
}
rightActions={
<Button variant="secondary" onClick={loadTriggers}>
Try Again
</Button>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
<div className="text-center py-8">
<p className="text-red-500 mb-4">{error}</p>
</div>
</div>
</div>
</Panel>
);
}
if (triggers.length === 0) {
return (
<Panel
title={
<div className="text-base font-normal text-gray-900 dark:text-gray-100">
Listen for events from connected apps to run your assistant workflow automatically.
</div>
}
rightActions={
<Button
variant="primary"
startContent={<Plus className="w-4 h-4" />}
onClick={handleCreateNew}
className="whitespace-nowrap"
>
New External Trigger
</Button>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
<div className="text-center py-12">
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No external triggers yet
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6">
Create your first external trigger to listen for events from your connected apps.
</p>
</div>
</div>
</div>
</Panel>
);
}
return (
<Panel
title={
<div className="text-base font-normal text-gray-900 dark:text-gray-100">
Listen for events from connected apps to run your assistant workflow automatically.
</div>
}
rightActions={
<Button
variant="primary"
startContent={<Plus className="w-4 h-4" />}
onClick={handleCreateNew}
className="whitespace-nowrap"
>
New External Trigger
</Button>
}
>
<div className="h-full overflow-auto px-4 py-4">
<div className="max-w-[1024px] mx-auto">
<div className="flex flex-col gap-6">
{Object.entries(sections).map(([sectionName, sectionTriggers]) => {
if (sectionTriggers.length === 0) return null;
return (
<div key={sectionName} className="space-y-3">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{sectionName}
</h3>
<div className="grid gap-3">
{sectionTriggers.map((trigger) => (
<div
key={trigger.id}
className="block p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex items-start justify-between">
<div className="flex-1">
<a href={`/projects/${projectId}/job-rules/triggers/${trigger.id}`} className="block">
<div className="flex items-center gap-3 mb-2">
<span className="text-sm font-medium text-green-600 dark:text-green-400">
Active
</span>
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">
{triggerTypeNames[trigger.triggerTypeSlug] || trigger.triggerTypeSlug}
</span>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
Created: {new Date(trigger.createdAt).toLocaleDateString()}
</div>
{Object.keys(trigger.triggerConfig).length > 0 && (
<div className="text-sm text-gray-600 dark:text-gray-400 mt-1">
Configuration: {Object.keys(trigger.triggerConfig).length} settings
</div>
)}
</a>
</div>
<Button
variant="tertiary"
size="sm"
isLoading={deletingTrigger === trigger.id}
onClick={() => handleDeleteTrigger(trigger.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
{/* Advanced Details Section - Collapsible */}
<div className="mt-3">
<button
onClick={() => setExpandedTrigger(expandedTrigger === trigger.id ? null : trigger.id)}
className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
>
<span className="font-medium">Advanced Details</span>
{expandedTrigger === trigger.id ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
)}
</button>
{expandedTrigger === trigger.id && (
<div className="mt-2 space-y-1">
<div className="text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">Slug:</span> {trigger.triggerTypeSlug}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">Trigger ID:</span> {trigger.triggerId}
</div>
<div className="text-xs text-gray-600 dark:text-gray-400">
<span className="font-medium">Connected Account:</span> {trigger.connectedAccountId}
</div>
</div>
)}
</div>
</div>
))}
</div>
</div>
);
})}
{hasMore && (
<div className="text-center">
<Button
onClick={loadMore}
disabled={loadingMore}
variant="secondary"
size="sm"
>
{loadingMore ? (
<>
<Spinner size="sm" />
Loading...
</>
) : (
'Load More'
)}
</Button>
</div>
)}
</div>
</div>
</div>
</Panel>
);
};
const renderCreateFlow = () => {
// If trigger type is selected and auth is complete, show config
if (selectedToolkit && selectedTriggerType && !showAuthModal) {
const needsAuth = !selectedToolkit.no_auth;
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
if (!needsAuth || hasConnection) {
return (
<TriggerConfigForm
toolkit={selectedToolkit}
triggerType={selectedTriggerType}
onBack={handleBackToToolkitSelection}
onSubmit={handleTriggerSubmit}
isSubmitting={isSubmittingTrigger}
/>
);
}
}
// If no toolkit selected, show toolkit selection
if (!selectedToolkit) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Select a Toolkit to Create Trigger
</h3>
<Button
variant="secondary"
onClick={handleBackToList}
>
Back to Triggers
</Button>
</div>
<SelectComposioToolkit
projectId={projectId}
tools={[]} // Empty array since we're not using this for tools
onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={null}
filterByTriggers={true}
/>
</div>
);
}
// If toolkit selected, show trigger types
return (
<div className="space-y-4">
<ComposioTriggerTypesPanel
toolkit={selectedToolkit}
onBack={handleBackToToolkitSelection}
onSelectTriggerType={handleSelectTriggerType}
/>
</div>
);
};
return (
<>
{showCreateFlow ? renderCreateFlow() : renderTriggerList()}
{/* Auth Modal */}
{selectedToolkit && (
<ToolkitAuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={selectedToolkit.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
)}
</>
);
}

View file

@ -3,7 +3,7 @@ import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { JobRulesTabs } from "./components/job-rules-tabs";
export const metadata: Metadata = {
title: "Job Rules",
title: "Triggers",
};
export default async function Page(

View file

@ -4,11 +4,11 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Link, Spinner } from "@heroui/react";
import { Button } from "@/components/ui/button";
import { Panel } from "@/components/common/panel-common";
import { listScheduledJobRules } from "@/app/actions/scheduled-job-rules.actions";
import { listScheduledJobRules, deleteScheduledJobRule } from "@/app/actions/scheduled-job-rules.actions";
import { z } from "zod";
import { ListedRuleItem } from "@/src/application/repositories/scheduled-job-rules.repository.interface";
import { isToday, isThisWeek, isThisMonth } from "@/lib/utils/date";
import { PlusIcon } from "lucide-react";
import { PlusIcon, Trash2 } from "lucide-react";
type ListedItem = z.infer<typeof ListedRuleItem>;
@ -18,6 +18,7 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
const [loading, setLoading] = useState<boolean>(true);
const [loadingMore, setLoadingMore] = useState<boolean>(false);
const [hasMore, setHasMore] = useState<boolean>(false);
const [deletingRule, setDeletingRule] = useState<string | null>(null);
const fetchPage = useCallback(async (cursorArg?: string | null) => {
const res = await listScheduledJobRules({ projectId, cursor: cursorArg ?? undefined, limit: 20 });
@ -48,6 +49,24 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
setLoadingMore(false);
}, [cursor, fetchPage]);
const handleDeleteRule = async (ruleId: string) => {
if (!window.confirm('Are you sure you want to delete this one-time trigger?')) {
return;
}
try {
setDeletingRule(ruleId);
await deleteScheduledJobRule({ projectId, ruleId });
// Remove the deleted item from the list
setItems(prev => prev.filter(item => item.id !== ruleId));
} catch (err: any) {
console.error('Error deleting one-time trigger:', err);
alert('Failed to delete one-time trigger. Please try again.');
} finally {
setDeletingRule(null);
}
};
const sections = useMemo(() => {
const groups: Record<string, ListedItem[]> = {
Today: [],
@ -87,18 +106,15 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
return (
<Panel
title={
<div className="flex items-center gap-3">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
SCHEDULED JOB RULES
</div>
<div className="text-base font-normal text-gray-900 dark:text-gray-100">
Schedule a single job to run your assistant workflow at a specific date and time.
</div>
}
rightActions={
<div className="flex items-center gap-3">
<Link href={`/projects/${projectId}/job-rules/scheduled/new`}>
<Button size="sm" className="flex items-center gap-2">
<PlusIcon className="w-4 h-4" />
New Rule
<Button size="sm" className="whitespace-nowrap" startContent={<PlusIcon className="w-4 h-4" />}>
New One-time Trigger
</Button>
</Link>
</div>
@ -123,30 +139,40 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
</h3>
<div className="grid gap-3">
{sectionItems.map((item) => (
<Link
<div
key={item.id}
href={`/projects/${projectId}/job-rules/scheduled/${item.id}`}
className="block p-4 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
>
<div className="flex items-center justify-between">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className={`text-sm font-medium ${getStatusColor(item.status, item.processedAt || null)}`}>
{getStatusText(item.status, item.processedAt || null)}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
Next run: {formatNextRunAt(item.nextRunAt)}
</span>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Created: {new Date(item.createdAt).toLocaleDateString()}
</div>
</div>
<div className="text-sm text-gray-500 dark:text-gray-400">
{new Date(item.createdAt).toLocaleDateString()}
<Link
href={`/projects/${projectId}/job-rules/scheduled/${item.id}`}
className="block"
>
<div className="flex items-center gap-3 mb-2">
<span className={`text-sm font-medium ${getStatusColor(item.status, item.processedAt || null)}`}>
{getStatusText(item.status, item.processedAt || null)}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">
Next run: {formatNextRunAt(item.nextRunAt)}
</span>
</div>
<div className="text-sm text-gray-600 dark:text-gray-400">
Created: {new Date(item.createdAt).toLocaleDateString()}
</div>
</Link>
</div>
<Button
variant="tertiary"
size="sm"
isLoading={deletingRule === item.id}
onClick={() => handleDeleteRule(item.id)}
className="text-red-600 hover:text-red-700 hover:bg-red-50 dark:text-red-400 dark:hover:text-red-300 dark:hover:bg-red-950"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
</Link>
</div>
))}
</div>
</div>
@ -154,7 +180,7 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
})}
{items.length === 0 && !loading && (
<div className="text-center py-8 text-gray-500 dark:text-gray-400">
No scheduled job rules found. Create your first rule to get started.
No one-time triggers yet. Create your first one-time trigger to get started.
</div>
)}
{hasMore && (
@ -183,3 +209,4 @@ export function ScheduledJobRulesList({ projectId }: { projectId: string }) {
</Panel>
);
}

View file

@ -0,0 +1,19 @@
import { Metadata } from "next";
import { requireActiveBillingSubscription } from '@/app/lib/billing';
import { ComposioTriggerDeploymentView } from "../../components/composio-trigger-deployment-view";
export const metadata: Metadata = {
title: "External Trigger",
};
export default async function Page(
props: {
params: Promise<{ projectId: string; deploymentId: string }>
}
) {
const params = await props.params;
await requireActiveBillingSubscription();
return <ComposioTriggerDeploymentView projectId={params.projectId} deploymentId={params.deploymentId} />;
}

View file

@ -55,7 +55,7 @@ export function JobView({ projectId, jobId }: { projectId: string; jobId: string
'Deployment ID': reason.triggerDeploymentId,
},
payload: reason.payload,
link: null
link: reason.triggerDeploymentId ? `/projects/${projectId}/job-rules/triggers/${reason.triggerDeploymentId}` : null
};
}
if (reason.type === 'scheduled_job_rule') {

View file

@ -99,7 +99,7 @@ export function JobsList({ projectId, filters, showTitle = true, customTitle }:
return {
type: 'Composio Trigger',
display: `Composio: ${reason.triggerTypeSlug}`,
link: null
link: reason.triggerDeploymentId ? `/projects/${projectId}/job-rules/triggers/${reason.triggerDeploymentId}` : null
};
}
if (reason.type === 'scheduled_job_rule') {

View file

@ -8,7 +8,7 @@ interface TopBarProps {
localProjectName: string;
projectNameError: string | null;
onProjectNameChange: (value: string) => void;
onProjectNameCommit: (value: string) => void;
onProjectNameCommit: (value: string) => Promise<void>;
publishing: boolean;
isLive: boolean;
showCopySuccess: boolean;
@ -23,7 +23,6 @@ interface TopBarProps {
onRevertToLive: () => void;
onToggleCopilot: () => void;
onSettingsModalOpen: () => void;
onTriggersModalOpen: () => void;
}
export function TopBar({
@ -45,7 +44,6 @@ export function TopBar({
onRevertToLive,
onToggleCopilot,
onSettingsModalOpen,
onTriggersModalOpen,
}: TopBarProps) {
const router = useRouter();
const params = useParams();
@ -168,16 +166,9 @@ export function TopBar({
<DropdownItem
key="manage-triggers"
startContent={<ZapIcon size={16} />}
onPress={onTriggersModalOpen}
>
Manage triggers
</DropdownItem>
<DropdownItem
key="go-to-schedule-runs"
startContent={<Clock size={16} />}
onPress={() => { if (projectId) { router.push(`/projects/${projectId}/job-rules`); } }}
>
Go to schedule runs
Manage triggers
</DropdownItem>
{!isLive ? (
<>

View file

@ -1,360 +0,0 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Button, Spinner, Card, CardBody, CardHeader } from '@heroui/react';
import { Plus, Trash2, ZapIcon } from 'lucide-react';
import { z } from 'zod';
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
import { ComposioTriggerType } from '@/src/entities/models/composio-trigger-type';
import { listComposioTriggerDeployments, deleteComposioTriggerDeployment, createComposioTriggerDeployment } from '@/app/actions/composio.actions';
import { SelectComposioToolkit } from '../../tools/components/SelectComposioToolkit';
import { ComposioTriggerTypesPanel } from './ComposioTriggerTypesPanel';
import { TriggerConfigForm } from './TriggerConfigForm';
import { ToolkitAuthModal } from '../../tools/components/ToolkitAuthModal';
import { ZToolkit } from "@/src/application/lib/composio/types";
import { Project } from "@/src/entities/models/project";
interface TriggersModalProps {
isOpen: boolean;
onClose: () => void;
projectId: string;
projectConfig: z.infer<typeof Project>;
onProjectConfigUpdated?: () => void;
}
type TriggerDeployment = z.infer<typeof ComposioTriggerDeployment>;
export function TriggersModal({
isOpen,
onClose,
projectId,
projectConfig,
onProjectConfigUpdated,
}: TriggersModalProps) {
const [triggers, setTriggers] = useState<TriggerDeployment[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showCreateFlow, setShowCreateFlow] = useState(false);
const [selectedToolkit, setSelectedToolkit] = useState<z.infer<typeof ZToolkit> | null>(null);
const [selectedTriggerType, setSelectedTriggerType] = useState<z.infer<typeof ComposioTriggerType> | null>(null);
const [showAuthModal, setShowAuthModal] = useState(false);
const [isSubmittingTrigger, setIsSubmittingTrigger] = useState(false);
const [deletingTrigger, setDeletingTrigger] = useState<string | null>(null);
const loadTriggers = useCallback(async () => {
try {
setLoading(true);
setError(null);
const response = await listComposioTriggerDeployments({ projectId });
setTriggers(response.items);
} catch (err: any) {
console.error('Error loading triggers:', err);
setError('Failed to load triggers. Please try again.');
} finally {
setLoading(false);
}
}, [projectId]);
const handleDeleteTrigger = async (deploymentId: string) => {
if (!window.confirm('Are you sure you want to delete this trigger?')) {
return;
}
try {
setDeletingTrigger(deploymentId);
await deleteComposioTriggerDeployment({ projectId, deploymentId });
await loadTriggers(); // Reload the list
} catch (err: any) {
console.error('Error deleting trigger:', err);
setError('Failed to delete trigger. Please try again.');
} finally {
setDeletingTrigger(null);
}
};
const handleCreateNew = () => {
setShowCreateFlow(true);
};
const handleBackToList = () => {
setShowCreateFlow(false);
setSelectedToolkit(null);
setSelectedTriggerType(null);
setShowAuthModal(false);
setIsSubmittingTrigger(false);
loadTriggers(); // Reload in case any triggers were created
};
const handleSelectToolkit = (toolkit: z.infer<typeof ZToolkit>) => {
setSelectedToolkit(toolkit);
};
const handleBackToToolkitSelection = () => {
setSelectedToolkit(null);
setSelectedTriggerType(null);
setIsSubmittingTrigger(false);
};
const handleSelectTriggerType = (triggerType: z.infer<typeof ComposioTriggerType>) => {
if (!selectedToolkit) return;
setSelectedTriggerType(triggerType);
// Check if toolkit requires auth and if connected account exists
const needsAuth = !selectedToolkit.no_auth;
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
if (needsAuth && !hasConnection) {
// Show auth modal
setShowAuthModal(true);
} else {
// Proceed to trigger configuration
// For now this is just the placeholder, but will be actual config later
}
};
const handleAuthComplete = async () => {
setShowAuthModal(false);
onProjectConfigUpdated?.();
};
const handleTriggerSubmit = async (triggerConfig: Record<string, unknown>) => {
if (!selectedToolkit || !selectedTriggerType) return;
try {
setIsSubmittingTrigger(true);
// Get the connected account ID for this toolkit
const connectedAccountId = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.id;
if (!connectedAccountId) {
throw new Error('No connected account found for this toolkit');
}
// Create the trigger deployment
await createComposioTriggerDeployment({
projectId,
toolkitSlug: selectedToolkit.slug,
triggerTypeSlug: selectedTriggerType.slug,
connectedAccountId,
triggerConfig,
});
// Success! Go back to triggers list and reload
handleBackToList();
} catch (err: any) {
console.error('Error creating trigger:', err);
setError('Failed to create trigger. Please try again.');
} finally {
setIsSubmittingTrigger(false);
}
};
useEffect(() => {
if (isOpen && !showCreateFlow) {
loadTriggers();
}
}, [isOpen, showCreateFlow, loadTriggers]);
const renderTriggerList = () => {
if (loading) {
return (
<div className="flex items-center justify-center py-8">
<Spinner size="lg" />
<span className="ml-2">Loading triggers...</span>
</div>
);
}
if (error) {
return (
<div className="text-center py-8">
<p className="text-red-500 mb-4">{error}</p>
<Button variant="flat" onPress={loadTriggers}>
Try Again
</Button>
</div>
);
}
if (triggers.length === 0) {
return (
<div className="text-center py-12">
<ZapIcon className="w-16 h-16 mx-auto text-gray-400 mb-4" />
<h3 className="text-lg font-medium text-gray-900 dark:text-gray-100 mb-2">
No triggers configured
</h3>
<p className="text-gray-500 dark:text-gray-400 mb-6">
Set up your first trigger to listen for events from your connected apps.
</p>
<Button
color="primary"
variant="solid"
startContent={<Plus className="w-4 h-4" />}
onPress={handleCreateNew}
>
Create your first trigger
</Button>
</div>
);
}
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Active Triggers ({triggers.length})
</h3>
<Button
color="primary"
variant="solid"
startContent={<Plus className="w-4 h-4" />}
onPress={handleCreateNew}
>
Create New Trigger
</Button>
</div>
<div className="space-y-3">
{triggers.map((trigger) => (
<Card key={trigger.id} className="w-full">
<CardHeader className="flex justify-between items-start">
<div>
<h4 className="text-base font-medium text-gray-900 dark:text-gray-100">
{trigger.triggerTypeSlug}
</h4>
<p className="text-sm text-gray-500 dark:text-gray-400">
Created {new Date(trigger.createdAt).toLocaleDateString()}
</p>
</div>
<Button
isIconOnly
variant="light"
color="danger"
size="sm"
isLoading={deletingTrigger === trigger.id}
onPress={() => handleDeleteTrigger(trigger.id)}
>
<Trash2 className="w-4 h-4" />
</Button>
</CardHeader>
<CardBody className="pt-0">
<div className="text-sm text-gray-600 dark:text-gray-300">
<p><strong>Trigger ID:</strong> {trigger.triggerId}</p>
<p><strong>Connected Account:</strong> {trigger.connectedAccountId}</p>
{Object.keys(trigger.triggerConfig).length > 0 && (
<div className="mt-2">
<strong>Configuration:</strong>
<pre className="mt-1 text-xs bg-gray-100 dark:bg-gray-800 p-2 rounded">
{JSON.stringify(trigger.triggerConfig, null, 2)}
</pre>
</div>
)}
</div>
</CardBody>
</Card>
))}
</div>
</div>
);
};
const renderCreateFlow = () => {
// If trigger type is selected and auth is complete, show config
if (selectedToolkit && selectedTriggerType && !showAuthModal) {
const needsAuth = !selectedToolkit.no_auth;
const hasConnection = projectConfig?.composioConnectedAccounts?.[selectedToolkit.slug]?.status === 'ACTIVE';
if (!needsAuth || hasConnection) {
return (
<TriggerConfigForm
toolkit={selectedToolkit}
triggerType={selectedTriggerType}
onBack={handleBackToToolkitSelection}
onSubmit={handleTriggerSubmit}
isSubmitting={isSubmittingTrigger}
/>
);
}
}
// If no toolkit selected, show toolkit selection
if (!selectedToolkit) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
Select a Toolkit to Create Trigger
</h3>
<Button
variant="flat"
onPress={handleBackToList}
>
Back to Triggers
</Button>
</div>
<SelectComposioToolkit
projectId={projectId}
tools={[]} // Empty array since we're not using this for tools
onSelectToolkit={handleSelectToolkit}
initialToolkitSlug={null}
filterByTriggers={true}
/>
</div>
);
}
// If toolkit selected, show trigger types
return (
<div className="space-y-4">
<ComposioTriggerTypesPanel
toolkit={selectedToolkit}
onBack={handleBackToToolkitSelection}
onSelectTriggerType={handleSelectTriggerType}
/>
</div>
);
};
return (
<>
<Modal
isOpen={isOpen}
onClose={onClose}
size="5xl"
scrollBehavior="inside"
>
<ModalContent className="max-h-[90vh]">
<ModalHeader>
<div className="flex items-center gap-2">
<ZapIcon className="w-5 h-5" />
<span>Manage Triggers</span>
</div>
</ModalHeader>
<ModalBody>
{showCreateFlow ? renderCreateFlow() : renderTriggerList()}
</ModalBody>
{!showCreateFlow && (
<ModalFooter>
<Button variant="light" onPress={onClose}>
Close
</Button>
</ModalFooter>
)}
</ModalContent>
</Modal>
{/* Auth Modal */}
{selectedToolkit && (
<ToolkitAuthModal
isOpen={showAuthModal}
onClose={() => setShowAuthModal(false)}
toolkitSlug={selectedToolkit.slug}
projectId={projectId}
onComplete={handleAuthComplete}
/>
)}
</>
);
}

View file

@ -37,7 +37,6 @@ import { Button as CustomButton } from "@/components/ui/button";
import { ConfigApp } from "../config/app";
import { InputField } from "@/app/lib/components/input-field";
import { VoiceSection } from "../config/components/voice";
import { TriggersModal } from "./components/TriggersModal";
import { TopBar } from "./components/TopBar";
enablePatches();
@ -882,9 +881,6 @@ export function WorkflowEditor({
// Modal state for chat widget configuration
const { isOpen: isChatWidgetModalOpen, onOpen: onChatWidgetModalOpen, onClose: onChatWidgetModalClose } = useDisclosure();
// Modal state for triggers management
const { isOpen: isTriggersModalOpen, onOpen: onTriggersModalOpen, onClose: onTriggersModalClose } = useDisclosure();
// Project name state
const [localProjectName, setLocalProjectName] = useState<string>(projectConfig.name || '');
const [projectNameError, setProjectNameError] = useState<string | null>(null);
@ -1285,7 +1281,6 @@ export function WorkflowEditor({
onRevertToLive={handleRevertToLive}
onToggleCopilot={() => setShowCopilot(!showCopilot)}
onSettingsModalOpen={onSettingsModalOpen}
onTriggersModalOpen={onTriggersModalOpen}
/>
{/* Content Area */}
@ -1565,14 +1560,6 @@ export function WorkflowEditor({
</Modal>
*/}
{/* Triggers Management Modal */}
<TriggersModal
isOpen={isTriggersModalOpen}
onClose={onTriggersModalClose}
projectId={projectId}
projectConfig={projectConfig}
onProjectConfigUpdated={onProjectConfigUpdated}
/>
</div>
</EntitySelectionContext.Provider>
);

View file

@ -3,7 +3,7 @@
import { useState, useRef, useEffect } from "react";
import { listTemplates, listProjects } from "@/app/actions/project.actions";
import { createProjectWithOptions, createProjectFromJsonWithOptions, createProjectFromTemplate } from "../lib/project-creation-utils";
import { useRouter } from 'next/navigation';
import { useRouter, useSearchParams } from 'next/navigation';
import clsx from 'clsx';
import Image from 'next/image';
import mascotImage from '@/public/mascot.png';
@ -36,6 +36,8 @@ export function BuildAssistantSection() {
const [selectedTab, setSelectedTab] = useState('new');
const fileInputRef = useRef<HTMLInputElement>(null);
const router = useRouter();
const searchParams = useSearchParams();
const [autoCreateLoading, setAutoCreateLoading] = useState(false);
const totalPages = Math.ceil(projects.length / ITEMS_PER_PAGE);
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
@ -103,6 +105,29 @@ export function BuildAssistantSection() {
fetchProjects();
}, []);
// Handle URL parameters for auto-creation and direct redirect to build view
useEffect(() => {
const urlPrompt = searchParams.get('prompt');
const urlTemplate = searchParams.get('template');
if (urlPrompt || urlTemplate) {
setAutoCreateLoading(true);
createProjectWithOptions({
template: urlTemplate || undefined,
prompt: urlPrompt || undefined,
router,
onError: (error) => {
console.error('Error auto-creating project:', error);
setAutoCreateLoading(false);
// Fall back to showing the form with the prompt pre-filled
if (urlPrompt) {
setUserPrompt(urlPrompt);
}
}
});
}
}, [searchParams, router]);
const handleCreateAssistant = async () => {
setIsCreating(true);
try {
@ -170,6 +195,15 @@ export function BuildAssistantSection() {
className="hidden"
onChange={handleFileChange}
/>
{autoCreateLoading && (
<div className="flex flex-col items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-indigo-500 mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">
Creating your assistant...
</p>
</div>
)}
{!autoCreateLoading && (
<div className="px-8 py-16">
<div className="max-w-7xl mx-auto">
{/* Main Headline */}
@ -445,6 +479,7 @@ export function BuildAssistantSection() {
)}
</div>
</div>
)}
</>
);
}

View file

@ -2,7 +2,8 @@
import { useEffect, useState } from 'react';
import Link from "next/link";
import Image from "next/image";
import logoImage from '@/public/logo-only.png';
import logo from '@/public/logo.png';
import logoOnly from '@/public/logo-only.png';
import { usePathname } from "next/navigation";
import { Tooltip, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
import { UserButton } from "@/app/lib/components/user_button";
@ -17,7 +18,8 @@ import {
HelpCircle,
MessageSquareIcon,
LogsIcon,
Clock
Clock,
ZapIcon
} from "lucide-react";
import { fetchProject } from "@/app/actions/project.actions";
import { createProjectWithOptions } from "../../lib/project-creation-utils";
@ -102,6 +104,12 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
icon: WorkflowIcon,
requiresProject: true
},
{
href: 'job-rules',
label: 'Triggers',
icon: ZapIcon,
requiresProject: true
},
{
href: 'conversations',
label: 'Conversations',
@ -114,12 +122,6 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
icon: LogsIcon,
requiresProject: true
},
{
href: 'job-rules',
label: 'Job Rules',
icon: Clock,
requiresProject: true
},
{
href: 'config',
label: 'Settings',
@ -154,18 +156,17 @@ export default function Sidebar({ projectId, useAuth, collapsed = false, onToggl
${collapsed ? 'py-3' : 'gap-3 px-4 py-2.5 justify-start'}
`}
>
<Image
src={logoImage}
{collapsed && <Image
src={logoOnly}
alt="Rowboat"
width={collapsed ? 24 : 24}
height={collapsed ? 24 : 24}
className="rounded-full transition-all duration-200 flex-shrink-0"
/>
{!collapsed && (
<span className="text-sm font-semibold text-zinc-900 dark:text-zinc-100">
Rowboat
</span>
)}
width={24}
height={24}
/>}
{!collapsed && <Image
src={logo}
alt="Rowboat"
height={32}
/>}
</Link>
</Tooltip>
</div>

View file

@ -74,17 +74,18 @@ export function BillingUpgradeModal({ isOpen, onClose, errorMessage }: BillingUp
plan: "starter" as const,
description: "Great for your personal projects",
features: [
"1000 playground chat requests",
"500 copilot requests"
"2,000 credits",
"Latest models like gpt-5, claude-4 and others",
]
},
{
name: "Pro",
plan: "pro" as const,
description: "Great for enterprise teams",
description: "Great for power users or teams",
features: [
"10000 playground chat requests",
"2000 copilot requests"
"20,000 credits",
"o3 and o3-pro",
"Priority support",
],
recommended: true
}

View file

@ -23,6 +23,7 @@ import { MongodbProjectsRepository } from "@/src/infrastructure/repositories/mon
import { MongodbComposioTriggerDeploymentsRepository } from "@/src/infrastructure/repositories/mongodb.composio-trigger-deployments.repository";
import { CreateComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/create-composio-trigger-deployment.use-case";
import { ListComposioTriggerDeploymentsUseCase } from "@/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-deployments.use-case";
import { FetchComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/fetch-composio-trigger-deployment.use-case";
import { DeleteComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/delete-composio-trigger-deployment.use-case";
import { ListComposioTriggerTypesUseCase } from "@/src/application/use-cases/composio-trigger-deployments/list-composio-trigger-types.use-case";
import { HandleCompsioWebhookRequestUseCase } from "@/src/application/use-cases/composio/webhook/handle-composio-webhook-request.use-case";
@ -30,6 +31,7 @@ import { MongoDBJobsRepository } from "@/src/infrastructure/repositories/mongodb
import { CreateComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/create-composio-trigger-deployment.controller";
import { DeleteComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/delete-composio-trigger-deployment.controller";
import { ListComposioTriggerDeploymentsController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-deployments.controller";
import { FetchComposioTriggerDeploymentController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/fetch-composio-trigger-deployment.controller";
import { ListComposioTriggerTypesController } from "@/src/interface-adapters/controllers/composio-trigger-deployments/list-composio-trigger-types.controller";
import { HandleComposioWebhookRequestController } from "@/src/interface-adapters/controllers/composio/webhook/handle-composio-webhook-request.controller";
import { JobsWorker } from "@/src/application/workers/jobs.worker";
@ -299,10 +301,12 @@ container.register({
listComposioTriggerTypesUseCase: asClass(ListComposioTriggerTypesUseCase).singleton(),
createComposioTriggerDeploymentUseCase: asClass(CreateComposioTriggerDeploymentUseCase).singleton(),
listComposioTriggerDeploymentsUseCase: asClass(ListComposioTriggerDeploymentsUseCase).singleton(),
fetchComposioTriggerDeploymentUseCase: asClass(FetchComposioTriggerDeploymentUseCase).singleton(),
deleteComposioTriggerDeploymentUseCase: asClass(DeleteComposioTriggerDeploymentUseCase).singleton(),
createComposioTriggerDeploymentController: asClass(CreateComposioTriggerDeploymentController).singleton(),
deleteComposioTriggerDeploymentController: asClass(DeleteComposioTriggerDeploymentController).singleton(),
listComposioTriggerDeploymentsController: asClass(ListComposioTriggerDeploymentsController).singleton(),
fetchComposioTriggerDeploymentController: asClass(FetchComposioTriggerDeploymentController).singleton(),
listComposioTriggerTypesController: asClass(ListComposioTriggerTypesController).singleton(),
// conversations

View file

@ -212,4 +212,9 @@ export async function listTriggersTypes(toolkitSlug: string, cursor?: string): P
// fetch
return composioApiCall(ZListResponse(ZTriggerType), url.toString());
}
export async function getTriggersType(triggerTypeSlug: string): Promise<z.infer<typeof ZTriggerType>> {
const url = new URL(`${BASE_URL}/triggers_types/${triggerTypeSlug}`);
return composioApiCall(ZTriggerType, url.toString());
}

View file

@ -14,6 +14,7 @@ export const CreateDeploymentSchema = ComposioTriggerDeployment
toolkitSlug: true,
logo: true,
triggerTypeSlug: true,
triggerTypeName: true,
triggerConfig: true,
});

View file

@ -2,18 +2,20 @@ 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 { CreateDeploymentSchema, IComposioTriggerDeploymentsRepository } from '../../repositories/composio-trigger-deployments.repository.interface';
import { IComposioTriggerDeploymentsRepository } from '../../repositories/composio-trigger-deployments.repository.interface';
import { IProjectsRepository } from '../../repositories/projects.repository.interface';
import { composio, getToolkit } from '../../lib/composio/composio';
import { composio, getTriggersType } from '../../lib/composio/composio';
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
data: CreateDeploymentSchema.omit({
triggerId: true,
logo: true,
projectId: z.string(),
data: ComposioTriggerDeployment.pick({
triggerTypeSlug: true,
connectedAccountId: true,
triggerConfig: true,
}),
});
@ -46,7 +48,7 @@ export class CreateComposioTriggerDeploymentUseCase implements ICreateComposioTr
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>> {
// extract projectid from conversation
const { projectId } = request.data;
const { projectId } = request;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
@ -59,8 +61,11 @@ export class CreateComposioTriggerDeploymentUseCase implements ICreateComposioTr
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
// get trigger type info
const triggerType = await getTriggersType(request.data.triggerTypeSlug);
// get toolkit info
const toolkit = await getToolkit(request.data.toolkitSlug);
const toolkit = triggerType.toolkit;
// ensure that connected account exists on project
const project = await this.projectsRepository.fetch(projectId);
@ -69,7 +74,7 @@ export class CreateComposioTriggerDeploymentUseCase implements ICreateComposioTr
}
// ensure connected account exists
const account = project.composioConnectedAccounts?.[request.data.toolkitSlug];
const account = project.composioConnectedAccounts?.[toolkit.slug];
if (!account || account.id !== request.data.connectedAccountId) {
throw new BadRequestError('Invalid connected account');
}
@ -81,7 +86,7 @@ export class CreateComposioTriggerDeploymentUseCase implements ICreateComposioTr
}
// create trigger on composio
const result = await composio.triggers.create(request.data.projectId, request.data.triggerTypeSlug, {
const result = await composio.triggers.create(projectId, request.data.triggerTypeSlug, {
connectedAccountId: request.data.connectedAccountId,
triggerConfig: request.data.triggerConfig,
});
@ -89,11 +94,12 @@ export class CreateComposioTriggerDeploymentUseCase implements ICreateComposioTr
// create trigger deployment in db
return await this.composioTriggerDeploymentsRepository.create({
projectId,
toolkitSlug: request.data.toolkitSlug,
logo: toolkit.meta.logo,
toolkitSlug: toolkit.slug,
logo: toolkit.logo,
triggerId: result.triggerId,
connectedAccountId: request.data.connectedAccountId,
triggerTypeSlug: request.data.triggerTypeSlug,
triggerTypeName: triggerType.name,
triggerConfig: request.data.triggerConfig,
});
}

View file

@ -0,0 +1,62 @@
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 { IComposioTriggerDeploymentsRepository } from '../../repositories/composio-trigger-deployments.repository.interface';
import { ComposioTriggerDeployment } from '@/src/entities/models/composio-trigger-deployment';
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
deploymentId: z.string(),
});
export interface IFetchComposioTriggerDeploymentUseCase {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>>;
}
export class FetchComposioTriggerDeploymentUseCase implements IFetchComposioTriggerDeploymentUseCase {
private readonly composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository;
private readonly usageQuotaPolicy: IUsageQuotaPolicy;
private readonly projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy;
constructor({
composioTriggerDeploymentsRepository,
usageQuotaPolicy,
projectActionAuthorizationPolicy,
}: {
composioTriggerDeploymentsRepository: IComposioTriggerDeploymentsRepository,
usageQuotaPolicy: IUsageQuotaPolicy,
projectActionAuthorizationPolicy: IProjectActionAuthorizationPolicy,
}) {
this.composioTriggerDeploymentsRepository = composioTriggerDeploymentsRepository;
this.usageQuotaPolicy = usageQuotaPolicy;
this.projectActionAuthorizationPolicy = projectActionAuthorizationPolicy;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>> {
// fetch deployment first to get projectId
const deployment = await this.composioTriggerDeploymentsRepository.fetch(request.deploymentId);
if (!deployment) {
throw new NotFoundError(`Composio trigger deployment ${request.deploymentId} not found`);
}
const { projectId } = deployment;
// authz check
await this.projectActionAuthorizationPolicy.authorize({
caller: request.caller,
userId: request.userId,
apiKey: request.apiKey,
projectId,
});
// assert and consume quota
await this.usageQuotaPolicy.assertAndConsume(projectId);
return deployment;
}
}

View file

@ -6,6 +6,7 @@ export const ComposioTriggerDeployment = z.object({
triggerId: z.string(),
toolkitSlug: z.string(),
triggerTypeSlug: z.string(),
triggerTypeName: z.string(),
connectedAccountId: z.string(),
triggerConfig: z.record(z.string(), z.unknown()),
logo: z.string(),

View file

@ -2,15 +2,16 @@ import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { ICreateComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/create-composio-trigger-deployment.use-case";
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
import { CreateDeploymentSchema } from "@/src/application/repositories/composio-trigger-deployments.repository.interface";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
data: CreateDeploymentSchema.omit({
triggerId: true,
logo: true,
projectId: z.string(),
data: ComposioTriggerDeployment.pick({
triggerTypeSlug: true,
connectedAccountId: true,
triggerConfig: true,
}),
});
@ -35,13 +36,14 @@ export class CreateComposioTriggerDeploymentController implements ICreateComposi
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, data } = result.data;
const { caller, userId, apiKey, projectId, data } = result.data;
// execute use case
return await this.createComposioTriggerDeploymentUseCase.execute({
caller,
userId,
apiKey,
projectId,
data,
});
}

View file

@ -0,0 +1,44 @@
import { BadRequestError } from "@/src/entities/errors/common";
import z from "zod";
import { IFetchComposioTriggerDeploymentUseCase } from "@/src/application/use-cases/composio-trigger-deployments/fetch-composio-trigger-deployment.use-case";
import { ComposioTriggerDeployment } from "@/src/entities/models/composio-trigger-deployment";
const inputSchema = z.object({
caller: z.enum(["user", "api"]),
userId: z.string().optional(),
apiKey: z.string().optional(),
deploymentId: z.string(),
});
export interface IFetchComposioTriggerDeploymentController {
execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>>;
}
export class FetchComposioTriggerDeploymentController implements IFetchComposioTriggerDeploymentController {
private readonly fetchComposioTriggerDeploymentUseCase: IFetchComposioTriggerDeploymentUseCase;
constructor({
fetchComposioTriggerDeploymentUseCase,
}: {
fetchComposioTriggerDeploymentUseCase: IFetchComposioTriggerDeploymentUseCase,
}) {
this.fetchComposioTriggerDeploymentUseCase = fetchComposioTriggerDeploymentUseCase;
}
async execute(request: z.infer<typeof inputSchema>): Promise<z.infer<typeof ComposioTriggerDeployment>> {
const result = inputSchema.safeParse(request);
if (!result.success) {
throw new BadRequestError(`Invalid request: ${JSON.stringify(result.error)}`);
}
const { caller, userId, apiKey, deploymentId } = result.data;
return await this.fetchComposioTriggerDeploymentUseCase.execute({
caller,
userId,
apiKey,
deploymentId,
});
}
}