kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: show active subscription on marketplace project page
rodzic
df601af335
commit
a085cbb2a4
|
@ -0,0 +1,72 @@
|
|||
import { assert, parseZodSchema } from '@agentic/platform-core'
|
||||
import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
|
||||
|
||||
import type { AuthenticatedHonoEnv } from '@/lib/types'
|
||||
import { and, db, eq, schema } from '@/db'
|
||||
import { acl } from '@/lib/acl'
|
||||
import { aclPublicProject } from '@/lib/acl-public-project'
|
||||
import {
|
||||
openapiAuthenticatedSecuritySchemas,
|
||||
openapiErrorResponse404,
|
||||
openapiErrorResponses
|
||||
} from '@/lib/openapi-utils'
|
||||
|
||||
import { projectIdentifierAndPopulateConsumerSchema } from './schemas'
|
||||
|
||||
const route = createRoute({
|
||||
description:
|
||||
'Gets a consumer for the authenticated user and the given project identifier.',
|
||||
tags: ['consumers'],
|
||||
operationId: 'getConsumerByProjectIdentifier',
|
||||
method: 'get',
|
||||
path: 'consumers/by-project-identifier',
|
||||
security: openapiAuthenticatedSecuritySchemas,
|
||||
request: {
|
||||
query: projectIdentifierAndPopulateConsumerSchema
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: 'A consumer object',
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: schema.consumerSelectSchema
|
||||
}
|
||||
}
|
||||
},
|
||||
...openapiErrorResponses,
|
||||
...openapiErrorResponse404
|
||||
}
|
||||
})
|
||||
|
||||
export function registerV1GetConsumerByProjectIdentifier(
|
||||
app: OpenAPIHono<AuthenticatedHonoEnv>
|
||||
) {
|
||||
return app.openapi(route, async (c) => {
|
||||
const { projectIdentifier, populate = [] } = c.req.valid('query')
|
||||
const userId = c.get('userId')
|
||||
|
||||
const project = await db.query.projects.findFirst({
|
||||
where: eq(schema.projects.identifier, projectIdentifier)
|
||||
})
|
||||
assert(project, 404, `Project not found "${projectIdentifier}"`)
|
||||
await aclPublicProject(project)
|
||||
|
||||
const consumer = await db.query.consumers.findFirst({
|
||||
where: and(
|
||||
eq(schema.consumers.userId, userId),
|
||||
eq(schema.consumers.projectId, project.id)
|
||||
),
|
||||
with: {
|
||||
...Object.fromEntries(populate.map((field) => [field, true]))
|
||||
}
|
||||
})
|
||||
assert(
|
||||
consumer,
|
||||
404,
|
||||
`Consumer not found for user "${userId}" and project "${projectIdentifier}"`
|
||||
)
|
||||
await acl(c, consumer, { label: 'Consumer' })
|
||||
|
||||
return c.json(parseZodSchema(schema.consumerSelectSchema, consumer))
|
||||
})
|
||||
}
|
|
@ -3,7 +3,8 @@ import { z } from '@hono/zod-openapi'
|
|||
import {
|
||||
consumerIdSchema,
|
||||
consumerRelationsSchema,
|
||||
paginationSchema
|
||||
paginationSchema,
|
||||
projectIdentifierSchema
|
||||
} from '@/db'
|
||||
|
||||
export const consumerIdParamsSchema = z.object({
|
||||
|
@ -29,6 +30,10 @@ export const consumerTokenParamsSchema = z.object({
|
|||
})
|
||||
})
|
||||
|
||||
export const projectIdentifierQuerySchema = z.object({
|
||||
projectIdentifier: projectIdentifierSchema
|
||||
})
|
||||
|
||||
export const populateConsumerSchema = z.object({
|
||||
populate: z
|
||||
.union([consumerRelationsSchema, z.array(consumerRelationsSchema)])
|
||||
|
@ -41,3 +46,8 @@ export const paginationAndPopulateConsumerSchema = z.object({
|
|||
...paginationSchema.shape,
|
||||
...populateConsumerSchema.shape
|
||||
})
|
||||
|
||||
export const projectIdentifierAndPopulateConsumerSchema = z.object({
|
||||
...projectIdentifierQuerySchema.shape,
|
||||
...populateConsumerSchema.shape
|
||||
})
|
||||
|
|
|
@ -16,6 +16,7 @@ import { registerV1AdminGetConsumerByToken } from './consumers/admin-get-consume
|
|||
import { registerV1CreateConsumer } from './consumers/create-consumer'
|
||||
import { registerV1CreateConsumerCheckoutSession } from './consumers/create-consumer-checkout-session'
|
||||
import { registerV1GetConsumer } from './consumers/get-consumer'
|
||||
import { registerV1GetConsumerByProjectIdentifier } from './consumers/get-consumer-by-project-identifier'
|
||||
import { registerV1ListConsumers } from './consumers/list-consumers'
|
||||
import { registerV1ListConsumersForProject } from './consumers/list-project-consumers'
|
||||
import { registerV1RefreshConsumerToken } from './consumers/refresh-consumer-token'
|
||||
|
@ -118,6 +119,7 @@ registerV1GetProject(privateRouter)
|
|||
registerV1UpdateProject(privateRouter)
|
||||
|
||||
// Consumers
|
||||
registerV1GetConsumerByProjectIdentifier(privateRouter) // msut be before `registerV1GetConsumer`
|
||||
registerV1GetConsumer(privateRouter)
|
||||
registerV1CreateConsumer(privateRouter)
|
||||
registerV1CreateConsumerCheckoutSession(privateRouter)
|
||||
|
|
|
@ -20,6 +20,7 @@ export function MarketplaceProjectIndex({
|
|||
const checkout = searchParams.get('checkout')
|
||||
const plan = searchParams.get('plan')
|
||||
|
||||
// Load the public project.
|
||||
const {
|
||||
data: project,
|
||||
isLoading,
|
||||
|
@ -34,6 +35,26 @@ export function MarketplaceProjectIndex({
|
|||
enabled: !!ctx
|
||||
})
|
||||
|
||||
// If the user is authenticated, check if they have an active subscription to
|
||||
// this project.
|
||||
const {
|
||||
data: consumer
|
||||
// isLoading: isConsumerLoading,
|
||||
// isError: isConsumerError
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
'project',
|
||||
projectIdentifier,
|
||||
'user',
|
||||
ctx?.api.authSession?.user.id
|
||||
],
|
||||
queryFn: () =>
|
||||
ctx!.api.getConsumerByProjectIdentifier({
|
||||
projectIdentifier
|
||||
}),
|
||||
enabled: !!ctx?.isAuthenticated
|
||||
})
|
||||
|
||||
const onSubscribe = useCallback(
|
||||
async (pricingPlanSlug: string) => {
|
||||
assert(ctx, 500, 'Agentic context is required')
|
||||
|
@ -61,7 +82,7 @@ export function MarketplaceProjectIndex({
|
|||
plan: pricingPlanSlug
|
||||
})
|
||||
|
||||
console.log('checkoutSession', res)
|
||||
console.log('checkout', res)
|
||||
checkoutSession = res.checkoutSession
|
||||
} catch (err) {
|
||||
return toastError(err, { label: 'Error creating checkout session' })
|
||||
|
@ -146,17 +167,15 @@ export function MarketplaceProjectIndex({
|
|||
<pre className='max-w-lg'>{JSON.stringify(plan, null, 2)}</pre>
|
||||
|
||||
<Button
|
||||
onClick={() =>
|
||||
onSubscribe(
|
||||
project.lastPublishedDeployment?.pricingPlans[0]?.slug ??
|
||||
'free'
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
!project.lastPublishedDeployment?.pricingPlans[0]?.slug
|
||||
}
|
||||
onClick={() => onSubscribe(plan.slug)}
|
||||
// TODO: handle free plans correctly
|
||||
disabled={consumer?.plan === plan.slug}
|
||||
>
|
||||
Subscribe to "{plan.name}"
|
||||
{consumer?.plan === plan.slug ? (
|
||||
<span>Currently subscribed to "{plan.name}"</span>
|
||||
) : (
|
||||
<span>Subscribe to "{plan.name}"</span>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
@ -25,9 +25,6 @@ export function useConfettiFireworks() {
|
|||
...options
|
||||
}
|
||||
|
||||
const randomInRange = (min: number, max: number) =>
|
||||
Math.random() * (max - min) + min
|
||||
|
||||
const interval = globalThis.window.setInterval(() => {
|
||||
const timeLeft = animationEnd - Date.now()
|
||||
|
||||
|
@ -58,3 +55,7 @@ export function useConfettiFireworks() {
|
|||
fireConfetti
|
||||
}
|
||||
}
|
||||
|
||||
function randomInRange(min: number, max: number) {
|
||||
return Math.random() * (max - min) + min
|
||||
}
|
||||
|
|
|
@ -491,6 +491,23 @@ export class AgenticApiClient {
|
|||
.json()
|
||||
}
|
||||
|
||||
/** Gets a consumer by ID. */
|
||||
async getConsumerByProjectIdentifier<
|
||||
TPopulate extends NonNullable<
|
||||
OperationParameters<'getConsumerByProjectIdentifier'>['populate']
|
||||
>[number]
|
||||
>(
|
||||
searchParams: OperationParameters<'getConsumerByProjectIdentifier'> & {
|
||||
populate?: TPopulate[]
|
||||
}
|
||||
): Promise<PopulateConsumer<TPopulate>> {
|
||||
return this.ky
|
||||
.get(`v1/consumers/by-project-identifier`, {
|
||||
searchParams: sanitizeSearchParams(searchParams)
|
||||
})
|
||||
.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a consumer's subscription to a different deployment or pricing
|
||||
* plan. Set `plan` to undefined to cancel the subscription.
|
||||
|
|
|
@ -283,6 +283,23 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/consumers/by-project-identifier": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** @description Gets a consumer for the authenticated user and the given project identifier. */
|
||||
get: operations["getConsumerByProjectIdentifier"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/v1/consumers/{consumerId}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
@ -1717,6 +1734,34 @@ export interface operations {
|
|||
404: components["responses"]["404"];
|
||||
};
|
||||
};
|
||||
getConsumerByProjectIdentifier: {
|
||||
parameters: {
|
||||
query: {
|
||||
/** @description Public project identifier (e.g. "@namespace/project-name") */
|
||||
projectIdentifier: components["schemas"]["ProjectIdentifier"];
|
||||
populate?: ("user" | "project" | "deployment") | ("user" | "project" | "deployment")[];
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description A consumer object */
|
||||
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"];
|
||||
};
|
||||
};
|
||||
getConsumer: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
|
@ -15,11 +15,9 @@
|
|||
- **website**
|
||||
- marketing landing page
|
||||
- webapp
|
||||
- consider a PrettyJson component which displays json but links to resources
|
||||
- stripe
|
||||
- if user is subscribed to a plan, show that plan as selected
|
||||
- handle unauthenticated checkout flow => auth and then redirect to create a checkout session
|
||||
- will need a `redirect` url for `/login` and `/signup`
|
||||
- `/marketplace/projects/@{projectIdentifier}?checkout=true&plan={plan}`
|
||||
- stripe checkout
|
||||
- stripe billing portal
|
||||
- **API gateway**
|
||||
|
|
Ładowanie…
Reference in New Issue