From 3ff95d6a8c57c81e9826223dadab16fca878eb41 Mon Sep 17 00:00:00 2001 From: Travis Fischer Date: Tue, 17 Jun 2025 08:08:39 +0700 Subject: [PATCH] feat: add listing for customer subscriptions --- .../src/api-v1/consumers/list-consumers.ts | 23 ++-- .../consumers/list-project-consumers.ts | 78 ++++++++++++ apps/api/src/api-v1/index.ts | 6 +- apps/web/src/app/app/app-index.tsx | 2 +- .../app/app/consumers/app-consumers-index.tsx | 114 ++++++++++++++++++ apps/web/src/app/app/consumers/page.tsx | 5 + .../[project-name]/app-project-index.tsx | 7 +- apps/web/src/lib/notifications.ts | 4 +- packages/api-client/src/agentic-api-client.ts | 50 ++++++-- packages/core/src/utils.ts | 2 +- packages/types/src/openapi.d.ts | 47 ++++++-- 11 files changed, 291 insertions(+), 47 deletions(-) create mode 100644 apps/api/src/api-v1/consumers/list-project-consumers.ts create mode 100644 apps/web/src/app/app/consumers/app-consumers-index.tsx create mode 100644 apps/web/src/app/app/consumers/page.tsx diff --git a/apps/api/src/api-v1/consumers/list-consumers.ts b/apps/api/src/api-v1/consumers/list-consumers.ts index e444c334..eb5f8c54 100644 --- a/apps/api/src/api-v1/consumers/list-consumers.ts +++ b/apps/api/src/api-v1/consumers/list-consumers.ts @@ -1,27 +1,25 @@ -import { assert, parseZodSchema } from '@agentic/platform-core' +import { parseZodSchema } from '@agentic/platform-core' import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' import type { AuthenticatedHonoEnv } from '@/lib/types' import { db, eq, schema } from '@/db' -import { acl } from '@/lib/acl' +import { ensureAuthUser } from '@/lib/ensure-auth-user' import { openapiAuthenticatedSecuritySchemas, openapiErrorResponse404, openapiErrorResponses } from '@/lib/openapi-utils' -import { projectIdParamsSchema } from '../projects/schemas' import { paginationAndPopulateConsumerSchema } from './schemas' const route = createRoute({ - description: 'Lists all of the customers for a project.', + description: 'Lists all of the customer subscriptions for the current user.', tags: ['consumers'], operationId: 'listConsumers', method: 'get', - path: 'projects/{projectId}/consumers', + path: 'consumers', security: openapiAuthenticatedSecuritySchemas, request: { - params: projectIdParamsSchema, query: paginationAndPopulateConsumerSchema }, responses: { @@ -38,7 +36,7 @@ const route = createRoute({ } }) -export function registerV1ProjectsListConsumers( +export function registerV1ConsumersListConsumers( app: OpenAPIHono ) { return app.openapi(route, async (c) => { @@ -50,17 +48,10 @@ export function registerV1ProjectsListConsumers( populate = [] } = c.req.valid('query') - const { projectId } = c.req.valid('param') - assert(projectId, 400, 'Project ID is required') - - const project = await db.query.projects.findFirst({ - where: eq(schema.projects.id, projectId) - }) - assert(project, 404, `Project not found "${projectId}"`) - await acl(c, project, { label: 'Project' }) + const user = await ensureAuthUser(c) const consumers = await db.query.consumers.findMany({ - where: eq(schema.consumers.projectId, projectId), + where: eq(schema.consumers.userId, user.id), with: { ...Object.fromEntries(populate.map((field) => [field, true])) }, diff --git a/apps/api/src/api-v1/consumers/list-project-consumers.ts b/apps/api/src/api-v1/consumers/list-project-consumers.ts new file mode 100644 index 00000000..f9a0f854 --- /dev/null +++ b/apps/api/src/api-v1/consumers/list-project-consumers.ts @@ -0,0 +1,78 @@ +import { assert, parseZodSchema } from '@agentic/platform-core' +import { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi' + +import type { AuthenticatedHonoEnv } from '@/lib/types' +import { db, eq, schema } from '@/db' +import { acl } from '@/lib/acl' +import { + openapiAuthenticatedSecuritySchemas, + openapiErrorResponse404, + openapiErrorResponses +} from '@/lib/openapi-utils' + +import { projectIdParamsSchema } from '../projects/schemas' +import { paginationAndPopulateConsumerSchema } from './schemas' + +const route = createRoute({ + description: 'Lists all of the customers for a project.', + tags: ['consumers'], + operationId: 'listConsumersForProject', + method: 'get', + path: 'projects/{projectId}/consumers', + security: openapiAuthenticatedSecuritySchemas, + request: { + params: projectIdParamsSchema, + query: paginationAndPopulateConsumerSchema + }, + responses: { + 200: { + description: 'A list of consumers subscribed to the given project', + content: { + 'application/json': { + schema: z.array(schema.consumerSelectSchema) + } + } + }, + ...openapiErrorResponses, + ...openapiErrorResponse404 + } +}) + +export function registerV1ConsumersListForProject( + app: OpenAPIHono +) { + return app.openapi(route, async (c) => { + const { + offset = 0, + limit = 10, + sort = 'desc', + sortBy = 'createdAt', + populate = [] + } = c.req.valid('query') + + const { projectId } = c.req.valid('param') + assert(projectId, 400, 'Project ID is required') + + const project = await db.query.projects.findFirst({ + where: eq(schema.projects.id, projectId) + }) + assert(project, 404, `Project not found "${projectId}"`) + await acl(c, project, { label: 'Project' }) + + const consumers = await db.query.consumers.findMany({ + where: eq(schema.consumers.projectId, projectId), + with: { + ...Object.fromEntries(populate.map((field) => [field, true])) + }, + orderBy: (consumers, { asc, desc }) => [ + sort === 'desc' ? desc(consumers[sortBy]) : asc(consumers[sortBy]) + ], + offset, + limit + }) + + return c.json( + parseZodSchema(z.array(schema.consumerSelectSchema), consumers) + ) + }) +} diff --git a/apps/api/src/api-v1/index.ts b/apps/api/src/api-v1/index.ts index a9aa2426..fa4aa663 100644 --- a/apps/api/src/api-v1/index.ts +++ b/apps/api/src/api-v1/index.ts @@ -15,7 +15,8 @@ import { registerV1AdminConsumersActivateConsumer } from './consumers/admin-acti import { registerV1AdminConsumersGetConsumerByToken } from './consumers/admin-get-consumer-by-token' import { registerV1ConsumersCreateConsumer } from './consumers/create-consumer' import { registerV1ConsumersGetConsumer } from './consumers/get-consumer' -import { registerV1ProjectsListConsumers } from './consumers/list-consumers' +import { registerV1ConsumersListConsumers } from './consumers/list-consumers' +import { registerV1ConsumersListForProject } from './consumers/list-project-consumers' import { registerV1ConsumersRefreshConsumerToken } from './consumers/refresh-consumer-token' import { registerV1ConsumersUpdateConsumer } from './consumers/update-consumer' import { registerV1AdminDeploymentsGetDeploymentByIdentifier } from './deployments/admin-get-deployment-by-identifier' @@ -112,7 +113,8 @@ registerV1ConsumersGetConsumer(privateRouter) registerV1ConsumersCreateConsumer(privateRouter) registerV1ConsumersUpdateConsumer(privateRouter) registerV1ConsumersRefreshConsumerToken(privateRouter) -registerV1ProjectsListConsumers(privateRouter) +registerV1ConsumersListConsumers(privateRouter) +registerV1ConsumersListForProject(privateRouter) // Deployments registerV1DeploymentsGetDeploymentByIdentifier(privateRouter) // must be before `registerV1DeploymentsGetDeployment` diff --git a/apps/web/src/app/app/app-index.tsx b/apps/web/src/app/app/app-index.tsx index 83bb3834..16ac59eb 100644 --- a/apps/web/src/app/app/app-index.tsx +++ b/apps/web/src/app/app/app-index.tsx @@ -37,7 +37,7 @@ export function AppIndex() { } }) .catch((err: any) => { - void toastError(err, { label: 'Failed to fetch projects' }) + void toastError('Failed to fetch projects') throw err }), getNextPageParam: (lastGroup) => lastGroup?.nextOffset, diff --git a/apps/web/src/app/app/consumers/app-consumers-index.tsx b/apps/web/src/app/app/consumers/app-consumers-index.tsx new file mode 100644 index 00000000..7a83a312 --- /dev/null +++ b/apps/web/src/app/app/consumers/app-consumers-index.tsx @@ -0,0 +1,114 @@ +'use client' + +import { useInfiniteQuery } from '@tanstack/react-query' +import Link from 'next/link' +import useInfiniteScroll from 'react-infinite-scroll-hook' + +import { useAuthenticatedAgentic } from '@/components/agentic-provider' +import { LoadingIndicator } from '@/components/loading-indicator' +import { toastError } from '@/lib/notifications' + +export function AppConsumersIndex() { + const ctx = useAuthenticatedAgentic() + const limit = 10 + const { + data, + isLoading, + isError, + hasNextPage, + fetchNextPage, + isFetchingNextPage + } = useInfiniteQuery({ + queryKey: ['consumers', ctx?.api.authSession?.user?.id], + queryFn: ({ pageParam = 0 }) => + ctx!.api + .listConsumers({ + populate: ['project'], + offset: pageParam, + limit + }) + .then(async (consumers) => { + return { + consumers, + offset: pageParam, + limit, + nextOffset: + consumers.length >= limit + ? pageParam + consumers.length + : undefined + } + }) + .catch((err: any) => { + void toastError('Failed to fetch customer subscriptions') + throw err + }), + getNextPageParam: (lastGroup) => lastGroup?.nextOffset, + enabled: !!ctx, + initialPageParam: 0 + }) + + const [sentryRef] = useInfiniteScroll({ + loading: isLoading || isFetchingNextPage, + hasNextPage, + onLoadMore: fetchNextPage, + disabled: !ctx || isError, + rootMargin: '0px 0px 200px 0px' + }) + + const consumers = data ? data.pages.flatMap((p) => p.consumers) : [] + + return ( + <> +
+

