diff --git a/apps/webapp/app/components/setting-section.tsx b/apps/webapp/app/components/setting-section.tsx
new file mode 100644
index 0000000..fefa149
--- /dev/null
+++ b/apps/webapp/app/components/setting-section.tsx
@@ -0,0 +1,34 @@
+interface SettingSectionProps {
+ title: React.ReactNode | string;
+ description: React.ReactNode | string;
+ metadata?: React.ReactNode;
+ actions?: React.ReactNode;
+ children: React.ReactNode;
+}
+
+export function SettingSection({
+ title,
+ description,
+ metadata,
+ children,
+ actions,
+}: SettingSectionProps) {
+ return (
+
+
+
+
{title}
+
{description}
+ {metadata ? metadata : null}
+
+
+
{actions}
+
+
+
+ );
+}
diff --git a/apps/webapp/app/components/sidebar/app-sidebar.tsx b/apps/webapp/app/components/sidebar/app-sidebar.tsx
index 426ae5e..67bd5a7 100644
--- a/apps/webapp/app/components/sidebar/app-sidebar.tsx
+++ b/apps/webapp/app/components/sidebar/app-sidebar.tsx
@@ -29,7 +29,7 @@ const data = {
},
{
title: "Logs",
- url: "/home/logs/all",
+ url: "/home/logs",
icon: Activity,
},
{
diff --git a/apps/webapp/app/components/sidebar/nav-main.tsx b/apps/webapp/app/components/sidebar/nav-main.tsx
index 8ad573a..8a13db6 100644
--- a/apps/webapp/app/components/sidebar/nav-main.tsx
+++ b/apps/webapp/app/components/sidebar/nav-main.tsx
@@ -34,7 +34,11 @@ export const NavMain = ({
location.pathname.includes(item.url) &&
"!bg-accent !text-accent-foreground",
)}
- onClick={() => navigate(item.url)}
+ onClick={() =>
+ navigate(
+ item.url.includes("/logs") ? `${item.url}/all` : item.url,
+ )
+ }
variant="ghost"
>
{item.icon &&
}
diff --git a/apps/webapp/app/components/ui/FormButtons.tsx b/apps/webapp/app/components/ui/FormButtons.tsx
index 40486f7..09d82d9 100644
--- a/apps/webapp/app/components/ui/FormButtons.tsx
+++ b/apps/webapp/app/components/ui/FormButtons.tsx
@@ -11,10 +11,7 @@ export function FormButtons({
}) {
return (
{cancelButton ? cancelButton :
} {confirmButton}
diff --git a/apps/webapp/app/components/ui/checkbox.tsx b/apps/webapp/app/components/ui/checkbox.tsx
new file mode 100644
index 0000000..9145ade
--- /dev/null
+++ b/apps/webapp/app/components/ui/checkbox.tsx
@@ -0,0 +1,28 @@
+import * as CheckboxPrimitive from '@radix-ui/react-checkbox';
+import { CheckIcon } from '@radix-ui/react-icons';
+import React from 'react';
+
+import { cn } from '../../lib/utils';
+
+const Checkbox = React.forwardRef<
+ React.ElementRef
,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+
+
+));
+Checkbox.displayName = CheckboxPrimitive.Root.displayName;
+
+export { Checkbox };
diff --git a/apps/webapp/app/components/ui/header.tsx b/apps/webapp/app/components/ui/header.tsx
index b991f86..c102adc 100644
--- a/apps/webapp/app/components/ui/header.tsx
+++ b/apps/webapp/app/components/ui/header.tsx
@@ -2,6 +2,7 @@ import { useLocation, useNavigate } from "@remix-run/react";
import { Button } from "./button";
import { Plus } from "lucide-react";
import { SidebarTrigger } from "./sidebar";
+import React from "react";
const PAGE_TITLES: Record = {
"/home/dashboard": "Memory graph",
@@ -30,6 +31,25 @@ function isIntegrationsPage(pathname: string): boolean {
return pathname === "/home/integrations";
}
+function isAllLogs(pathname: string): boolean {
+ return pathname === "/home/logs/all";
+}
+
+function isActivityLogs(pathname: string): boolean {
+ return pathname === "/home/logs/activity";
+}
+
+function isLogsPage(pathname: string): boolean {
+ // Matches /home/logs, /home/logs/all, /home/logs/activity, or any /home/logs/*
+ return pathname.includes("/home/logs");
+}
+
+function getLogsTab(pathname: string): "all" | "activity" {
+ if (pathname.startsWith("/home/logs/activity")) return "activity";
+ // Default to "all" for /home/logs or /home/logs/all or anything else
+ return "all";
+}
+
export function SiteHeader() {
const location = useLocation();
const navigate = useNavigate();
@@ -37,13 +57,50 @@ export function SiteHeader() {
const showNewConversationButton = isConversationDetail(location.pathname);
const showRequestIntegrationButton = isIntegrationsPage(location.pathname);
+ const showLogsTabs = isLogsPage(location.pathname);
+
+ const logsTab = getLogsTab(location.pathname);
+
+ const handleTabClick = (tab: "all" | "activity") => {
+ if (tab === "all") {
+ navigate("/home/logs/all");
+ } else if (tab === "activity") {
+ navigate("/home/logs/activity");
+ }
+ };
return (
+
{title}
+
+ {showLogsTabs && (
+
+
+
+
+ )}
{showNewConversationButton && (
@@ -58,7 +115,12 @@ export function SiteHeader() {
)}
{showRequestIntegrationButton && (
);
}
diff --git a/apps/webapp/app/routes/home.logs.all.tsx b/apps/webapp/app/routes/home.logs.all.tsx
index 6d872e1..e817807 100644
--- a/apps/webapp/app/routes/home.logs.all.tsx
+++ b/apps/webapp/app/routes/home.logs.all.tsx
@@ -2,13 +2,8 @@ import { useState } from "react";
import { useLogs } from "~/hooks/use-logs";
import { LogsFilters } from "~/components/logs/logs-filters";
import { VirtualLogsList } from "~/components/logs/virtual-logs-list";
-import {
- AppContainer,
- PageContainer,
- PageBody,
-} from "~/components/layout/app-layout";
-import { Card, CardContent, CardHeader, CardTitle } from "~/components/ui/card";
-import { Badge } from "~/components/ui/badge";
+import { AppContainer, PageContainer } from "~/components/layout/app-layout";
+import { Card, CardContent } from "~/components/ui/card";
import { Database } from "lucide-react";
export default function LogsAll() {
@@ -42,17 +37,6 @@ export default function LogsAll() {
return (
- {/* Header */}
-
-
-
-
- View all ingestion queue items and their processing status
-
-
-
-
-
{/* Filters */}
-
-
Ingestion Queue
- {hasMore && (
-
- Scroll to load more...
-
- )}
-
-
{logs.length === 0 ? (
-
+
No logs found
diff --git a/apps/webapp/app/routes/settings.api.tsx b/apps/webapp/app/routes/settings.api.tsx
index b9960c7..e3b9919 100644
--- a/apps/webapp/app/routes/settings.api.tsx
+++ b/apps/webapp/app/routes/settings.api.tsx
@@ -25,6 +25,7 @@ import {
import { requireUserId } from "~/services/session.server";
import { useTypedLoaderData } from "remix-typedjson";
import { APITable } from "~/components/api";
+import { SettingSection } from "~/components/setting-section";
export const APIKeyBodyRequest = z.object({
name: z.string(),
@@ -96,19 +97,11 @@ export default function API() {
};
return (
-
-
-
-
API Keys
-
- Create and manage API keys to access your data programmatically. API
- keys allow secure access to your workspace's data and functionality
- through our REST API.
-
-
-
-
-
+
+
);
}
diff --git a/apps/webapp/app/routes/settings.logs.tsx b/apps/webapp/app/routes/settings.logs.tsx
deleted file mode 100644
index b5e9399..0000000
--- a/apps/webapp/app/routes/settings.logs.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-import { json } from "@remix-run/node";
-import { Link, useLoaderData } from "@remix-run/react";
-import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
-import { IngestionLogsTable } from "~/components/logs";
-import { getIngestionLogs } from "~/services/ingestionLogs.server";
-import { requireUserId } from "~/services/session.server";
-
-export async function loader({ request }: LoaderFunctionArgs) {
- const userId = await requireUserId(request);
- const url = new URL(request.url);
- const page = Number(url.searchParams.get("page") || 1);
-
- const { ingestionLogs, pagination } = await getIngestionLogs(userId, page);
-
- return json({ ingestionLogs, pagination });
-}
-
-export default function Logs() {
- const { ingestionLogs, pagination } = useLoaderData
();
-
- return (
-
-
-
-
Logs
-
- View and monitor your data ingestion logs. These logs show the
- history of data being loaded into memory, helping you track and
- debug the ingestion process.
-
-
-
-
-
-
- {Array.from({ length: pagination.pages }, (_, i) => (
-
- {i + 1}
-
- ))}
-
-
- );
-}
diff --git a/apps/webapp/app/routes/settings.tsx b/apps/webapp/app/routes/settings.tsx
index 5b1ab2f..d49fc58 100644
--- a/apps/webapp/app/routes/settings.tsx
+++ b/apps/webapp/app/routes/settings.tsx
@@ -6,6 +6,7 @@ import {
Code,
User,
Workflow,
+ Webhook,
} from "lucide-react";
import React from "react";
@@ -50,9 +51,9 @@ export default function Settings() {
const data = {
nav: [
- { name: "Workspace", icon: Building },
- { name: "Preferences", icon: User },
+ // { name: "Workspace", icon: Building },
{ name: "API", icon: Code },
+ { name: "Webhooks", icon: Webhook },
],
};
const navigate = useNavigate();
diff --git a/apps/webapp/app/routes/settings.webhooks.tsx b/apps/webapp/app/routes/settings.webhooks.tsx
new file mode 100644
index 0000000..64dbce5
--- /dev/null
+++ b/apps/webapp/app/routes/settings.webhooks.tsx
@@ -0,0 +1,245 @@
+import { useState, useEffect, useRef } from "react";
+import { json } from "@remix-run/node";
+import { useLoaderData, Form, useNavigation } from "@remix-run/react";
+import { type LoaderFunctionArgs } from "@remix-run/server-runtime";
+import { requireUserId, requireWorkpace } from "~/services/session.server";
+
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { Button } from "~/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "~/components/ui/dialog";
+import { Input } from "~/components/ui/input";
+import { Label } from "~/components/ui/label";
+import { Badge } from "~/components/ui/badge";
+import { FormButtons } from "~/components/ui/FormButtons";
+import { Plus, Trash2, Globe, Check, X, Webhook } from "lucide-react";
+import { prisma } from "~/db.server";
+import { SettingSection } from "~/components/setting-section";
+
+export async function loader({ request }: LoaderFunctionArgs) {
+ const userId = await requireUserId(request);
+ const workspace = await requireWorkpace(request);
+
+ const webhooks = await prisma.webhookConfiguration.findMany({
+ where: {
+ workspaceId: workspace.id,
+ },
+ include: {
+ _count: {
+ select: {
+ WebhookDeliveryLog: true,
+ },
+ },
+ },
+ orderBy: {
+ createdAt: "desc",
+ },
+ });
+
+ return json({
+ webhooks,
+ workspace,
+ });
+}
+
+export default function WebhooksSettings() {
+ const { webhooks, workspace } = useLoaderData();
+ const navigation = useNavigation();
+ const [isDialogOpen, setIsDialogOpen] = useState(false);
+ const [formData, setFormData] = useState({
+ url: "",
+ secret: "",
+ });
+
+ // Track previous submitting state to detect when submission finishes
+ const prevIsSubmitting = useRef(false);
+ const isSubmitting = navigation.state === "submitting";
+
+ // Close dialog when submission finishes and was open
+ useEffect(() => {
+ if (prevIsSubmitting.current && !isSubmitting && isDialogOpen) {
+ setIsDialogOpen(false);
+ setFormData({ url: "", secret: "" });
+ }
+ prevIsSubmitting.current = isSubmitting;
+ }, [isSubmitting, isDialogOpen]);
+
+ const resetForm = () => {
+ setFormData({
+ url: "",
+ secret: "",
+ });
+ };
+
+ const handleDialogClose = (open: boolean) => {
+ setIsDialogOpen(open);
+ if (!open) {
+ resetForm();
+ }
+ };
+
+ return (
+
+
+
+ {webhooks.length > 0 && (
+
+
+
+ Add Webhook
+
+
+ )}
+ >
+ }
+ description="View and monitor your data ingestion logs."
+ >
+
+ {webhooks.length === 0 ? (
+
+
+
+
+ No webhooks configured
+
+
+ Add your first webhook to start receiving real-time
+ notifications
+
+ setIsDialogOpen(true)}
+ variant="secondary"
+ >
+
+ Add Webhook
+
+
+
+ ) : (
+ webhooks.map((webhook) => (
+
+
+
+
+
+
+ {webhook.url}
+
+
+ Created{" "}
+ {new Date(webhook.createdAt).toLocaleDateString()}
+ {webhook._count.WebhookDeliveryLog > 0 && (
+
+ • {webhook._count.WebhookDeliveryLog} deliveries
+
+ )}
+
+
+
+
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
index f922700..c686cf0 100644
--- a/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
+++ b/apps/webapp/app/services/routeBuilders/apiBuilder.server.ts
@@ -16,6 +16,7 @@ import {
checkAuthorization,
} from "../authorization.server";
import { logger } from "../logger.service";
+import { getUserId } from "../session.server";
import { safeJsonParse } from "~/utils/json";
@@ -632,3 +633,326 @@ async function wrapResponse(
return response;
}
+
+// New hybrid authentication types and functions
+export type HybridAuthenticationResult = ApiAuthenticationResultSuccess | {
+ ok: true;
+ type: "COOKIE";
+ userId: string;
+};
+
+async function authenticateHybridRequest(
+ request: Request,
+ options: { allowJWT?: boolean } = {},
+): Promise {
+ // First try API key authentication
+ const apiResult = await authenticateApiRequestWithFailure(request, options);
+ if (apiResult.ok) {
+ return apiResult;
+ }
+
+ // If API key fails, try cookie authentication
+ const userId = await getUserId(request);
+ if (userId) {
+ return {
+ ok: true,
+ type: "COOKIE",
+ userId,
+ };
+ }
+
+ return null;
+}
+
+type HybridActionRouteBuilderOptions<
+ TParamsSchema extends AnyZodSchema | undefined = undefined,
+ TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
+ THeadersSchema extends AnyZodSchema | undefined = undefined,
+ TBodySchema extends AnyZodSchema | undefined = undefined,
+> = {
+ params?: TParamsSchema;
+ searchParams?: TSearchParamsSchema;
+ headers?: THeadersSchema;
+ allowJWT?: boolean;
+ corsStrategy?: "all" | "none";
+ method?: "POST" | "PUT" | "DELETE" | "PATCH";
+ authorization?: {
+ action: AuthorizationAction;
+ };
+ maxContentLength?: number;
+ body?: TBodySchema;
+};
+
+type HybridActionHandlerFunction<
+ TParamsSchema extends AnyZodSchema | undefined,
+ TSearchParamsSchema extends AnyZodSchema | undefined,
+ THeadersSchema extends AnyZodSchema | undefined = undefined,
+ TBodySchema extends AnyZodSchema | undefined = undefined,
+> = (args: {
+ params: TParamsSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined;
+ searchParams: TSearchParamsSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined;
+ headers: THeadersSchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined;
+ body: TBodySchema extends
+ | z.ZodFirstPartySchemaTypes
+ | z.ZodDiscriminatedUnion
+ ? z.infer
+ : undefined;
+ authentication: HybridAuthenticationResult;
+ request: Request;
+}) => Promise;
+
+export function createHybridActionApiRoute<
+ TParamsSchema extends AnyZodSchema | undefined = undefined,
+ TSearchParamsSchema extends AnyZodSchema | undefined = undefined,
+ THeadersSchema extends AnyZodSchema | undefined = undefined,
+ TBodySchema extends AnyZodSchema | undefined = undefined,
+>(
+ options: HybridActionRouteBuilderOptions<
+ TParamsSchema,
+ TSearchParamsSchema,
+ THeadersSchema,
+ TBodySchema
+ >,
+ handler: HybridActionHandlerFunction<
+ TParamsSchema,
+ TSearchParamsSchema,
+ THeadersSchema,
+ TBodySchema
+ >,
+) {
+ const {
+ params: paramsSchema,
+ searchParams: searchParamsSchema,
+ headers: headersSchema,
+ body: bodySchema,
+ allowJWT = false,
+ corsStrategy = "none",
+ authorization,
+ maxContentLength,
+ } = options;
+
+ async function loader({ request, params }: LoaderFunctionArgs) {
+ if (corsStrategy !== "none" && request.method.toUpperCase() === "OPTIONS") {
+ return apiCors(request, json({}));
+ }
+
+ return new Response(null, { status: 405 });
+ }
+
+ async function action({ request, params }: ActionFunctionArgs) {
+ if (options.method) {
+ if (request.method.toUpperCase() !== options.method) {
+ return await wrapResponse(
+ request,
+ json(
+ { error: "Method not allowed" },
+ { status: 405, headers: { Allow: options.method } },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ }
+
+ try {
+ const authenticationResult = await authenticateHybridRequest(
+ request,
+ { allowJWT },
+ );
+
+ if (!authenticationResult) {
+ return await wrapResponse(
+ request,
+ json({ error: "Authentication required" }, { status: 401 }),
+ corsStrategy !== "none",
+ );
+ }
+
+ if (maxContentLength) {
+ const contentLength = request.headers.get("content-length");
+
+ if (!contentLength || parseInt(contentLength) > maxContentLength) {
+ return json({ error: "Request body too large" }, { status: 413 });
+ }
+ }
+
+ let parsedParams: any = undefined;
+ if (paramsSchema) {
+ const parsed = paramsSchema.safeParse(params);
+ if (!parsed.success) {
+ return await wrapResponse(
+ request,
+ json(
+ {
+ error: "Params Error",
+ details: fromZodError(parsed.error).details,
+ },
+ { status: 400 },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ parsedParams = parsed.data;
+ }
+
+ let parsedSearchParams: any = undefined;
+ if (searchParamsSchema) {
+ const searchParams = Object.fromEntries(
+ new URL(request.url).searchParams,
+ );
+ const parsed = searchParamsSchema.safeParse(searchParams);
+ if (!parsed.success) {
+ return await wrapResponse(
+ request,
+ json(
+ {
+ error: "Query Error",
+ details: fromZodError(parsed.error).details,
+ },
+ { status: 400 },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ parsedSearchParams = parsed.data;
+ }
+
+ let parsedHeaders: any = undefined;
+ if (headersSchema) {
+ const rawHeaders = Object.fromEntries(request.headers);
+ const headers = headersSchema.safeParse(rawHeaders);
+ if (!headers.success) {
+ return await wrapResponse(
+ request,
+ json(
+ {
+ error: "Headers Error",
+ details: fromZodError(headers.error).details,
+ },
+ { status: 400 },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ parsedHeaders = headers.data;
+ }
+
+ let parsedBody: any = undefined;
+ if (bodySchema) {
+ const rawBody = await request.text();
+ if (rawBody.length === 0) {
+ return await wrapResponse(
+ request,
+ json({ error: "Request body is empty" }, { status: 400 }),
+ corsStrategy !== "none",
+ );
+ }
+
+ const rawParsedJson = safeJsonParse(rawBody);
+
+ if (!rawParsedJson) {
+ return await wrapResponse(
+ request,
+ json({ error: "Invalid JSON" }, { status: 400 }),
+ corsStrategy !== "none",
+ );
+ }
+
+ const body = bodySchema.safeParse(rawParsedJson);
+ if (!body.success) {
+ return await wrapResponse(
+ request,
+ json(
+ { error: fromZodError(body.error).toString() },
+ { status: 400 },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ parsedBody = body.data;
+ }
+
+ // Authorization check - only applies to API key authentication
+ if (authorization && authenticationResult.type === "PRIVATE") {
+ const { action } = authorization;
+
+ logger.debug("Checking authorization", {
+ action,
+ scopes: authenticationResult.scopes,
+ });
+
+ const authorizationResult = checkAuthorization(authenticationResult);
+
+ if (!authorizationResult.authorized) {
+ return await wrapResponse(
+ request,
+ json(
+ {
+ error: `Unauthorized: ${authorizationResult.reason}`,
+ code: "unauthorized",
+ param: "access_token",
+ type: "authorization",
+ },
+ { status: 403 },
+ ),
+ corsStrategy !== "none",
+ );
+ }
+ }
+
+ const result = await handler({
+ params: parsedParams,
+ searchParams: parsedSearchParams,
+ headers: parsedHeaders,
+ body: parsedBody,
+ authentication: authenticationResult,
+ request,
+ });
+ return await wrapResponse(request, result, corsStrategy !== "none");
+ } catch (error) {
+ try {
+ if (error instanceof Response) {
+ return await wrapResponse(request, error, corsStrategy !== "none");
+ }
+
+ logger.error("Error in hybrid action", {
+ error:
+ error instanceof Error
+ ? {
+ name: error.name,
+ message: error.message,
+ stack: error.stack,
+ }
+ : String(error),
+ url: request.url,
+ });
+
+ return await wrapResponse(
+ request,
+ json({ error: "Internal Server Error" }, { status: 500 }),
+ corsStrategy !== "none",
+ );
+ } catch (innerError) {
+ logger.error("[apiBuilder] Failed to handle error", {
+ error,
+ innerError,
+ });
+
+ return json({ error: "Internal Server Error" }, { status: 500 });
+ }
+ }
+ }
+
+ return { loader, action };
+}
diff --git a/apps/webapp/app/trigger/webhooks/webhook-delivery.ts b/apps/webapp/app/trigger/webhooks/webhook-delivery.ts
new file mode 100644
index 0000000..b456e9b
--- /dev/null
+++ b/apps/webapp/app/trigger/webhooks/webhook-delivery.ts
@@ -0,0 +1,224 @@
+import { queue, task } from "@trigger.dev/sdk";
+
+import { logger } from "~/services/logger.service";
+import { WebhookDeliveryStatus } from "@core/database";
+import crypto from "crypto";
+import { prisma } from "~/db.server";
+
+const webhookQueue = queue({
+ name: "webhook-delivery-queue",
+});
+
+interface WebhookDeliveryPayload {
+ activityId: string;
+ workspaceId: string;
+}
+
+export const webhookDeliveryTask = task({
+ id: "webhook-delivery",
+ queue: webhookQueue,
+ run: async (payload: WebhookDeliveryPayload) => {
+ try {
+ logger.log(
+ `Processing webhook delivery for activity ${payload.activityId}`,
+ );
+
+ // Get the activity data
+ const activity = await prisma.activity.findUnique({
+ where: { id: payload.activityId },
+ include: {
+ integrationAccount: {
+ include: {
+ integrationDefinition: true,
+ },
+ },
+ workspace: true,
+ },
+ });
+
+ if (!activity) {
+ logger.error(`Activity ${payload.activityId} not found`);
+ return { success: false, error: "Activity not found" };
+ }
+
+ // Get active webhooks for this workspace
+ const webhooks = await prisma.webhookConfiguration.findMany({
+ where: {
+ workspaceId: payload.workspaceId,
+ isActive: true,
+ },
+ });
+
+ if (webhooks.length === 0) {
+ logger.log(
+ `No active webhooks found for workspace ${payload.workspaceId}`,
+ );
+ return { success: true, message: "No webhooks to deliver to" };
+ }
+
+ // Prepare webhook payload
+ const webhookPayload = {
+ event: "activity.created",
+ timestamp: new Date().toISOString(),
+ data: {
+ id: activity.id,
+ text: activity.text,
+ sourceURL: activity.sourceURL,
+ createdAt: activity.createdAt,
+ updatedAt: activity.updatedAt,
+ integrationAccount: activity.integrationAccount
+ ? {
+ id: activity.integrationAccount.id,
+ integrationDefinition: {
+ name: activity.integrationAccount.integrationDefinition.name,
+ slug: activity.integrationAccount.integrationDefinition.slug,
+ },
+ }
+ : null,
+ workspace: {
+ id: activity.workspace.id,
+ name: activity.workspace.name,
+ },
+ },
+ };
+
+ const payloadString = JSON.stringify(webhookPayload);
+ const deliveryResults = [];
+
+ // Deliver to each webhook
+ for (const webhook of webhooks) {
+ const deliveryId = crypto.randomUUID();
+
+ try {
+ // Create delivery log entry
+ const deliveryLog = await prisma.webhookDeliveryLog.create({
+ data: {
+ webhookConfigurationId: webhook.id,
+ activityId: activity.id,
+ status: WebhookDeliveryStatus.FAILED, // Will update if successful
+ },
+ });
+
+ // Prepare headers
+ const headers: Record = {
+ "Content-Type": "application/json",
+ "User-Agent": "Echo-Webhooks/1.0",
+ "X-Webhook-Delivery": deliveryId,
+ "X-Webhook-Event": "activity.created",
+ };
+
+ // Add HMAC signature if secret is configured
+ if (webhook.secret) {
+ const signature = crypto
+ .createHmac("sha256", webhook.secret)
+ .update(payloadString)
+ .digest("hex");
+ headers["X-Hub-Signature-256"] = `sha256=${signature}`;
+ }
+
+ // Make the HTTP request
+ const response = await fetch(webhook.url, {
+ method: "POST",
+ headers,
+ body: payloadString,
+ signal: AbortSignal.timeout(30000), // 30 second timeout
+ });
+
+ const responseBody = await response.text().catch(() => "");
+
+ // Update delivery log with results
+ await prisma.webhookDeliveryLog.update({
+ where: { id: deliveryLog.id },
+ data: {
+ status: response.ok
+ ? WebhookDeliveryStatus.SUCCESS
+ : WebhookDeliveryStatus.FAILED,
+ responseStatusCode: response.status,
+ responseBody: responseBody.slice(0, 1000), // Limit response body length
+ error: response.ok
+ ? null
+ : `HTTP ${response.status}: ${response.statusText}`,
+ },
+ });
+
+ deliveryResults.push({
+ webhookId: webhook.id,
+ success: response.ok,
+ statusCode: response.status,
+ error: response.ok
+ ? null
+ : `HTTP ${response.status}: ${response.statusText}`,
+ });
+
+ logger.log(`Webhook delivery to ${webhook.url}: ${response.status}`);
+ } catch (error: any) {
+ // Update delivery log with error
+ const deliveryLog = await prisma.webhookDeliveryLog.findFirst({
+ where: {
+ webhookConfigurationId: webhook.id,
+ activityId: activity.id,
+ },
+ orderBy: { createdAt: "desc" },
+ });
+
+ if (deliveryLog) {
+ await prisma.webhookDeliveryLog.update({
+ where: { id: deliveryLog.id },
+ data: {
+ status: WebhookDeliveryStatus.FAILED,
+ error: error.message,
+ },
+ });
+ }
+
+ deliveryResults.push({
+ webhookId: webhook.id,
+ success: false,
+ error: error.message,
+ });
+
+ logger.error(`Error delivering webhook to ${webhook.url}:`, error);
+ }
+ }
+
+ const successCount = deliveryResults.filter((r) => r.success).length;
+ const totalCount = deliveryResults.length;
+
+ logger.log(
+ `Webhook delivery completed: ${successCount}/${totalCount} successful`,
+ );
+
+ return {
+ success: true,
+ delivered: successCount,
+ total: totalCount,
+ results: deliveryResults,
+ };
+ } catch (error: any) {
+ logger.error(
+ `Error in webhook delivery task for activity ${payload.activityId}:`,
+ error,
+ );
+ return { success: false, error: error.message };
+ }
+ },
+});
+
+// Helper function to trigger webhook delivery
+export async function triggerWebhookDelivery(
+ activityId: string,
+ workspaceId: string,
+) {
+ try {
+ await webhookDeliveryTask.trigger({
+ activityId,
+ workspaceId,
+ });
+ logger.log(`Triggered webhook delivery for activity ${activityId}`);
+ } catch (error: any) {
+ logger.error(
+ `Failed to trigger webhook delivery for activity ${activityId}:`,
+ error,
+ );
+ }
+}
diff --git a/apps/webapp/prisma/schema.prisma b/apps/webapp/prisma/schema.prisma
index 996ed8c..621128e 100644
--- a/apps/webapp/prisma/schema.prisma
+++ b/apps/webapp/prisma/schema.prisma
@@ -31,6 +31,7 @@ model Activity {
WebhookDeliveryLog WebhookDeliveryLog[]
ConversationHistory ConversationHistory[]
+ IngestionQueue IngestionQueue[]
}
model AuthorizationCode {
@@ -136,6 +137,9 @@ model IngestionQueue {
workspaceId String
workspace Workspace @relation(fields: [workspaceId], references: [id])
+ activity Activity? @relation(fields: [activityId], references: [id])
+ activityId String?
+
// Error handling
error String?
retryCount Int @default(0)
diff --git a/integrations/linear/spec.json b/integrations/linear/spec.json
index e614786..9dd6b86 100644
--- a/integrations/linear/spec.json
+++ b/integrations/linear/spec.json
@@ -14,6 +14,7 @@
},
"mcpAuth": {
"serverUrl": "https://mcp.linear.app/sse",
- "transportStrategy": "sse-first"
+ "transportStrategy": "sse-first",
+ "needsSeparateAuth": true
}
}
\ No newline at end of file
diff --git a/packages/mcp-proxy/src/core/mcp-remote-client.ts b/packages/mcp-proxy/src/core/mcp-remote-client.ts
index 1677e0d..e525393 100644
--- a/packages/mcp-proxy/src/core/mcp-remote-client.ts
+++ b/packages/mcp-proxy/src/core/mcp-remote-client.ts
@@ -266,7 +266,6 @@ export class MCPAuthenticationClient {
constructor(private config: MCPRemoteClientConfig) {
this.serverUrlHash = getServerUrlHash(config.serverUrl);
- console.log(config);
// Validate configuration
this.validateConfig();
}