feat: add listing for customer subscriptions

pull/715/head
Travis Fischer 2025-06-17 08:08:39 +07:00
rodzic 2e84664367
commit 3ff95d6a8c
11 zmienionych plików z 291 dodań i 47 usunięć

Wyświetl plik

@ -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 { createRoute, type OpenAPIHono, z } from '@hono/zod-openapi'
import type { AuthenticatedHonoEnv } from '@/lib/types' import type { AuthenticatedHonoEnv } from '@/lib/types'
import { db, eq, schema } from '@/db' import { db, eq, schema } from '@/db'
import { acl } from '@/lib/acl' import { ensureAuthUser } from '@/lib/ensure-auth-user'
import { import {
openapiAuthenticatedSecuritySchemas, openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404, openapiErrorResponse404,
openapiErrorResponses openapiErrorResponses
} from '@/lib/openapi-utils' } from '@/lib/openapi-utils'
import { projectIdParamsSchema } from '../projects/schemas'
import { paginationAndPopulateConsumerSchema } from './schemas' import { paginationAndPopulateConsumerSchema } from './schemas'
const route = createRoute({ 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'], tags: ['consumers'],
operationId: 'listConsumers', operationId: 'listConsumers',
method: 'get', method: 'get',
path: 'projects/{projectId}/consumers', path: 'consumers',
security: openapiAuthenticatedSecuritySchemas, security: openapiAuthenticatedSecuritySchemas,
request: { request: {
params: projectIdParamsSchema,
query: paginationAndPopulateConsumerSchema query: paginationAndPopulateConsumerSchema
}, },
responses: { responses: {
@ -38,7 +36,7 @@ const route = createRoute({
} }
}) })
export function registerV1ProjectsListConsumers( export function registerV1ConsumersListConsumers(
app: OpenAPIHono<AuthenticatedHonoEnv> app: OpenAPIHono<AuthenticatedHonoEnv>
) { ) {
return app.openapi(route, async (c) => { return app.openapi(route, async (c) => {
@ -50,17 +48,10 @@ export function registerV1ProjectsListConsumers(
populate = [] populate = []
} = c.req.valid('query') } = c.req.valid('query')
const { projectId } = c.req.valid('param') const user = await ensureAuthUser(c)
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({ const consumers = await db.query.consumers.findMany({
where: eq(schema.consumers.projectId, projectId), where: eq(schema.consumers.userId, user.id),
with: { with: {
...Object.fromEntries(populate.map((field) => [field, true])) ...Object.fromEntries(populate.map((field) => [field, true]))
}, },

Wyświetl plik

@ -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<AuthenticatedHonoEnv>
) {
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)
)
})
}

Wyświetl plik

@ -15,7 +15,8 @@ import { registerV1AdminConsumersActivateConsumer } from './consumers/admin-acti
import { registerV1AdminConsumersGetConsumerByToken } from './consumers/admin-get-consumer-by-token' import { registerV1AdminConsumersGetConsumerByToken } from './consumers/admin-get-consumer-by-token'
import { registerV1ConsumersCreateConsumer } from './consumers/create-consumer' import { registerV1ConsumersCreateConsumer } from './consumers/create-consumer'
import { registerV1ConsumersGetConsumer } from './consumers/get-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 { registerV1ConsumersRefreshConsumerToken } from './consumers/refresh-consumer-token'
import { registerV1ConsumersUpdateConsumer } from './consumers/update-consumer' import { registerV1ConsumersUpdateConsumer } from './consumers/update-consumer'
import { registerV1AdminDeploymentsGetDeploymentByIdentifier } from './deployments/admin-get-deployment-by-identifier' import { registerV1AdminDeploymentsGetDeploymentByIdentifier } from './deployments/admin-get-deployment-by-identifier'
@ -112,7 +113,8 @@ registerV1ConsumersGetConsumer(privateRouter)
registerV1ConsumersCreateConsumer(privateRouter) registerV1ConsumersCreateConsumer(privateRouter)
registerV1ConsumersUpdateConsumer(privateRouter) registerV1ConsumersUpdateConsumer(privateRouter)
registerV1ConsumersRefreshConsumerToken(privateRouter) registerV1ConsumersRefreshConsumerToken(privateRouter)
registerV1ProjectsListConsumers(privateRouter) registerV1ConsumersListConsumers(privateRouter)
registerV1ConsumersListForProject(privateRouter)
// Deployments // Deployments
registerV1DeploymentsGetDeploymentByIdentifier(privateRouter) // must be before `registerV1DeploymentsGetDeployment` registerV1DeploymentsGetDeploymentByIdentifier(privateRouter) // must be before `registerV1DeploymentsGetDeployment`

Wyświetl plik

@ -37,7 +37,7 @@ export function AppIndex() {
} }
}) })
.catch((err: any) => { .catch((err: any) => {
void toastError(err, { label: 'Failed to fetch projects' }) void toastError('Failed to fetch projects')
throw err throw err
}), }),
getNextPageParam: (lastGroup) => lastGroup?.nextOffset, getNextPageParam: (lastGroup) => lastGroup?.nextOffset,

