kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: add listing for customer subscriptions
rodzic
2e84664367
commit
3ff95d6a8c
|
@ -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]))
|
||||
},
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
})
|
||||
}
|
|
@ -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`
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
import { AppConsumersIndex } from './app-consumers-index'
|
||||
|
||||
export default function AppConsumersIndexPage() {
|
||||
return <AppConsumersIndex />
|
||||
}
|
|
@ -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 ? (
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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', {
|
||||
|
|
|
@ -139,7 +139,7 @@ export function sanitizeSearchParams(
|
|||
string,
|
||||
string | number | boolean | string[] | number[] | boolean[] | undefined
|
||||
>
|
||||
| object,
|
||||
| object = {},
|
||||
{
|
||||
csv = false
|
||||
}: {
|
||||
|
|
|
@ -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";
|
||||
|
|
Ładowanie…
Reference in New Issue