Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 5 additions & 7 deletions apps/web/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,15 @@ const { data } = useQuery({

## Types

Document types come from Convex. Use `Doc<"tableName">` from the generated types:
Document types live in `packages/backend/convex/types.ts`. Import from `@clawe/backend/types`:

```tsx
import type { Doc } from "@clawe/backend/server";

type Task = Doc<"tasks">;
type Agent = Doc<"agents">;
type Message = Doc<"messages">;
import type { Agent, Tenant } from "@clawe/backend/types";
```

Or infer from query results (preferred when using the data directly).
- If a type doesn't exist yet, add it to `types.ts` using `Doc<>`
- Never use `Doc<>` outside of `types.ts`
- Or infer from query results (preferred when using the data directly)

**Environment variables:**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { toast } from "@clawe/ui/components/sonner";
import { api } from "@clawe/backend";
import { Button } from "@clawe/ui/components/button";
import { Spinner } from "@clawe/ui/components/spinner";
import { removeTelegramBot } from "@/lib/squadhub/actions";
import {
AlertDialog,
AlertDialogContent,
Expand All @@ -32,6 +33,10 @@ export const TelegramDisconnectDialog = ({
const handleDisconnect = async () => {
setIsDisconnecting(true);
try {
// Remove token from squadhub config
await removeTelegramBot();

// Update Convex status
await disconnectChannel({ type: "telegram" });
toast.success("Telegram disconnected");
onOpenChange(false);
Expand All @@ -50,8 +55,8 @@ export const TelegramDisconnectDialog = ({
<AlertDialogDescription>
Your bot{" "}
{botUsername && <span className="font-medium">@{botUsername}</span>}{" "}
will stop receiving messages. The bot token will remain saved and
you can reconnect anytime.
will stop receiving messages. You can reconnect anytime by adding a
new bot token.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/app/api/chat/abort/route.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { mockTenantAuth } from "@/test/mock-tenant-auth";
import { POST } from "./route";

vi.mock("@/lib/api/tenant-auth", () => mockTenantAuth);

// Mock the shared client
const mockRequest = vi.fn();

Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/app/api/chat/abort/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { getSharedClient } from "@clawe/shared/squadhub";
import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";
import { getConnection } from "@/lib/squadhub/connection";

export const runtime = "nodejs";
Expand All @@ -15,6 +16,9 @@ type AbortRequestBody = {
* Abort an in-progress chat generation.
*/
export async function POST(request: NextRequest) {
const auth = await getAuthenticatedTenant(request);
if (auth.error) return auth.error;

let body: AbortRequestBody;

try {
Expand All @@ -33,7 +37,7 @@ export async function POST(request: NextRequest) {
}

try {
const client = await getSharedClient(getConnection());
const client = await getSharedClient(getConnection(auth.tenant));

await client.request("chat.abort", {
sessionKey,
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/app/api/chat/history/route.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { mockTenantAuth } from "@/test/mock-tenant-auth";
import { GET } from "./route";

vi.mock("@/lib/api/tenant-auth", () => mockTenantAuth);

// Mock the shared client
const mockRequest = vi.fn();

Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/app/api/chat/history/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { getSharedClient } from "@clawe/shared/squadhub";
import type { ChatHistoryResponse } from "@clawe/shared/squadhub";
import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";
import { getConnection } from "@/lib/squadhub/connection";

export const runtime = "nodejs";
Expand All @@ -10,6 +11,9 @@ export const dynamic = "force-dynamic";
* GET /api/chat/history?sessionKey=xxx&limit=200
*/
export async function GET(request: NextRequest) {
const auth = await getAuthenticatedTenant(request);
if (auth.error) return auth.error;

const searchParams = request.nextUrl.searchParams;
const sessionKey = searchParams.get("sessionKey");
const limitParam = searchParams.get("limit");
Expand All @@ -30,7 +34,7 @@ export async function GET(request: NextRequest) {
}

try {
const client = await getSharedClient(getConnection());
const client = await getSharedClient(getConnection(auth.tenant));

const response = await client.request<ChatHistoryResponse>("chat.history", {
sessionKey,
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/app/api/chat/route.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { NextRequest } from "next/server";
import { mockTenantAuth } from "@/test/mock-tenant-auth";
import { POST } from "./route";

vi.mock("@/lib/api/tenant-auth", () => mockTenantAuth);

// Mock the AI SDK
vi.mock("@ai-sdk/openai", () => ({
createOpenAI: vi.fn(() => ({
Expand All @@ -25,7 +29,7 @@ describe("POST /api/chat", () => {
});

it("returns 400 when sessionKey is missing", async () => {
const request = new Request("http://localhost/api/chat", {
const request = new NextRequest("http://localhost/api/chat", {
method: "POST",
body: JSON.stringify({ messages: [{ role: "user", content: "Hello" }] }),
});
Expand All @@ -38,7 +42,7 @@ describe("POST /api/chat", () => {
});

it("returns 400 when messages is missing", async () => {
const request = new Request("http://localhost/api/chat", {
const request = new NextRequest("http://localhost/api/chat", {
method: "POST",
body: JSON.stringify({ sessionKey: "test-session" }),
});
Expand All @@ -51,7 +55,7 @@ describe("POST /api/chat", () => {
});

it("returns stream response with valid request", async () => {
const request = new Request("http://localhost/api/chat", {
const request = new NextRequest("http://localhost/api/chat", {
method: "POST",
body: JSON.stringify({
sessionKey: "test-session",
Expand All @@ -64,7 +68,7 @@ describe("POST /api/chat", () => {
});

it("returns 500 on invalid JSON", async () => {
const request = new Request("http://localhost/api/chat", {
const request = new NextRequest("http://localhost/api/chat", {
method: "POST",
body: "invalid json",
});
Expand Down
11 changes: 8 additions & 3 deletions apps/web/src/app/api/chat/route.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,21 @@
import type { NextRequest } from "next/server";
import { createOpenAI } from "@ai-sdk/openai";
import { streamText } from "ai";
import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";
import { getConnection } from "@/lib/squadhub/connection";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* POST /api/chat
* Proxy chat requests to the squadhub's OpenAI-compatible endpoint.
* Proxy chat requests to the tenant's squadhub OpenAI-compatible endpoint.
*/
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
try {
const auth = await getAuthenticatedTenant(request);
if (auth.error) return auth.error;

const body = await request.json();
const { messages, sessionKey } = body;

Expand All @@ -28,7 +33,7 @@ export async function POST(request: Request) {
});
}

const { squadhubUrl, squadhubToken } = getConnection();
const { squadhubUrl, squadhubToken } = getConnection(auth.tenant);

// Create OpenAI-compatible client pointing to squadhub gateway
const squadhub = createOpenAI({
Expand Down
9 changes: 7 additions & 2 deletions apps/web/src/app/api/squadhub/health/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { checkHealth } from "@clawe/shared/squadhub";
import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";
import { getConnection } from "@/lib/squadhub/connection";

export async function POST() {
const result = await checkHealth(getConnection());
export async function POST(request: NextRequest) {
const auth = await getAuthenticatedTenant(request);
if (auth.error) return auth.error;

const result = await checkHealth(getConnection(auth.tenant));
return NextResponse.json(result);
}
50 changes: 37 additions & 13 deletions apps/web/src/app/api/squadhub/pairing/route.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,34 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import {
listChannelPairingRequests,
approveChannelPairingCode,
listPairingRequests,
approvePairingCode,
parseToolText,
} from "@clawe/shared/squadhub";
import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";
import { getConnection } from "@/lib/squadhub/connection";

// GET /api/squadhub/pairing?channel=telegram - List pending pairing requests
export async function GET(request: Request) {
const { searchParams } = new URL(request.url);
const channel = searchParams.get("channel") || "telegram";
export async function GET(request: NextRequest) {
const auth = await getAuthenticatedTenant(request);
if (auth.error) return auth.error;

const result = await listChannelPairingRequests(channel);
const channel = request.nextUrl.searchParams.get("channel") || "telegram";
const result = await listPairingRequests(getConnection(auth.tenant), channel);

if (!result.ok) {
return NextResponse.json({ error: result.error.message }, { status: 500 });
}

return NextResponse.json(result.result);
const data = parseToolText<{ requests?: unknown[] }>(result);
return NextResponse.json({ requests: data?.requests ?? [] });
}

// POST /api/squadhub/pairing - Approve a pairing code
export async function POST(request: Request) {
export async function POST(request: NextRequest) {
const auth = await getAuthenticatedTenant(request);
if (auth.error) return auth.error;

try {
const body = await request.json();
const { channel = "telegram", code } = body as {
Expand All @@ -32,18 +40,34 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Code is required" }, { status: 400 });
}

const result = await approveChannelPairingCode(
getConnection(),
const result = await approvePairingCode(
getConnection(auth.tenant),
channel,
code,
);

if (!result.ok) {
const status = result.error.type === "not_found" ? 404 : 500;
return NextResponse.json({ error: result.error.message }, { status });
return NextResponse.json(
{ error: result.error.message },
{ status: 500 },
);
}

const data = parseToolText<{
ok: boolean;
id?: string;
approved?: boolean;
error?: string;
}>(result);

if (!data?.ok) {
return NextResponse.json(
{ error: data?.error || "Failed to approve pairing code" },
{ status: 404 },
);
}

return NextResponse.json(result.result);
return NextResponse.json({ id: data.id, approved: data.approved });
} catch {
return NextResponse.json(
{ error: "Failed to approve pairing code" },
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/app/api/tenant/squadhub/restart/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";
* Dev: no-op. Cloud: forces new ECS task deployment.
*/
export const POST = async (request: NextRequest) => {
const result = await getAuthenticatedTenant(request);
if ("error" in result) return result.error;

const { tenant } = result;
const { error, tenant } = await getAuthenticatedTenant(request);
if (error) return error;

try {
await loadPlugins();
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/app/api/tenant/squadhub/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,8 @@ import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";
* Dev: no-op. Cloud: deletes ECS service + EFS access point + CloudMap entry.
*/
export const DELETE = async (request: NextRequest) => {
const result = await getAuthenticatedTenant(request);
if ("error" in result) return result.error;

const { tenant } = result;
const { error, tenant } = await getAuthenticatedTenant(request);
if (error) return error;

try {
await loadPlugins();
Expand Down
6 changes: 2 additions & 4 deletions apps/web/src/app/api/tenant/squadhub/status/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,8 @@ import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";
* Cloud: checks ECS service running count + task health.
*/
export const GET = async (request: NextRequest) => {
const result = await getAuthenticatedTenant(request);
if ("error" in result) return result.error;

const { tenant } = result;
const { error, tenant } = await getAuthenticatedTenant(request);
if (error) return error;

try {
await loadPlugins();
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/app/api/tenant/status/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import { loadPlugins, getPlugin } from "@clawe/plugins";
import { getAuthenticatedTenant } from "@/lib/api/tenant-auth";

/**
* GET /api/tenant/status
*
* Check provisioning status for the current user's tenant.
* Dev: always returns { status: "active" }.
* Cloud: returns real ECS provisioning status.
*/
export const GET = async (request: NextRequest) => {
const { error, tenant } = await getAuthenticatedTenant(request);
if (error) return error;

try {
await loadPlugins();
const provisioner = getPlugin("squadhub-provisioner");
const status = await provisioner.getProvisioningStatus(tenant._id);

return NextResponse.json(status);
} catch (error) {
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: message }, { status: 500 });
}
};
Loading