Wyświetl plik

@ -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 (
<>
<section>
<h1
className='text-center text-balance leading-snug md:leading-none
text-4xl font-extrabold tracking-tight
'
>
Your Subscriptions
</h1>
{!ctx || isLoading ? (
<LoadingIndicator />
) : (
<div className='mt-8'>
{isError ? (
<p>Error fetching customer subscriptions</p>
) : !consumers.length ? (
<p>
No subscriptions found. Subscribe to your first project to get
started!
</p>
) : (
<div className='grid gap-4'>
{consumers.map((consumer) => (
<Link
key={consumer.id}
className='p-4 border rounded-lg hover:border-gray-400 transition-colors'
href={`/app/consumers/${consumer.id}`}
>
<h3 className='font-medium'>{consumer.project.name}</h3>
<p className='text-sm text-gray-500'>
{consumer.project.identifier}
</p>
<pre className='max-w-lg'>
{JSON.stringify(consumer, null, 2)}
</pre>
</Link>
))}
{hasNextPage && (
<div ref={sentryRef} className=''>
{isLoading || (isFetchingNextPage && <LoadingIndicator />)}
</div>
)}
</div>
)}
</div>
)}
</section>
</>
)
}

Wyświetl plik

@ -0,0 +1,5 @@
import { AppConsumersIndex } from './app-consumers-index'
export default function AppConsumersIndexPage() {
return <AppConsumersIndex />
}

Wyświetl plik

