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

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 { 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`

Wyświetl plik

@ -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,

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']
})
.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 (
<section>
{!ctx || isLoading ? (

Wyświetl plik

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

Wyświetl plik

@ -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<Array<Team>> {
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<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. */
@ -341,7 +343,7 @@ export class AgenticApiClient {
>(
searchParams: OperationParameters<'listProjects'> & {
populate?: TPopulate[]
}
} = {}
): Promise<Array<PopulateProject<TPopulate>>> {
return this.ky
.get('v1/projects', { searchParams: sanitizeSearchParams(searchParams) })
@ -437,7 +439,12 @@ export class AgenticApiClient {
consumer: OperationBody<'createConsumer'>,
searchParams: OperationParameters<'createConsumer'> = {}
): 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. */
@ -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<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,
...searchParams
}: OperationParameters<'listConsumers'> & {
}: OperationParameters<'listConsumersForProject'> & {
populate?: TPopulate[]
}): Promise<Array<PopulateConsumer<TPopulate>>> {
return this.ky
@ -527,7 +551,7 @@ export class AgenticApiClient {
>(
searchParams: OperationParameters<'listDeployments'> & {
populate?: TPopulate[]
}
} = {}
): Promise<Array<PopulateDeployment<TPopulate>>> {
return this.ky
.get('v1/deployments', {

Wyświetl plik

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

Wyświetl plik

@ -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";