+ Your Subscriptions +

+ + {!ctx || isLoading ? ( + + ) : ( +
+ {isError ? ( +

Error fetching customer subscriptions

+ ) : !consumers.length ? ( +

+ No subscriptions found. Subscribe to your first project to get + started! +

+ ) : ( +
+ {consumers.map((consumer) => ( + +

{consumer.project.name}

+ +

+ {consumer.project.identifier} +

+ +
+                      {JSON.stringify(consumer, null, 2)}
+                    
+ + ))} + + {hasNextPage && ( +
+ {isLoading || (isFetchingNextPage && )} +
+ )} +
+ )} +
+ )} +
+ + ) +} diff --git a/apps/web/src/app/app/consumers/page.tsx b/apps/web/src/app/app/consumers/page.tsx new file mode 100644 index 00000000..08e41cfe --- /dev/null +++ b/apps/web/src/app/app/consumers/page.tsx @@ -0,0 +1,5 @@ +import { AppConsumersIndex } from './app-consumers-index' + +export default function AppConsumersIndexPage() { + return +} diff --git a/apps/web/src/app/app/projects/[namespace]/[project-name]/app-project-index.tsx b/apps/web/src/app/app/projects/[namespace]/[project-name]/app-project-index.tsx index 979f3362..627c0720 100644 --- a/apps/web/src/app/app/projects/[namespace]/[project-name]/app-project-index.tsx +++ b/apps/web/src/app/app/projects/[namespace]/[project-name]/app-project-index.tsx @@ -25,15 +25,14 @@ export function AppProjectIndex({ populate: ['lastPublishedDeployment'] }) .catch((err: any) => { - void toastError(err, { - label: `Error fetching project "${projectIdentifier}"` - }) - + void toastError(`Failed to fetch project "${projectIdentifier}"`) throw err }), enabled: !!ctx }) + // TODO: show deployments + return (
{!ctx || isLoading ? ( diff --git a/apps/web/src/lib/notifications.ts b/apps/web/src/lib/notifications.ts index a0314e95..3b195b7d 100644 --- a/apps/web/src/lib/notifications.ts +++ b/apps/web/src/lib/notifications.ts @@ -7,7 +7,7 @@ export function toast(message: string) { export async function toastError( error: any, - ctx: { + ctx?: { label?: string } ) { @@ -36,6 +36,6 @@ export async function toastError( } } - console.error(ctx.label, message, ...[details].filter(Boolean)) + console.error(...[ctx?.label, message, details].filter(Boolean)) toastImpl.error(message) } diff --git a/packages/api-client/src/agentic-api-client.ts b/packages/api-client/src/agentic-api-client.ts index 77e4cc4b..6b711f8e 100644 --- a/packages/api-client/src/agentic-api-client.ts +++ b/packages/api-client/src/agentic-api-client.ts @@ -15,11 +15,6 @@ import { assert, sanitizeSearchParams } from '@agentic/platform-core' import defaultKy, { type KyInstance } from 'ky' import type { OnUpdateAuthSessionFunction } from './types' -// import { -// type AuthClient, -// type AuthorizeResult, -// createAuthClient -// } from './auth-client' export class AgenticApiClient { static readonly DEFAULT_API_BASE_URL = 'https://api.agentic.so' @@ -260,9 +255,11 @@ export class AgenticApiClient { /** Lists all teams the authenticated user belongs to. */ async listTeams( - searchParams: OperationParameters<'listTeams'> + searchParams: OperationParameters<'listTeams'> = {} ): Promise> { - return this.ky.get('v1/teams', { searchParams }).json() + return this.ky + .get('v1/teams', { searchParams: sanitizeSearchParams(searchParams) }) + .json() } /** Creates a team. */ @@ -270,7 +267,12 @@ export class AgenticApiClient { team: OperationBody<'createTeam'>, searchParams: OperationParameters<'createTeam'> = {} ): Promise { - return this.ky.post('v1/teams', { json: team, searchParams }).json() + return this.ky + .post('v1/teams', { + json: team, + searchParams: sanitizeSearchParams(searchParams) + }) + .json() } /** Gets a team by ID. */ @@ -341,7 +343,7 @@ export class AgenticApiClient { >( searchParams: OperationParameters<'listProjects'> & { populate?: TPopulate[] - } + } = {} ): Promise>> { return this.ky .get('v1/projects', { searchParams: sanitizeSearchParams(searchParams) }) @@ -437,7 +439,12 @@ export class AgenticApiClient { consumer: OperationBody<'createConsumer'>, searchParams: OperationParameters<'createConsumer'> = {} ): Promise { - return this.ky.post('v1/consumers', { json: consumer, searchParams }).json() + return this.ky + .post('v1/consumers', { + json: consumer, + searchParams: sanitizeSearchParams(searchParams) + }) + .json() } /** Refreshes a consumer's API token. */ @@ -450,15 +457,32 @@ export class AgenticApiClient { .json() } - /** Lists all of the customers for a project. */ + /** Lists all of the customers. */ async listConsumers< TPopulate extends NonNullable< OperationParameters<'listConsumers'>['populate'] >[number] + >( + searchParams: OperationParameters<'listConsumers'> & { + populate?: TPopulate[] + } = {} + ): Promise>> { + return this.ky + .get('v1/consumers', { + searchParams: sanitizeSearchParams(searchParams) + }) + .json() + } + + /** Lists all of the customers for a project. */ + async listConsumersForProject< + TPopulate extends NonNullable< + OperationParameters<'listConsumersForProject'>['populate'] + >[number] >({ projectId, ...searchParams - }: OperationParameters<'listConsumers'> & { + }: OperationParameters<'listConsumersForProject'> & { populate?: TPopulate[] }): Promise>> { return this.ky @@ -527,7 +551,7 @@ export class AgenticApiClient { >( searchParams: OperationParameters<'listDeployments'> & { populate?: TPopulate[] - } + } = {} ): Promise>> { return this.ky .get('v1/deployments', { diff --git a/packages/core/src/utils.ts b/packages/core/src/utils.ts index ed33fa33..1ed9e13b 100644 --- a/packages/core/src/utils.ts +++ b/packages/core/src/utils.ts @@ -139,7 +139,7 @@ export function sanitizeSearchParams( string, string | number | boolean | string[] | number[] | boolean[] | undefined > - | object, + | object = {}, { csv = false }: { diff --git a/packages/types/src/openapi.d.ts b/packages/types/src/openapi.d.ts index 92eba9ab..36a7def1 100644 --- a/packages/types/src/openapi.d.ts +++ b/packages/types/src/openapi.d.ts @@ -257,7 +257,8 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** @description Lists all of the customer subscriptions for the current user. */ + get: operations["listConsumers"]; put?: never; /** @description Creates a new consumer by subscribing a customer to a project. */ post: operations["createConsumer"]; @@ -292,7 +293,7 @@ export interface paths { cookie?: never; }; /** @description Lists all of the customers for a project. */ - get: operations["listConsumers"]; + get: operations["listConsumersForProject"]; put?: never; post?: never; delete?: never; @@ -1128,7 +1129,7 @@ export interface operations { listTeams: { parameters: { query?: { - offset?: number; + offset?: number | null; limit?: number; sort?: "asc" | "desc"; sortBy?: "createdAt" | "updatedAt"; @@ -1378,7 +1379,7 @@ export interface operations { listProjects: { parameters: { query?: { - offset?: number; + offset?: number | null; limit?: number; sort?: "asc" | "desc"; sortBy?: "createdAt" | "updatedAt"; @@ -1590,6 +1591,36 @@ export interface operations { 410: components["responses"]["410"]; }; }; + listConsumers: { + parameters: { + query?: { + offset?: number | null; + limit?: number; + sort?: "asc" | "desc"; + sortBy?: "createdAt" | "updatedAt"; + populate?: ("user" | "project" | "deployment") | ("user" | "project" | "deployment")[]; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description A list of consumers */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Consumer"][]; + }; + }; + 400: components["responses"]["400"]; + 401: components["responses"]["401"]; + 403: components["responses"]["403"]; + 404: components["responses"]["404"]; + }; + }; createConsumer: { parameters: { query?: never; @@ -1652,10 +1683,10 @@ export interface operations { 404: components["responses"]["404"]; }; }; - listConsumers: { + listConsumersForProject: { parameters: { query?: { - offset?: number; + offset?: number | null; limit?: number; sort?: "asc" | "desc"; sortBy?: "createdAt" | "updatedAt"; @@ -1670,7 +1701,7 @@ export interface operations { }; requestBody?: never; responses: { - /** @description A list of consumers */ + /** @description A list of consumers subscribed to the given project */ 200: { headers: { [name: string]: unknown; @@ -1779,7 +1810,7 @@ export interface operations { listDeployments: { parameters: { query?: { - offset?: number; + offset?: number | null; limit?: number; sort?: "asc" | "desc"; sortBy?: "createdAt" | "updatedAt";