diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1864399..10934c83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.3.12" # pinned to match the committed bun.lock - name: Install canvas build deps run: sudo apt-get update && sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev @@ -68,7 +68,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.3.12" # pinned to match the committed bun.lock - name: Install canvas build deps run: sudo apt-get update && sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev @@ -103,7 +103,7 @@ jobs: - uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: "1.3.12" # pinned to match the committed bun.lock - name: Install canvas build deps run: sudo apt-get update && sudo apt-get install -y libcairo2-dev libpango1.0-dev libjpeg-dev libgif-dev librsvg2-dev diff --git a/src/app/api/chat/upload/route.ts b/src/app/api/chat/upload/route.ts index 9500fafe..7af2ec6c 100644 --- a/src/app/api/chat/upload/route.ts +++ b/src/app/api/chat/upload/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/lib/auth" +import { getRequestUserId } from "@/lib/mobile-auth" import { ChatUploadFormSchema } from "@/features/chat-public/schema" import { isChatPublicServiceError, @@ -7,8 +7,8 @@ import { } from "@/features/chat-public/service" export async function POST(req: Request) { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -28,7 +28,7 @@ export async function POST(req: Request) { const result = await uploadChatAttachment({ file: parsedForm.data.file, sessionId: parsedForm.data.sessionId, - userId: session.user.id, + userId, }) if (isChatPublicServiceError(result)) { diff --git a/src/app/api/dashboard/chat/sessions/[id]/messages/route.ts b/src/app/api/dashboard/chat/sessions/[id]/messages/route.ts index 06d7ca7b..d4a376dc 100644 --- a/src/app/api/dashboard/chat/sessions/[id]/messages/route.ts +++ b/src/app/api/dashboard/chat/sessions/[id]/messages/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server" import { auth } from "@/lib/auth" +import { getRequestUserId } from "@/lib/mobile-auth" import { DashboardChatSessionIdParamsSchema, DashboardChatSessionMessageDeleteBodySchema, @@ -18,8 +19,8 @@ export async function POST( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -36,7 +37,7 @@ export async function POST( ) } const result = await addDashboardChatSessionMessages({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, input: parsedBody.data, }) @@ -96,8 +97,8 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -114,7 +115,7 @@ export async function DELETE( ) } const result = await deleteDashboardChatSessionMessages({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, input: parsedBody.data, }) diff --git a/src/app/api/dashboard/chat/sessions/[id]/route.ts b/src/app/api/dashboard/chat/sessions/[id]/route.ts index 21dff891..c4596bc0 100644 --- a/src/app/api/dashboard/chat/sessions/[id]/route.ts +++ b/src/app/api/dashboard/chat/sessions/[id]/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/lib/auth" +import { getRequestUserId } from "@/lib/mobile-auth" import { DashboardChatSessionIdParamsSchema, DashboardChatSessionUpdateBodySchema, @@ -16,8 +16,8 @@ export async function GET( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -27,7 +27,7 @@ export async function GET( } const result = await getDashboardChatSession({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, }) @@ -47,8 +47,8 @@ export async function PATCH( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -67,7 +67,7 @@ export async function PATCH( ) } const result = await updateDashboardChatSession({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, input: parsedBody.data, }) @@ -88,8 +88,8 @@ export async function DELETE( { params }: { params: Promise<{ id: string }> } ) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -99,7 +99,7 @@ export async function DELETE( } const result = await deleteDashboardChatSession({ - userId: session.user.id, + userId, sessionId: parsedParams.data.id, }) diff --git a/src/app/api/dashboard/chat/sessions/route.ts b/src/app/api/dashboard/chat/sessions/route.ts index 9edbb193..d19f3c5a 100644 --- a/src/app/api/dashboard/chat/sessions/route.ts +++ b/src/app/api/dashboard/chat/sessions/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server" -import { auth } from "@/lib/auth" +import { getRequestUserId } from "@/lib/mobile-auth" import { DashboardChatSessionCreateBodySchema, } from "@/features/conversations/sessions/schema" @@ -9,14 +9,14 @@ import { } from "@/features/conversations/sessions/service" import { isHttpServiceError } from "@/features/shared/http-service-error" -export async function GET() { +export async function GET(request: Request) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(request) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } - const result = await listDashboardChatSessions({ userId: session.user.id }) + const result = await listDashboardChatSessions({ userId }) return NextResponse.json(result) } catch (error) { console.error("[Chat Sessions API] GET error:", error) @@ -26,8 +26,8 @@ export async function GET() { export async function POST(req: Request) { try { - const session = await auth() - if (!session?.user?.id) { + const userId = await getRequestUserId(req) + if (!userId) { return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) } @@ -43,7 +43,7 @@ export async function POST(req: Request) { ) } const result = await createDashboardChatSession({ - userId: session.user.id, + userId, input: parsedBody.data, }) diff --git a/src/app/api/mobile/admin/models/route.ts b/src/app/api/mobile/admin/models/route.ts new file mode 100644 index 00000000..c47224b3 --- /dev/null +++ b/src/app/api/mobile/admin/models/route.ts @@ -0,0 +1,42 @@ +import { NextResponse } from "next/server" +import { requireMobileAdmin } from "@/lib/mobile-admin" +import { + listAdminModels, + setModelEnabled, + setModelToolCalling, + setDefaultChatModel, +} from "@/features/platform-admin/models-service" + +export async function GET(request: Request) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const params = new URL(request.url).searchParams + const result = await listAdminModels({ + search: params.get("search") ?? undefined, + providerId: params.get("providerId") ?? undefined, + }) + return NextResponse.json(result) +} + +export async function PATCH(request: Request) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const body = await request.json().catch(() => null) + if (!body?.id) return NextResponse.json({ error: "id is required" }, { status: 400 }) + + let result: { ok?: boolean; error?: string; defaultModelId?: string } + if (body.default === true) { + result = await setDefaultChatModel(auth.user, body.id) + } else if (typeof body.enabled === "boolean") { + result = await setModelEnabled(auth.user, body.id, body.enabled) + } else if (typeof body.hasToolCalling === "boolean") { + result = await setModelToolCalling(auth.user, body.id, body.hasToolCalling) + } else { + return NextResponse.json( + { error: "Nothing to update (enabled, default, or hasToolCalling)" }, + { status: 400 }, + ) + } + if (result.error) return NextResponse.json({ error: result.error }, { status: 400 }) + return NextResponse.json(result) +} diff --git a/src/app/api/mobile/admin/models/sync/route.ts b/src/app/api/mobile/admin/models/sync/route.ts new file mode 100644 index 00000000..2af3c580 --- /dev/null +++ b/src/app/api/mobile/admin/models/sync/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server" +import { requireMobileAdmin } from "@/lib/mobile-admin" +import { syncModelsFromOpenRouter } from "@/lib/models/sync" + +export async function POST(request: Request) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + if (!process.env.OPENROUTER_API_KEY) { + return NextResponse.json({ error: "OPENROUTER_API_KEY is not configured" }, { status: 400 }) + } + try { + return NextResponse.json(await syncModelsFromOpenRouter()) + } catch (err) { + return NextResponse.json( + { error: `Sync failed: ${err instanceof Error ? err.message : err}` }, + { status: 502 }, + ) + } +} diff --git a/src/app/api/mobile/admin/providers/route.ts b/src/app/api/mobile/admin/providers/route.ts new file mode 100644 index 00000000..1ec6c595 --- /dev/null +++ b/src/app/api/mobile/admin/providers/route.ts @@ -0,0 +1,9 @@ +import { NextResponse } from "next/server" +import { requireMobileAdmin } from "@/lib/mobile-admin" +import { listProviders } from "@/features/platform-admin/providers-service" + +export async function GET(request: Request) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + return NextResponse.json({ providers: await listProviders() }) +} diff --git a/src/app/api/mobile/admin/settings/kb/route.ts b/src/app/api/mobile/admin/settings/kb/route.ts new file mode 100644 index 00000000..adff3e42 --- /dev/null +++ b/src/app/api/mobile/admin/settings/kb/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server" +import { requireMobileAdmin } from "@/lib/mobile-admin" +import { getKbSettings, updateKbSettings } from "@/features/platform-admin/kb-settings-service" + +export async function GET(request: Request) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + return NextResponse.json(await getKbSettings()) +} + +export async function PUT(request: Request) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const body = await request.json().catch(() => null) + if (!body?.updates || typeof body.updates !== "object") { + return NextResponse.json({ error: "updates object is required" }, { status: 400 }) + } + const result = await updateKbSettings(auth.user, body.updates, { + confirmEmbeddingChange: body.confirmEmbeddingChange === true, + }) + if ("error" in result) { + return NextResponse.json(result, { status: result.requiresEmbeddingConfirmation ? 409 : 400 }) + } + return NextResponse.json(await getKbSettings()) +} diff --git a/src/app/api/mobile/admin/users/[id]/reset-password/route.ts b/src/app/api/mobile/admin/users/[id]/reset-password/route.ts new file mode 100644 index 00000000..8effe754 --- /dev/null +++ b/src/app/api/mobile/admin/users/[id]/reset-password/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server" +import { requireMobileAdmin } from "@/lib/mobile-admin" +import { resetUserPassword } from "@/features/platform-admin/users-service" + +export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const { id } = await params + const result = await resetUserPassword(auth.user, id) + if ("error" in result) return NextResponse.json({ error: result.error }, { status: 404 }) + return NextResponse.json(result) +} diff --git a/src/app/api/mobile/admin/users/[id]/route.ts b/src/app/api/mobile/admin/users/[id]/route.ts new file mode 100644 index 00000000..a756870d --- /dev/null +++ b/src/app/api/mobile/admin/users/[id]/route.ts @@ -0,0 +1,31 @@ +import { NextResponse } from "next/server" +import { requireMobileAdmin } from "@/lib/mobile-admin" +import { getUserDetail, setUserSuspended, setUserRole } from "@/features/platform-admin/users-service" + +export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const { id } = await params + const detail = await getUserDetail(id) + if (!detail) return NextResponse.json({ error: "User not found" }, { status: 404 }) + return NextResponse.json(detail) +} + +export async function PATCH(request: Request, { params }: { params: Promise<{ id: string }> }) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const { id } = await params + const body = await request.json().catch(() => null) + if (!body) return NextResponse.json({ error: "Invalid body" }, { status: 400 }) + if (typeof body.suspended === "boolean") { + const result = await setUserSuspended(auth.user, id, body.suspended) + if ("error" in result) return NextResponse.json({ error: result.error }, { status: 400 }) + } + if (body.role === "USER" || body.role === "ADMIN") { + const result = await setUserRole(auth.user, id, body.role) + if ("error" in result) return NextResponse.json({ error: result.error }, { status: 400 }) + } + const detail = await getUserDetail(id) + if (!detail) return NextResponse.json({ error: "User not found" }, { status: 404 }) + return NextResponse.json(detail) +} diff --git a/src/app/api/mobile/admin/users/route.ts b/src/app/api/mobile/admin/users/route.ts new file mode 100644 index 00000000..d68b6b73 --- /dev/null +++ b/src/app/api/mobile/admin/users/route.ts @@ -0,0 +1,33 @@ +import { NextResponse } from "next/server" +import { requireMobileAdmin } from "@/lib/mobile-admin" +import { listUsers, createUser } from "@/features/platform-admin/users-service" + +export async function GET(request: Request) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const params = new URL(request.url).searchParams + const suspendedParam = params.get("suspended") + const result = await listUsers({ + search: params.get("search") ?? undefined, + role: (params.get("role") as "USER" | "ADMIN" | null) ?? undefined, + suspended: suspendedParam === null ? undefined : suspendedParam === "true", + page: params.get("page") ? Number(params.get("page")) : undefined, + pageSize: params.get("pageSize") ? Number(params.get("pageSize")) : undefined, + }) + return NextResponse.json(result) +} + +export async function POST(request: Request) { + const auth = await requireMobileAdmin(request) + if ("error" in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) + const body = await request.json().catch(() => null) + if (!body?.email) return NextResponse.json({ error: "email is required" }, { status: 400 }) + const result = await createUser(auth.user, { + email: body.email, + name: body.name ?? "", + password: body.password || undefined, + role: body.role, + }) + if ("error" in result) return NextResponse.json({ error: result.error }, { status: 400 }) + return NextResponse.json(result, { status: 201 }) +} diff --git a/src/app/api/mobile/agent-api-keys/[id]/route.ts b/src/app/api/mobile/agent-api-keys/[id]/route.ts new file mode 100644 index 00000000..5125d47b --- /dev/null +++ b/src/app/api/mobile/agent-api-keys/[id]/route.ts @@ -0,0 +1,78 @@ +import { NextResponse } from "next/server" + +import { UpdateAgentApiKeySchema } from "@/features/agent-api-keys/schema" +import { + deleteAgentApiKey, + updateAgentApiKey, +} from "@/features/agent-api-keys/service" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +function isServiceError( + value: unknown +): value is { status: number; error: string } { + if (typeof value !== "object" || value === null) return false + const candidate = value as { status?: unknown; error?: unknown } + return typeof candidate.status === "number" && typeof candidate.error === "string" +} + +/** + * PUT /api/mobile/agent-api-keys/[id] — update a key (e.g. enable/disable, + * rename). Body: { name?, scopes?, ipWhitelist?, enabled?, expiresAt? }. + */ +export async function PUT(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params + const parsed = UpdateAgentApiKeySchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }) + } + const result = await updateAgentApiKey({ + context: { + organizationId: ctx.organizationId, + role: ctx.role, + userId: ctx.userId, + }, + id, + input: parsed.data, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Agent API Keys] PUT error:", error) + return NextResponse.json({ error: "Failed to update API key" }, { status: 500 }) + } +} + +/** + * DELETE /api/mobile/agent-api-keys/[id] — revoke a key. + */ +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params + const result = await deleteAgentApiKey( + { organizationId: ctx.organizationId, role: ctx.role, userId: ctx.userId }, + id + ) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[Mobile Agent API Keys] DELETE error:", error) + return NextResponse.json({ error: "Failed to revoke API key" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/agent-api-keys/route.ts b/src/app/api/mobile/agent-api-keys/route.ts new file mode 100644 index 00000000..72a3443f --- /dev/null +++ b/src/app/api/mobile/agent-api-keys/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server" + +import { CreateAgentApiKeySchema } from "@/features/agent-api-keys/schema" +import { + createAgentApiKey, + listAgentApiKeys, +} from "@/features/agent-api-keys/service" +import { getMobileContext } from "@/lib/mobile-org" + +function isServiceError( + value: unknown +): value is { status: number; error: string } { + if (typeof value !== "object" || value === null) return false + const candidate = value as { status?: unknown; error?: unknown } + return typeof candidate.status === "number" && typeof candidate.error === "string" +} + +/** + * GET /api/mobile/agent-api-keys — list the org's agent API keys (owner/admin). + */ +export async function GET(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const result = await listAgentApiKeys({ + organizationId: ctx.organizationId, + role: ctx.role, + userId: ctx.userId, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Agent API Keys] GET error:", error) + return NextResponse.json({ error: "Failed to fetch API keys" }, { status: 500 }) + } +} + +/** + * POST /api/mobile/agent-api-keys — create a key for an assistant (owner/admin). + * The full key is returned once. Body: { name, assistantId, scopes?, + * ipWhitelist?, expiresAt? }. + */ +export async function POST(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const parsed = CreateAgentApiKeySchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsed.error.flatten() }, + { status: 400 } + ) + } + const result = await createAgentApiKey({ + context: { + organizationId: ctx.organizationId, + role: ctx.role, + userId: ctx.userId, + }, + input: parsed.data, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result, { status: 201 }) + } catch (error) { + console.error("[Mobile Agent API Keys] POST error:", error) + return NextResponse.json({ error: "Failed to create API key" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/assistants/[id]/default/route.ts b/src/app/api/mobile/assistants/[id]/default/route.ts new file mode 100644 index 00000000..63931896 --- /dev/null +++ b/src/app/api/mobile/assistants/[id]/default/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" + +import { AssistantIdParamsSchema } from "@/features/assistants/core/schema" +import { getAssistantForUser } from "@/features/assistants/core/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { updateUserPreferences } from "@/features/user/preferences/service" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * POST /api/mobile/assistants/[id]/default — set this agent as the current + * user's personal default (UserPreference.defaultAssistantId). Unlike the web + * org-wide system default, this is per-user so any member can set it. + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + // Enforce org scoping: only agents the user can see may become the default. + const assistant = await getAssistantForUser({ + id: parsedParams.data.id, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(assistant)) { + return NextResponse.json({ error: assistant.error }, { status: assistant.status }) + } + + const preferences = await updateUserPreferences(ctx.userId, { + defaultAssistantId: parsedParams.data.id, + }) + if (isHttpServiceError(preferences)) { + return NextResponse.json({ error: preferences.error }, { status: preferences.status }) + } + + return NextResponse.json(preferences) + } catch (error) { + console.error("[Mobile Assistants API] set default error:", error) + return NextResponse.json({ error: "Failed to set default agent" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/assistants/[id]/duplicate/route.ts b/src/app/api/mobile/assistants/[id]/duplicate/route.ts new file mode 100644 index 00000000..210d6067 --- /dev/null +++ b/src/app/api/mobile/assistants/[id]/duplicate/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server" + +import { AssistantIdParamsSchema } from "@/features/assistants/core/schema" +import type { CreateAssistantInput } from "@/features/assistants/core/schema" +import { + createAssistantForUser, + getAssistantForUser, +} from "@/features/assistants/core/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * POST /api/mobile/assistants/[id]/duplicate — clone an agent's core config + * into a new org-scoped agent named " (Copy)". Tool/skill/etc. bindings + * are out of MVP scope and not copied. + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const source = await getAssistantForUser({ + id: parsedParams.data.id, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(source)) { + return NextResponse.json({ error: source.error }, { status: source.status }) + } + + const s = source as Record + const input: CreateAssistantInput = { + name: `${String(s.name)} (Copy)`, + description: (s.description as string | null) ?? undefined, + emoji: (s.emoji as string | undefined) ?? undefined, + systemPrompt: String(s.systemPrompt), + model: (s.model as string | undefined) ?? undefined, + useKnowledgeBase: (s.useKnowledgeBase as boolean | undefined) ?? undefined, + knowledgeBaseGroupIds: (s.knowledgeBaseGroupIds as string[] | undefined) ?? undefined, + liveChatEnabled: (s.liveChatEnabled as boolean | undefined) ?? undefined, + modelConfig: (s.modelConfig as unknown) ?? undefined, + openingMessage: (s.openingMessage as string | null) ?? undefined, + openingQuestions: (s.openingQuestions as string[] | undefined) ?? undefined, + chatConfig: (s.chatConfig as unknown) ?? undefined, + guardRails: (s.guardRails as unknown) ?? undefined, + memoryConfig: (s.memoryConfig as unknown) ?? undefined, + avatarS3Key: (s.avatarS3Key as string | null) ?? undefined, + tags: (s.tags as string[] | undefined) ?? undefined, + } + + const created = await createAssistantForUser({ + userId: ctx.userId, + input, + organizationId: ctx.organizationId, + role: ctx.role, + }) + if (isHttpServiceError(created)) { + return NextResponse.json({ error: created.error }, { status: created.status }) + } + + return NextResponse.json(created, { status: 201 }) + } catch (error) { + console.error("[Mobile Assistants API] duplicate error:", error) + return NextResponse.json({ error: "Failed to duplicate assistant" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/assistants/[id]/route.ts b/src/app/api/mobile/assistants/[id]/route.ts new file mode 100644 index 00000000..3d98ea68 --- /dev/null +++ b/src/app/api/mobile/assistants/[id]/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server" + +import { + AssistantIdParamsSchema, + UpdateAssistantSchema, +} from "@/features/assistants/core/schema" +import { + deleteAssistantForUser, + getAssistantForUser, + updateAssistantForUser, +} from "@/features/assistants/core/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +// GET /api/mobile/assistants/[id] +export async function GET(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const assistant = await getAssistantForUser({ + id: parsedParams.data.id, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(assistant)) { + return NextResponse.json({ error: assistant.error }, { status: assistant.status }) + } + + return NextResponse.json(assistant) + } catch (error) { + console.error("[Mobile Assistants API] GET [id] error:", error) + return NextResponse.json({ error: "Failed to fetch assistant" }, { status: 500 }) + } +} + +// PUT /api/mobile/assistants/[id] +export async function PUT(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const bodyParse = UpdateAssistantSchema.safeParse(await request.json()) + if (!bodyParse.success) { + return NextResponse.json( + { error: "Invalid request payload", details: bodyParse.error.flatten() }, + { status: 400 } + ) + } + + const assistant = await updateAssistantForUser({ + id: parsedParams.data.id, + userId: ctx.userId, + input: bodyParse.data, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(assistant)) { + return NextResponse.json({ error: assistant.error }, { status: assistant.status }) + } + + return NextResponse.json(assistant) + } catch (error) { + console.error("[Mobile Assistants API] PUT [id] error:", error) + return NextResponse.json({ error: "Failed to update assistant" }, { status: 500 }) + } +} + +// DELETE /api/mobile/assistants/[id] +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const result = await deleteAssistantForUser({ + id: parsedParams.data.id, + context: { organizationId: ctx.organizationId, role: ctx.role }, + }) + if (isHttpServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Assistants API] DELETE [id] error:", error) + return NextResponse.json({ error: "Failed to delete assistant" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/assistants/[id]/skills/route.ts b/src/app/api/mobile/assistants/[id]/skills/route.ts new file mode 100644 index 00000000..925f9cc2 --- /dev/null +++ b/src/app/api/mobile/assistants/[id]/skills/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server" + +import { + AssistantIdParamsSchema, + AssistantSkillIdsSchema, +} from "@/features/assistants/bindings/schema" +import { + isServiceError, + listAssistantSkills, + setAssistantSkills, +} from "@/features/assistants/bindings/service" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * GET /api/mobile/assistants/[id]/skills — the skills currently bound to the + * agent (mobile Bearer auth; catalog comes from GET /api/mobile/skills). + */ +export async function GET(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const result = await listAssistantSkills(parsedParams.data.id, { + organizationId: ctx.organizationId, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Assistant Skills API] GET error:", error) + return NextResponse.json( + { error: "Failed to fetch assistant skills" }, + { status: 500 } + ) + } +} + +/** + * PUT /api/mobile/assistants/[id]/skills — replace the agent's skill bindings. + * Body: { skillIds: string[] }. + */ +export async function PUT(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const parsedBody = AssistantSkillIdsSchema.safeParse(await request.json()) + if (!parsedBody.success) { + return NextResponse.json( + { error: "skillIds must be an array of strings" }, + { status: 400 } + ) + } + + const result = await setAssistantSkills( + parsedParams.data.id, + parsedBody.data.skillIds, + { organizationId: ctx.organizationId } + ) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Assistant Skills API] PUT error:", error) + return NextResponse.json( + { error: "Failed to update assistant skills" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/mobile/assistants/[id]/tools/route.ts b/src/app/api/mobile/assistants/[id]/tools/route.ts new file mode 100644 index 00000000..b74a5aa2 --- /dev/null +++ b/src/app/api/mobile/assistants/[id]/tools/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server" + +import { + AssistantIdParamsSchema, + AssistantToolIdsSchema, +} from "@/features/assistants/bindings/schema" +import { + isServiceError, + listAssistantTools, + setAssistantTools, +} from "@/features/assistants/bindings/service" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * GET /api/mobile/assistants/[id]/tools — the tools currently bound to the + * agent (mobile Bearer auth; mirrors the web session-based route). + */ +export async function GET(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const result = await listAssistantTools(parsedParams.data.id, { + organizationId: ctx.organizationId, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Assistant Tools API] GET error:", error) + return NextResponse.json( + { error: "Failed to fetch assistant tools" }, + { status: 500 } + ) + } +} + +/** + * PUT /api/mobile/assistants/[id]/tools — replace the agent's tool bindings. + * Body: { toolIds: string[] }. + */ +export async function PUT(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = AssistantIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Invalid assistant id" }, { status: 400 }) + } + + const parsedBody = AssistantToolIdsSchema.safeParse(await request.json()) + if (!parsedBody.success) { + return NextResponse.json( + { error: "toolIds must be an array of strings" }, + { status: 400 } + ) + } + + const result = await setAssistantTools( + parsedParams.data.id, + parsedBody.data.toolIds, + { organizationId: ctx.organizationId } + ) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Assistant Tools API] PUT error:", error) + return NextResponse.json( + { error: "Failed to update assistant tools" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/mobile/assistants/route.ts b/src/app/api/mobile/assistants/route.ts new file mode 100644 index 00000000..888247b7 --- /dev/null +++ b/src/app/api/mobile/assistants/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from "next/server" + +import { CreateAssistantSchema } from "@/features/assistants/core/schema" +import { + createAssistantForUser, + listAssistantsForUser, +} from "@/features/assistants/core/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getUserPreferences } from "@/features/user/preferences/service" +import { getMobileContext } from "@/lib/mobile-org" + +/** + * GET /api/mobile/assistants — list agents visible to the user's org, plus the + * user's personal default agent id (so the app can mark it in the list). + */ +export async function GET(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const [assistants, preferences] = await Promise.all([ + listAssistantsForUser({ + organizationId: ctx.organizationId, + role: ctx.role, + }), + getUserPreferences(ctx.userId), + ]) + + return NextResponse.json({ + assistants, + defaultAssistantId: preferences.defaultAssistantId, + }) + } catch (error) { + console.error("[Mobile Assistants API] GET error:", error) + return NextResponse.json( + { error: "Failed to fetch assistants" }, + { status: 500 } + ) + } +} + +/** + * POST /api/mobile/assistants — create an agent. Only `name` and `systemPrompt` + * are required; the rest is defaulted server-side. + */ +export async function POST(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsed = CreateAssistantSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const assistant = await createAssistantForUser({ + userId: ctx.userId, + input: parsed.data, + organizationId: ctx.organizationId, + role: ctx.role, + }) + if (isHttpServiceError(assistant)) { + return NextResponse.json({ error: assistant.error }, { status: assistant.status }) + } + + return NextResponse.json(assistant, { status: 201 }) + } catch (error) { + console.error("[Mobile Assistants API] POST error:", error) + return NextResponse.json( + { error: "Failed to create assistant" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/mobile/chat/regenerate/route.ts b/src/app/api/mobile/chat/regenerate/route.ts new file mode 100644 index 00000000..068fbfe8 --- /dev/null +++ b/src/app/api/mobile/chat/regenerate/route.ts @@ -0,0 +1,41 @@ +import { NextResponse } from "next/server" + +import { regenerateMobileReply } from "@/lib/mobile-chat" +import { getRequestUserId } from "@/lib/mobile-auth" +import { readChatOptions } from "../route" + +/** + * POST /api/mobile/chat/regenerate + * Body: { sessionId, ...opsi tools/skills/canvas } + * Membuang balasan asisten terakhir lalu menghasilkan balasan baru dari + * riwayat yang tersisa. Pesan user tidak ditulis ulang. + */ +export async function POST(req: Request) { + const userId = await getRequestUserId(req) + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + let body: Record + try { + body = await req.json() + } catch { + body = {} + } + + const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "" + if (!sessionId) { + return NextResponse.json({ error: "sessionId wajib diisi" }, { status: 400 }) + } + + const result = await regenerateMobileReply({ + userId, + sessionId, + options: readChatOptions(body), + }) + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) +} diff --git a/src/app/api/mobile/chat/route.ts b/src/app/api/mobile/chat/route.ts new file mode 100644 index 00000000..46f525f6 --- /dev/null +++ b/src/app/api/mobile/chat/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server" + +import { generateMobileReply, type MobileChatOptions } from "@/lib/mobile-chat" +import { getRequestUserId } from "@/lib/mobile-auth" + +/** Ambil opsi tools/skills/canvas dari body request. */ +export function readChatOptions(body: Record): MobileChatOptions { + const strings = (v: unknown) => + Array.isArray(v) ? v.filter((n): n is string => typeof n === "string") : undefined + const canvasMode = typeof body.canvasMode === "string" ? body.canvasMode.trim() : "" + const fileContext = typeof body.fileContext === "string" ? body.fileContext.trim() : "" + + return { + enableWebSearch: body.enableWebSearch === true, + enableCodeInterpreter: body.enableCodeInterpreter === true, + enabledToolNames: strings(body.enabledToolNames), + enabledSkillIds: strings(body.enabledSkillIds), + canvasMode: canvasMode || undefined, + fileContext: fileContext || undefined, + } +} + +/** + * POST /api/mobile/chat + * Body: { sessionId, content, enableWebSearch?, enableCodeInterpreter?, + * enabledToolNames?, enabledSkillIds?, canvasMode?, fileContext? } + * Menyimpan pesan user, menghasilkan balasan AI (non-streaming), menyimpannya, + * lalu mengembalikan { reply }. + */ +export async function POST(req: Request) { + const userId = await getRequestUserId(req) + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + let body: Record + try { + body = await req.json() + } catch { + body = {} + } + + const sessionId = typeof body.sessionId === "string" ? body.sessionId.trim() : "" + const content = typeof body.content === "string" ? body.content.trim() : "" + if (!sessionId || !content) { + return NextResponse.json( + { error: "sessionId dan content wajib diisi" }, + { status: 400 } + ) + } + + const result = await generateMobileReply({ + userId, + sessionId, + content, + replyTo: typeof body.replyTo === "string" ? body.replyTo.trim() || undefined : undefined, + options: readChatOptions(body), + }) + if ("error" in result) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) +} diff --git a/src/app/api/mobile/credentials/[id]/route.ts b/src/app/api/mobile/credentials/[id]/route.ts new file mode 100644 index 00000000..38adabfc --- /dev/null +++ b/src/app/api/mobile/credentials/[id]/route.ts @@ -0,0 +1,82 @@ +import { NextResponse } from "next/server" + +import { UpdateCredentialSchema } from "@/features/credentials/schema" +import { + deleteDashboardCredentialRecord, + updateDashboardCredentialRecord, +} from "@/features/credentials/service" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +function isServiceError( + value: unknown +): value is { status: number; error: string } { + if (typeof value !== "object" || value === null) return false + const candidate = value as { status?: unknown; error?: unknown } + return typeof candidate.status === "number" && typeof candidate.error === "string" +} + +/** + * PUT /api/mobile/credentials/[id] — update a credential. Omit `data` to keep + * the existing secret. Body: { name?, type?, data? }. + */ +export async function PUT(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + const parsed = UpdateCredentialSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const result = await updateDashboardCredentialRecord({ + context: { organizationId: ctx.organizationId, userId: ctx.userId }, + id, + input: parsed.data, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Credentials API] PUT error:", error) + return NextResponse.json({ error: "Failed to update credential" }, { status: 500 }) + } +} + +/** + * DELETE /api/mobile/credentials/[id] — delete a credential. + */ +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + const result = await deleteDashboardCredentialRecord({ + context: { organizationId: ctx.organizationId, userId: ctx.userId }, + id, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[Mobile Credentials API] DELETE error:", error) + return NextResponse.json({ error: "Failed to delete credential" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/credentials/route.ts b/src/app/api/mobile/credentials/route.ts new file mode 100644 index 00000000..d6eb5124 --- /dev/null +++ b/src/app/api/mobile/credentials/route.ts @@ -0,0 +1,76 @@ +import { NextResponse } from "next/server" + +import { CreateCredentialSchema } from "@/features/credentials/schema" +import { + createDashboardCredential, + listDashboardCredentials, +} from "@/features/credentials/service" +import { getMobileContext } from "@/lib/mobile-org" + +function isServiceError( + value: unknown +): value is { status: number; error: string } { + if (typeof value !== "object" || value === null) return false + const candidate = value as { status?: unknown; error?: unknown } + return typeof candidate.status === "number" && typeof candidate.error === "string" +} + +/** + * GET /api/mobile/credentials — list the org's credentials (masked; no secret + * data is ever returned). + */ +export async function GET(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const credentials = await listDashboardCredentials({ + organizationId: ctx.organizationId, + userId: ctx.userId, + }) + return NextResponse.json(credentials) + } catch (error) { + console.error("[Mobile Credentials API] GET error:", error) + return NextResponse.json( + { error: "Failed to fetch credentials" }, + { status: 500 } + ) + } +} + +/** + * POST /api/mobile/credentials — create a credential. `data` is sent as + * plaintext and encrypted (AES-256-GCM) server-side. + * Body: { name, type, data }. + */ +export async function POST(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsed = CreateCredentialSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsed.error.flatten() }, + { status: 400 } + ) + } + + const result = await createDashboardCredential({ + context: { organizationId: ctx.organizationId, userId: ctx.userId }, + input: parsed.data, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result, { status: 201 }) + } catch (error) { + console.error("[Mobile Credentials API] POST error:", error) + return NextResponse.json({ error: "Failed to create credential" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/features/route.ts b/src/app/api/mobile/features/route.ts new file mode 100644 index 00000000..5378eebd --- /dev/null +++ b/src/app/api/mobile/features/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from "next/server" + +import { UpdateAdminFeatureSchema } from "@/features/admin/features/schema" +import { + getAdminFeatures, + updateAdminFeature, +} from "@/features/admin/features/service" +import { getRequestUserId } from "@/lib/mobile-auth" +import { prisma } from "@/lib/prisma" + +/** True when the given user is a platform admin (system role ADMIN). */ +async function isAdmin(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { role: true }, + }) + return user?.role === "ADMIN" +} + +/** + * GET /api/mobile/features — beta feature flags (platform admin only). + */ +export async function GET(request: Request) { + try { + const userId = await getRequestUserId(request) + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + if (!(await isAdmin(userId))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const features = await getAdminFeatures() + return NextResponse.json(features) + } catch (error) { + console.error("[Mobile Features API] GET error:", error) + return NextResponse.json({ error: "Failed to fetch features" }, { status: 500 }) + } +} + +/** + * PUT /api/mobile/features — toggle a beta feature (platform admin only). + * Body: { feature, enabled?, config? }. + */ +export async function PUT(request: Request) { + try { + const userId = await getRequestUserId(request) + if (!userId) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + if (!(await isAdmin(userId))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }) + } + + const parsed = UpdateAdminFeatureSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ error: "Feature is required" }, { status: 400 }) + } + + const updated = await updateAdminFeature(parsed.data) + return NextResponse.json(updated) + } catch (error) { + console.error("[Mobile Features API] PUT error:", error) + return NextResponse.json({ error: "Failed to update feature" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/knowledge/categories/route.ts b/src/app/api/mobile/knowledge/categories/route.ts new file mode 100644 index 00000000..a6e14b00 --- /dev/null +++ b/src/app/api/mobile/knowledge/categories/route.ts @@ -0,0 +1,52 @@ +import { NextResponse } from "next/server" + +import { KnowledgeCategoryCreateSchema } from "@/features/knowledge/categories/schema" +import { + createKnowledgeCategoryForDashboard, + listKnowledgeCategoriesForDashboard, +} from "@/features/knowledge/categories/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +/** GET /api/mobile/knowledge/categories — org + global category tags. */ +export async function GET(request: Request) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + try { + const categories = await listKnowledgeCategoriesForDashboard(ctx.organizationId) + return NextResponse.json({ categories }) + } catch (error) { + console.error("[Mobile Knowledge] list categories error:", error) + return NextResponse.json({ error: "Failed to list categories" }, { status: 500 }) + } +} + +/** POST /api/mobile/knowledge/categories — create a category tag. */ +export async function POST(request: Request) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = KnowledgeCategoryCreateSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsed.error.flatten() }, + { status: 400 }, + ) + } + + try { + const category = await createKnowledgeCategoryForDashboard({ + input: parsed.data, + organizationId: ctx.organizationId, + userId: ctx.userId, + }) + if (isHttpServiceError(category)) { + return NextResponse.json({ error: category.error }, { status: category.status }) + } + return NextResponse.json(category) + } catch (error) { + console.error("[Mobile Knowledge] create category error:", error) + return NextResponse.json({ error: "Failed to create category" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/knowledge/documents/[id]/file/route.ts b/src/app/api/mobile/knowledge/documents/[id]/file/route.ts new file mode 100644 index 00000000..e1bd7aaf --- /dev/null +++ b/src/app/api/mobile/knowledge/documents/[id]/file/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" + +import { getKnowledgeDocumentForDashboard } from "@/features/knowledge/documents/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { userIdFromMobileToken } from "@/lib/mobile-auth" +import { resolveActiveOrg } from "@/lib/org-context" +import { downloadFile } from "@/lib/s3" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * GET /api/mobile/knowledge/documents/[id]/file — stream the original file bytes + * through the app server (so the phone never hits the internal S3 host). Mainly + * for image documents. Accepts the token via header or `?token=` (RN + * can't reliably send auth headers). `?download=1` forces an attachment. + */ +export async function GET(request: Request, { params }: RouteParams) { + const url = new URL(request.url) + const header = request.headers.get("authorization") + const rawToken = header?.startsWith("Bearer ") + ? header.slice(7) + : url.searchParams.get("token") + + const userId = rawToken ? await userIdFromMobileToken(rawToken) : null + if (!userId) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const org = await resolveActiveOrg(request, userId) + const { id } = await params + + const doc = await getKnowledgeDocumentForDashboard({ + documentId: id, + organizationId: org?.organizationId ?? null, + }) + if (isHttpServiceError(doc)) { + return NextResponse.json({ error: doc.error }, { status: doc.status }) + } + + const detail = doc as { s3Key?: string | null; mimeType?: string | null } + if (!detail.s3Key) { + return NextResponse.json({ error: "No file for this document" }, { status: 404 }) + } + + try { + const bytes = await downloadFile(detail.s3Key) + const contentType = detail.mimeType || "application/octet-stream" + const headers: Record = { + "Content-Type": contentType, + "Content-Length": String(bytes.byteLength), + "Cache-Control": "private, max-age=3600", + } + if (url.searchParams.get("download") === "1") { + const ext = contentType.split("/")[1]?.split(";")[0] ?? "bin" + headers["Content-Disposition"] = `attachment; filename="${id}.${ext}"` + } + return new NextResponse(new Uint8Array(bytes), { headers }) + } catch (error) { + console.error("[Mobile Knowledge] file proxy error:", error) + return NextResponse.json({ error: "Failed to load file" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/knowledge/documents/[id]/intelligence/route.ts b/src/app/api/mobile/knowledge/documents/[id]/intelligence/route.ts new file mode 100644 index 00000000..5629c210 --- /dev/null +++ b/src/app/api/mobile/knowledge/documents/[id]/intelligence/route.ts @@ -0,0 +1,43 @@ +import { NextResponse } from "next/server" + +import { KnowledgeDocumentIdParamsSchema } from "@/features/knowledge/documents/schema" +import { + getKnowledgeDocumentForDashboard, + getKnowledgeDocumentIntelligence, +} from "@/features/knowledge/documents/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * GET /api/mobile/knowledge/documents/[id]/intelligence — the document's + * extracted entities + relations (from SurrealDB). Org access is enforced via + * the document first. + */ +export async function GET(request: Request, { params }: RouteParams) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = KnowledgeDocumentIdParamsSchema.safeParse(await params) + if (!parsed.success) return NextResponse.json({ error: "Invalid document id" }, { status: 400 }) + + // Authorize: the document must be visible to the caller's org. + const doc = await getKnowledgeDocumentForDashboard({ + documentId: parsed.data.id, + organizationId: ctx.organizationId, + }) + if (isHttpServiceError(doc)) { + return NextResponse.json({ error: doc.error }, { status: doc.status }) + } + + try { + const result = await getKnowledgeDocumentIntelligence({ documentId: parsed.data.id }) + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Knowledge] intelligence error:", error) + return NextResponse.json({ error: "Failed to fetch document intelligence" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/knowledge/documents/[id]/route.ts b/src/app/api/mobile/knowledge/documents/[id]/route.ts new file mode 100644 index 00000000..a87a746c --- /dev/null +++ b/src/app/api/mobile/knowledge/documents/[id]/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from "next/server" + +import { + KnowledgeDocumentIdParamsSchema, + KnowledgeDocumentUpdateSchema, +} from "@/features/knowledge/documents/schema" +import { + deleteKnowledgeDocumentForDashboard, + getKnowledgeDocumentForDashboard, + updateKnowledgeDocumentForDashboard, +} from "@/features/knowledge/documents/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +// GET /api/mobile/knowledge/documents/[id] — detail with extracted content + chunks +export async function GET(request: Request, { params }: RouteParams) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = KnowledgeDocumentIdParamsSchema.safeParse(await params) + if (!parsed.success) return NextResponse.json({ error: "Invalid document id" }, { status: 400 }) + + try { + const result = await getKnowledgeDocumentForDashboard({ + documentId: parsed.data.id, + organizationId: ctx.organizationId, + }) + if (isHttpServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Knowledge] get document error:", error) + return NextResponse.json({ error: "Failed to get document" }, { status: 500 }) + } +} + +// PUT /api/mobile/knowledge/documents/[id] — update metadata (title/categories/groups) +export async function PUT(request: Request, { params }: RouteParams) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsedParams = KnowledgeDocumentIdParamsSchema.safeParse(await params) + if (!parsedParams.success) return NextResponse.json({ error: "Invalid document id" }, { status: 400 }) + + const parsedBody = KnowledgeDocumentUpdateSchema.safeParse(await request.json()) + if (!parsedBody.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsedBody.error.flatten() }, + { status: 400 }, + ) + } + + try { + const result = await updateKnowledgeDocumentForDashboard({ + documentId: parsedParams.data.id, + organizationId: ctx.organizationId, + role: ctx.role, + userId: ctx.userId, + input: parsedBody.data, + }) + if (isHttpServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Knowledge] update document error:", error) + return NextResponse.json({ error: "Failed to update document" }, { status: 500 }) + } +} + +// DELETE /api/mobile/knowledge/documents/[id] — soft delete (?hard=true to purge) +export async function DELETE(request: Request, { params }: RouteParams) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = KnowledgeDocumentIdParamsSchema.safeParse(await params) + if (!parsed.success) return NextResponse.json({ error: "Invalid document id" }, { status: 400 }) + + const hard = new URL(request.url).searchParams.get("hard") === "true" + + try { + const result = await deleteKnowledgeDocumentForDashboard({ + documentId: parsed.data.id, + organizationId: ctx.organizationId, + role: ctx.role, + userId: ctx.userId, + hard, + }) + if (isHttpServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Knowledge] delete document error:", error) + return NextResponse.json({ error: "Failed to delete document" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/knowledge/documents/route.ts b/src/app/api/mobile/knowledge/documents/route.ts new file mode 100644 index 00000000..94eed37a --- /dev/null +++ b/src/app/api/mobile/knowledge/documents/route.ts @@ -0,0 +1,124 @@ +import { NextResponse } from "next/server" + +import { + KnowledgeDocumentCreateSchema, + KnowledgeDocumentListQuerySchema, +} from "@/features/knowledge/documents/schema" +import { + createKnowledgeDocumentForDashboard, + listKnowledgeDocumentsForDashboard, +} from "@/features/knowledge/documents/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +// Document ingestion (extract + OCR + chunk + embed) is synchronous and can be +// slow for large/scanned files — give the route plenty of headroom. +export const maxDuration = 600 + +function parseList(value: FormDataEntryValue | null): string[] { + if (typeof value !== "string" || value.length === 0) return [] + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) { + return parsed.filter((e): e is string => typeof e === "string" && e.length > 0) + } + } catch { + return value.split(",").filter(Boolean) + } + return value.split(",").filter(Boolean) +} + +/** GET /api/mobile/knowledge/documents?groupId= — list documents (lightweight). */ +export async function GET(request: Request) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsedQuery = KnowledgeDocumentListQuerySchema.safeParse({ + groupId: new URL(request.url).searchParams.get("groupId") || undefined, + }) + if (!parsedQuery.success) { + return NextResponse.json({ error: "Invalid query" }, { status: 400 }) + } + + try { + const documents = await listKnowledgeDocumentsForDashboard({ + organizationId: ctx.organizationId, + groupId: parsedQuery.data.groupId ?? null, + }) + return NextResponse.json({ documents }) + } catch (error) { + console.error("[Mobile Knowledge] list documents error:", error) + return NextResponse.json({ error: "Failed to list documents" }, { status: 500 }) + } +} + +/** + * POST /api/mobile/knowledge/documents — upload a file (multipart) or raw JSON + * document. Synchronous ingestion; the response returns once processing is done. + */ +export async function POST(request: Request) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const context = { userId: ctx.userId, organizationId: ctx.organizationId, role: ctx.role } + const contentType = request.headers.get("content-type") || "" + + try { + if (contentType.includes("multipart/form-data")) { + const formData = await request.formData() + const file = formData.get("file") as File | null + if (!file) return NextResponse.json({ error: "No file provided" }, { status: 400 }) + + const result = await createKnowledgeDocumentForDashboard({ + context, + input: { + kind: "file", + file, + title: (formData.get("title") as string | null) ?? undefined, + categories: parseList(formData.get("categories")), + subcategory: (formData.get("subcategory") as string | null) ?? undefined, + groupIds: parseList(formData.get("groupIds")), + // Enhanced (entity/relation extraction for the Intelligence view) is + // on by default, matching the web; the client can disable it for speed. + useEnhanced: formData.get("enhanced") !== "false", + useCombined: true, + forceOCR: formData.get("forceOCR") === "true", + documentType: (formData.get("documentType") as string | null) ?? undefined, + }, + }) + if (isHttpServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result) + } + + const parsed = KnowledgeDocumentCreateSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsed.error.flatten() }, + { status: 400 }, + ) + } + const enhanced = new URL(request.url).searchParams.get("enhanced") === "true" + const result = await createKnowledgeDocumentForDashboard({ + context, + input: { + kind: "json", + title: parsed.data.title, + content: parsed.data.content, + categories: parsed.data.categories, + subcategory: parsed.data.subcategory, + groupIds: Array.isArray(parsed.data.groupIds) ? parsed.data.groupIds : [], + useEnhanced: enhanced, + useCombined: true, + }, + }) + if (isHttpServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Knowledge] create document error:", error) + return NextResponse.json({ error: "Failed to create document" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/knowledge/groups/[id]/route.ts b/src/app/api/mobile/knowledge/groups/[id]/route.ts new file mode 100644 index 00000000..a7b7b351 --- /dev/null +++ b/src/app/api/mobile/knowledge/groups/[id]/route.ts @@ -0,0 +1,99 @@ +import { NextResponse } from "next/server" + +import { + KnowledgeGroupIdParamsSchema, + KnowledgeGroupUpdateSchema, +} from "@/features/knowledge/groups/schema" +import { + deleteKnowledgeGroupForDashboard, + getKnowledgeGroupForDashboard, + updateKnowledgeGroupForDashboard, +} from "@/features/knowledge/groups/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +// GET /api/mobile/knowledge/groups/[id] — a knowledge base with its documents +export async function GET(request: Request, { params }: RouteParams) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = KnowledgeGroupIdParamsSchema.safeParse(await params) + if (!parsed.success) return NextResponse.json({ error: "Invalid group id" }, { status: 400 }) + + try { + const group = await getKnowledgeGroupForDashboard({ + groupId: parsed.data.id, + organizationId: ctx.organizationId, + }) + if (isHttpServiceError(group)) { + return NextResponse.json({ error: group.error }, { status: group.status }) + } + return NextResponse.json(group) + } catch (error) { + console.error("[Mobile Knowledge] get group error:", error) + return NextResponse.json({ error: "Failed to get group" }, { status: 500 }) + } +} + +// PUT /api/mobile/knowledge/groups/[id] +export async function PUT(request: Request, { params }: RouteParams) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsedParams = KnowledgeGroupIdParamsSchema.safeParse(await params) + if (!parsedParams.success) return NextResponse.json({ error: "Invalid group id" }, { status: 400 }) + + const parsedBody = KnowledgeGroupUpdateSchema.safeParse(await request.json()) + if (!parsedBody.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsedBody.error.flatten() }, + { status: 400 }, + ) + } + + try { + const group = await updateKnowledgeGroupForDashboard({ + groupId: parsedParams.data.id, + organizationId: ctx.organizationId, + role: ctx.role, + userId: ctx.userId, + input: parsedBody.data, + }) + if (isHttpServiceError(group)) { + return NextResponse.json({ error: group.error }, { status: group.status }) + } + return NextResponse.json(group) + } catch (error) { + console.error("[Mobile Knowledge] update group error:", error) + return NextResponse.json({ error: "Failed to update group" }, { status: 500 }) + } +} + +// DELETE /api/mobile/knowledge/groups/[id] — unassigns documents (keeps them) +export async function DELETE(request: Request, { params }: RouteParams) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = KnowledgeGroupIdParamsSchema.safeParse(await params) + if (!parsed.success) return NextResponse.json({ error: "Invalid group id" }, { status: 400 }) + + try { + const group = await deleteKnowledgeGroupForDashboard({ + groupId: parsed.data.id, + organizationId: ctx.organizationId, + role: ctx.role, + userId: ctx.userId, + }) + if (isHttpServiceError(group)) { + return NextResponse.json({ error: group.error }, { status: group.status }) + } + return NextResponse.json(group) + } catch (error) { + console.error("[Mobile Knowledge] delete group error:", error) + return NextResponse.json({ error: "Failed to delete group" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/knowledge/groups/route.ts b/src/app/api/mobile/knowledge/groups/route.ts new file mode 100644 index 00000000..1022e66f --- /dev/null +++ b/src/app/api/mobile/knowledge/groups/route.ts @@ -0,0 +1,57 @@ +import { NextResponse } from "next/server" + +import { KnowledgeGroupCreateSchema } from "@/features/knowledge/groups/schema" +import { + createKnowledgeGroupForDashboard, + listKnowledgeGroupsForDashboard, +} from "@/features/knowledge/groups/service" +import { countKnowledgeDocumentsForDashboard } from "@/features/knowledge/documents/service" +import { isHttpServiceError } from "@/features/shared/http-service-error" +import { getMobileContext } from "@/lib/mobile-org" + +/** GET /api/mobile/knowledge/groups — knowledge bases with total doc count. */ +export async function GET(request: Request) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + try { + const [groups, totalDocumentCount] = await Promise.all([ + listKnowledgeGroupsForDashboard(ctx.organizationId), + countKnowledgeDocumentsForDashboard(ctx.organizationId), + ]) + return NextResponse.json({ groups, totalDocumentCount }) + } catch (error) { + console.error("[Mobile Knowledge] list groups error:", error) + return NextResponse.json({ error: "Failed to list groups" }, { status: 500 }) + } +} + +/** POST /api/mobile/knowledge/groups — create a knowledge base. */ +export async function POST(request: Request) { + const ctx = await getMobileContext(request) + if (!ctx) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + + const parsed = KnowledgeGroupCreateSchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json( + { error: "Invalid request payload", details: parsed.error.flatten() }, + { status: 400 }, + ) + } + + try { + const group = await createKnowledgeGroupForDashboard({ + organizationId: ctx.organizationId, + role: ctx.role, + userId: ctx.userId, + input: parsed.data, + }) + if (isHttpServiceError(group)) { + return NextResponse.json({ error: group.error }, { status: group.status }) + } + return NextResponse.json(group) + } catch (error) { + console.error("[Mobile Knowledge] create group error:", error) + return NextResponse.json({ error: "Failed to create group" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/login/route.ts b/src/app/api/mobile/login/route.ts new file mode 100644 index 00000000..0bad7471 --- /dev/null +++ b/src/app/api/mobile/login/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server" + +import { loginMobile } from "@/lib/mobile-auth" + +/** + * POST /api/mobile/login + * Body: { email, password } + * Sukses: { token, user } — token dipakai sebagai `Authorization: Bearer` oleh app mobile. + */ +export async function POST(request: Request) { + let body: { email?: string; password?: string } + try { + body = await request.json() + } catch { + body = {} + } + + if (!body.email || !body.password) { + return NextResponse.json( + { error: "Email dan password wajib diisi" }, + { status: 400 } + ) + } + + const result = await loginMobile(body.email, body.password) + if ("error" in result) { + if (result.error === "suspended") { + return NextResponse.json( + { error: "Akun Anda ditangguhkan. Hubungi administrator." }, + { status: 403 } + ) + } + return NextResponse.json( + { error: "Email atau password salah" }, + { status: 401 } + ) + } + + return NextResponse.json(result) +} diff --git a/src/app/api/mobile/marketplace/[id]/route.ts b/src/app/api/mobile/marketplace/[id]/route.ts new file mode 100644 index 00000000..b2523751 --- /dev/null +++ b/src/app/api/mobile/marketplace/[id]/route.ts @@ -0,0 +1,62 @@ +import { NextResponse } from "next/server" + +import { + getDashboardMarketplaceItemDetail, + type ServiceError, +} from "@/features/marketplace/service" +import { DashboardMarketplaceIdParamsSchema } from "@/features/marketplace/schema" +import { getCatalogItemById } from "@/lib/marketplace/catalog" +import { getMobileContext } from "@/lib/mobile-org" + +function isServiceError(value: unknown): value is ServiceError { + if (typeof value !== "object" || value === null) return false + const candidate = value as { status?: unknown; error?: unknown } + return typeof candidate.status === "number" && typeof candidate.error === "string" +} + +/** + * GET /api/mobile/marketplace/[id] — full detail for one catalog item + * (mobile Bearer auth). + */ +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsedParams = DashboardMarketplaceIdParamsSchema.safeParse(await params) + if (!parsedParams.success) { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + + const detail = await getDashboardMarketplaceItemDetail({ + organizationId: ctx.organizationId, + itemId: parsedParams.data.id, + }) + if (isServiceError(detail)) { + return NextResponse.json({ error: detail.error }, { status: detail.status }) + } + + // Mobile "Use Template" for assistants opens the agent builder pre-filled + // (unsaved) from the template, so expose the raw assistantTemplate. Web's + // shared detail service intentionally omits it. + if (detail.type === "assistant") { + const catalog = await getCatalogItemById(parsedParams.data.id) + if (catalog?.assistantTemplate) { + detail.assistantTemplate = catalog.assistantTemplate + } + } + + return NextResponse.json(detail) + } catch (error) { + console.error("[Mobile Marketplace API] GET [id] error:", error) + return NextResponse.json( + { error: "Failed to fetch item detail" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/mobile/marketplace/install/route.ts b/src/app/api/mobile/marketplace/install/route.ts new file mode 100644 index 00000000..f00c589a --- /dev/null +++ b/src/app/api/mobile/marketplace/install/route.ts @@ -0,0 +1,94 @@ +import { NextResponse } from "next/server" + +import { + installDashboardMarketplaceItem, + uninstallDashboardMarketplaceItem, + type ServiceError, +} from "@/features/marketplace/service" +import { + DashboardMarketplaceInstallBodySchema, + DashboardMarketplaceUninstallQuerySchema, +} from "@/features/marketplace/schema" +import { getMobileContext } from "@/lib/mobile-org" + +function isServiceError(value: unknown): value is ServiceError { + if (typeof value !== "object" || value === null) return false + const candidate = value as { status?: unknown; error?: unknown } + return typeof candidate.status === "number" && typeof candidate.error === "string" +} + +/** + * POST /api/mobile/marketplace/install — install a catalog item into the + * caller's org (clones it into a real Tool/Skill/Workflow/Mcp/Assistant). + * Body: { catalogItemId, authConfig?, config? }. + */ +export async function POST(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsed = DashboardMarketplaceInstallBodySchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ error: "catalogItemId is required" }, { status: 400 }) + } + + const result = await installDashboardMarketplaceItem({ + organizationId: ctx.organizationId, + userId: ctx.userId, + input: parsed.data, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } + + return NextResponse.json(result, { status: 201 }) + } catch (error) { + console.error("[Mobile Marketplace Install] POST error:", error) + return NextResponse.json({ error: "Failed to install" }, { status: 500 }) + } +} + +/** + * DELETE /api/mobile/marketplace/install?catalogItemId= — uninstall a catalog + * item from the caller's org (removes the cloned resource). + */ +export async function DELETE(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const parsed = DashboardMarketplaceUninstallQuerySchema.safeParse({ + catalogItemId: searchParams.get("catalogItemId"), + }) + if (!parsed.success) { + return NextResponse.json( + { error: "catalogItemId query param required" }, + { status: 400 } + ) + } + + const result = await uninstallDashboardMarketplaceItem({ + organizationId: ctx.organizationId, + input: parsed.data, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + if (!result.success) { + return NextResponse.json({ error: result.error }, { status: 400 }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[Mobile Marketplace Install] DELETE error:", error) + return NextResponse.json({ error: "Failed to uninstall" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/marketplace/route.ts b/src/app/api/mobile/marketplace/route.ts new file mode 100644 index 00000000..95c8d0ea --- /dev/null +++ b/src/app/api/mobile/marketplace/route.ts @@ -0,0 +1,39 @@ +import { NextResponse } from "next/server" + +import { listDashboardMarketplaceItems } from "@/features/marketplace/service" +import { getMobileContext } from "@/lib/mobile-org" + +type CatalogType = "tool" | "skill" | "workflow" | "assistant" | "mcp" + +/** + * GET /api/mobile/marketplace?type=&category=&q= — curated catalog with the + * caller's org install state (mobile Bearer auth; mirrors the dashboard route). + */ +export async function GET(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { searchParams } = new URL(request.url) + const category = searchParams.get("category") || undefined + const type = (searchParams.get("type") as CatalogType) || undefined + const search = searchParams.get("q") || undefined + + const result = await listDashboardMarketplaceItems({ + organizationId: ctx.organizationId, + category, + type, + search, + }) + + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile Marketplace API] GET error:", error) + return NextResponse.json( + { error: "Failed to fetch marketplace" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/mobile/mcp-servers/[id]/discover/route.ts b/src/app/api/mobile/mcp-servers/[id]/discover/route.ts new file mode 100644 index 00000000..d5e6586b --- /dev/null +++ b/src/app/api/mobile/mcp-servers/[id]/discover/route.ts @@ -0,0 +1,51 @@ +import { NextResponse } from "next/server" + +import { + discoverDashboardMcpServerTools, + getDashboardMcpServerForDashboard, +} from "@/features/mcp/servers/service" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +function isServiceError( + value: unknown +): value is { status: number; error: string } { + if (typeof value !== "object" || value === null) return false + const candidate = value as { status?: unknown; error?: unknown } + return typeof candidate.status === "number" && typeof candidate.error === "string" +} + +/** + * POST /api/mobile/mcp-servers/[id]/discover — connect to the server and sync + * its tool list. Ownership is verified first (the discover service is not + * org-scoped on its own). + */ +export async function POST(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const { id } = await params + const server = await getDashboardMcpServerForDashboard({ + id, + organizationId: ctx.organizationId, + }) + if (isServiceError(server)) { + return NextResponse.json({ error: server.error }, { status: server.status }) + } + + const result = await discoverDashboardMcpServerTools(id) + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile MCP API] discover error:", error) + return NextResponse.json( + { error: "Failed to discover tools" }, + { status: 500 } + ) + } +} diff --git a/src/app/api/mobile/mcp-servers/[id]/route.ts b/src/app/api/mobile/mcp-servers/[id]/route.ts new file mode 100644 index 00000000..6bca3baf --- /dev/null +++ b/src/app/api/mobile/mcp-servers/[id]/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from "next/server" + +import { DashboardMcpServerUpdateBodySchema } from "@/features/mcp/servers/schema" +import { + deleteDashboardMcpServerForDashboard, + getDashboardMcpServerForDashboard, + updateDashboardMcpServerForDashboard, +} from "@/features/mcp/servers/service" +import { getMobileContext } from "@/lib/mobile-org" + +interface RouteParams { + params: Promise<{ id: string }> +} + +function isServiceError( + value: unknown +): value is { status: number; error: string } { + if (typeof value !== "object" || value === null) return false + const candidate = value as { status?: unknown; error?: unknown } + return typeof candidate.status === "number" && typeof candidate.error === "string" +} + +/** GET /api/mobile/mcp-servers/[id] — one server (masked; hasEnv/hasHeaders). */ +export async function GET(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params + const result = await getDashboardMcpServerForDashboard({ + id, + organizationId: ctx.organizationId, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile MCP API] GET [id] error:", error) + return NextResponse.json({ error: "Failed to fetch MCP server" }, { status: 500 }) + } +} + +/** PUT /api/mobile/mcp-servers/[id] — update. Omit env/headers to keep them. */ +export async function PUT(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params + const parsed = DashboardMcpServerUpdateBodySchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }) + } + const result = await updateDashboardMcpServerForDashboard({ + id, + organizationId: ctx.organizationId, + input: parsed.data, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json(result) + } catch (error) { + console.error("[Mobile MCP API] PUT error:", error) + return NextResponse.json({ error: "Failed to update MCP server" }, { status: 500 }) + } +} + +/** DELETE /api/mobile/mcp-servers/[id] — delete a server. */ +export async function DELETE(request: Request, { params }: RouteParams) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + const { id } = await params + const result = await deleteDashboardMcpServerForDashboard({ + id, + organizationId: ctx.organizationId, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + return NextResponse.json({ success: true }) + } catch (error) { + console.error("[Mobile MCP API] DELETE error:", error) + return NextResponse.json({ error: "Failed to delete MCP server" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/mcp-servers/route.ts b/src/app/api/mobile/mcp-servers/route.ts new file mode 100644 index 00000000..e85904ae --- /dev/null +++ b/src/app/api/mobile/mcp-servers/route.ts @@ -0,0 +1,65 @@ +import { NextResponse } from "next/server" + +import { DashboardMcpServerCreateBodySchema } from "@/features/mcp/servers/schema" +import { + createDashboardMcpServerForDashboard, + listDashboardMcpServers, +} from "@/features/mcp/servers/service" +import { getMobileContext } from "@/lib/mobile-org" + +function isServiceError( + value: unknown +): value is { status: number; error: string } { + if (typeof value !== "object" || value === null) return false + const candidate = value as { status?: unknown; error?: unknown } + return typeof candidate.status === "number" && typeof candidate.error === "string" +} + +/** + * GET /api/mobile/mcp-servers — MCP servers for the caller's org (masked; + * env/header values are not returned). + */ +export async function GET(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + return NextResponse.json(await listDashboardMcpServers(ctx.organizationId)) + } catch (error) { + console.error("[Mobile MCP API] GET error:", error) + return NextResponse.json({ error: "Failed to fetch MCP servers" }, { status: 500 }) + } +} + +/** + * POST /api/mobile/mcp-servers — create an MCP server. env/headers are + * encrypted server-side. Body: { name, transport, url, description?, env?, + * headers?, docsUrl? }. + */ +export async function POST(request: Request) { + try { + const ctx = await getMobileContext(request) + if (!ctx) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + + const parsed = DashboardMcpServerCreateBodySchema.safeParse(await request.json()) + if (!parsed.success) { + return NextResponse.json({ error: "Invalid request payload" }, { status: 400 }) + } + + const result = await createDashboardMcpServerForDashboard({ + context: { organizationId: ctx.organizationId, userId: ctx.userId }, + input: parsed.data, + }) + if (isServiceError(result)) { + return NextResponse.json({ error: result.error }, { status: result.status }) + } + + return NextResponse.json(result, { status: 201 }) + } catch (error) { + console.error("[Mobile MCP API] POST error:", error) + return NextResponse.json({ error: "Failed to create MCP server" }, { status: 500 }) + } +} diff --git a/src/app/api/mobile/media/assets/[id]/file/route.ts b/src/app/api/mobile/media/assets/[id]/file/route.ts new file mode 100644 index 00000000..65dda030 --- /dev/null +++ b/src/app/api/mobile/media/assets/[id]/file/route.ts @@ -0,0 +1,64 @@ +import { NextResponse } from "next/server" + +import { findAssetById } from "@/features/media/repository" +import { downloadMediaBytes } from "@/features/media/storage" +import { userIdFromMobileToken } from "@/lib/mobile-auth" +import { resolveActiveOrg } from "@/lib/org-context" + +interface RouteParams { + params: Promise<{ id: string }> +} + +/** + * GET /api/mobile/media/assets/[id]/file — stream the asset bytes through the + * app server so the phone never needs to reach the (often internal) S3 host. + * + * Accepts the token via the `Authorization: Bearer` header OR a `?token=` query + * param, because React Native's /