@ -25,15 +25,14 @@ export function AppProjectIndex({
populate: ['lastPublishedDeployment'] populate: ['lastPublishedDeployment']
}) })
.catch((err: any) => { .catch((err: any) => {
void toastError(err, { void toastError(`Failed to fetch project "${projectIdentifier}"`)
label: `Error fetching project "${projectIdentifier}"`
})
throw err throw err
}), }),
enabled: !!ctx enabled: !!ctx
}) })
// TODO: show deployments
return ( return (
<section> <section>
{!ctx || isLoading ? ( {!ctx || isLoading ? (

Wyświetl plik

@ -7,7 +7,7 @@ export function toast(message: string) {
export async function toastError( export async function toastError(
error: any, error: any,
ctx: { ctx?: {
label?: string 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) toastImpl.error(message)
} }

Wyświetl plik

@ -15,11 +15,6 @@ import { assert, sanitizeSearchParams } from '@agentic/platform-core'
import defaultKy, { type KyInstance } from 'ky' import defaultKy, { type KyInstance } from 'ky'
import type { OnUpdateAuthSessionFunction } from './types' import type { OnUpdateAuthSessionFunction } from './types'
// import {
// type AuthClient,
// type AuthorizeResult,
// createAuthClient
// } from './auth-client'
export class AgenticApiClient { export class AgenticApiClient {
static readonly DEFAULT_API_BASE_URL = 'https://api.agentic.so' 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. */ /** Lists all teams the authenticated user belongs to. */
async listTeams( async listTeams(
searchParams: OperationParameters<'listTeams'> searchParams: OperationParameters<'listTeams'> = {}
): Promise<Array<Team>> { ): Promise<Array<Team>> {
return this.ky.get('v1/teams', { searchParams }).json() return this.ky
.get('v1/teams', { searchParams: sanitizeSearchParams(searchParams) })
.json()
} }
/** Creates a team. */ /** Creates a team. */
@ -270,7 +267,12 @@ export class AgenticApiClient {
team: OperationBody<'createTeam'>, team: OperationBody<'createTeam'>,
searchParams: OperationParameters<'createTeam'> = {} searchParams: OperationParameters<'createTeam'> = {}
): Promise<Team> { ): Promise<Team> {
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. */ /** Gets a team by ID. */
@ -341,7 +343,7 @@ export class AgenticApiClient {
>( >(
searchParams: OperationParameters<'listProjects'> & { searchParams: OperationParameters<'listProjects'> & {
populate?: TPopulate[] populate?: TPopulate[]
} } = {}
): Promise<Array<PopulateProject<TPopulate>>> { ): Promise<Array<PopulateProject<TPopulate>>> {
return this.ky return this.ky
.get('v1/projects', { searchParams: sanitizeSearchParams(searchParams) }) .get('v1/projects', { searchParams: sanitizeSearchParams(searchParams) })
@ -437,7 +439,12 @@ export class AgenticApiClient {
consumer: OperationBody<'createConsumer'>, consumer: OperationBody<'createConsumer'>,
searchParams: OperationParameters<'createConsumer'> = {} searchParams: OperationParameters<'createConsumer'> = {}
): Promise<Consumer> { ): Promise<Consumer> {
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. */ /** Refreshes a consumer's API token. */
@ -450,15 +457,32 @@ export class AgenticApiClient {
.json() .json()
} }
/** Lists all of the customers for a project. */ /** Lists all of the customers. */
async listConsumers< async listConsumers<
TPopulate extends NonNullable< TPopulate extends NonNullable<
OperationParameters<'listConsumers'>['populate'] OperationParameters<'listConsumers'>['populate']
>[number] >[number]
>(
searchParams: OperationParameters<'listConsumers'> & {
populate?: TPopulate[]
} = {}
): Promise<Array<PopulateConsumer<TPopulate>>> {
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, projectId,
...searchParams ...searchParams
}: OperationParameters<'listConsumers'> & { }: OperationParameters<'listConsumersForProject'> & {
populate?: TPopulate[] populate?: TPopulate[]
}): Promise<Array<PopulateConsumer<TPopulate>>> { }): Promise<Array<PopulateConsumer<TPopulate>>> {
return this.ky return this.ky
@ -527,7 +551,7 @@ export class AgenticApiClient {
>( >(
searchParams: OperationParameters<'listDeployments'> & { searchParams: OperationParameters<'listDeployments'> & {
populate?: TPopulate[] populate?: TPopulate[]
} } = {}
): Promise<Array<PopulateDeployment<TPopulate>>> { ): Promise<Array<PopulateDeployment<TPopulate>>> {
return this.ky return this.ky
.get('v1/deployments', { .get('v1/deployments', {

Wyświetl plik

@ -139,7 +139,7 @@ export function sanitizeSearchParams(
string, string,
string | number | boolean | string[] | number[] | boolean[] | undefined string | number | boolean | string[] | number[] | boolean[] | undefined
> >
| object, | object = {},
{ {
csv = false csv = false
}: { }: {

Wyświetl plik

@ -257,7 +257,8 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
get?: never; /** @description Lists all of the customer subscriptions for the current user. */
get: operations["listConsumers"];
put?: never; put?: never;
/** @description Creates a new consumer by subscribing a customer to a project. */ /** @description Creates a new consumer by subscribing a customer to a project. */
post: operations["createConsumer"]; post: operations["createConsumer"];
@ -292,7 +293,7 @@ export interface paths {
cookie?: never; cookie?: never;
}; };
/** @description Lists all of the customers for a project. */ /** @description Lists all of the customers for a project. */
get: operations["listConsumers"]; get: operations["listConsumersForProject"];
put?: never; put?: never;
post?: never; post?: never;
delete?: never; delete?: never;
@ -1128,7 +1129,7 @@ export interface operations {
listTeams: { listTeams: {
parameters: { parameters: {
query?: { query?: {
offset?: number; offset?: number | null;
limit?: number; limit?: number;
sort?: "asc" | "desc"; sort?: "asc" | "desc";
sortBy?: "createdAt" | "updatedAt"; sortBy?: "createdAt" | "updatedAt";
@ -1378,7 +1379,7 @@ export interface operations {
listProjects: { listProjects: {
parameters: { parameters: {
query?: { query?: {
offset?: number; offset?: number | null;
limit?: number; limit?: number;
sort?: "asc" | "desc"; sort?: "asc" | "desc";
sortBy?: "createdAt" | "updatedAt"; sortBy?: "createdAt" | "updatedAt";
@ -1590,6 +1591,36 @@ export interface operations {
410: components["responses"]["410"]; 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: { createConsumer: {
parameters: { parameters: {
query?: never; query?: never;
@ -1652,10 +1683,10 @@ export interface operations {
404: components["responses"]["404"]; 404: components["responses"]["404"];
}; };
}; };
listConsumers: { listConsumersForProject: {
parameters: { parameters: {
query?: { query?: {
offset?: number; offset?: number | null;
limit?: number; limit?: number;
sort?: "asc" | "desc"; sort?: "asc" | "desc";
sortBy?: "createdAt" | "updatedAt"; sortBy?: "createdAt" | "updatedAt";
@ -1670,7 +1701,7 @@ export interface operations {
}; };
requestBody?: never; requestBody?: never;
responses: { responses: {
/** @description A list of consumers */ /** @description A list of consumers subscribed to the given project */
200: { 200: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@ -1779,7 +1810,7 @@ export interface operations {
listDeployments: { listDeployments: {
parameters: { parameters: {
query?: { query?: {
offset?: number; offset?: number | null;
limit?: number; limit?: number;
sort?: "asc" | "desc"; sort?: "asc" | "desc";
sortBy?: "createdAt" | "updatedAt"; sortBy?: "createdAt" | "updatedAt";