feat: replace enabled with deletedAt for soft deletes

pull/715/head
Travis Fischer 2025-05-20 14:47:03 +07:00
rodzic 1303926c9d
commit 2484e7efdb
19 zmienionych plików z 89 dodań i 44 usunięć

Wyświetl plik

@ -3,7 +3,7 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import type { AuthenticatedEnv } from '@/lib/types'
import { schema } from '@/db'
import { upsertConsumer } from '@/lib/billing/upsert-consumer'
import { upsertConsumer } from '@/lib/consumers/upsert-consumer'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,

Wyświetl plik

@ -3,7 +3,7 @@ import { createRoute, type OpenAPIHono } from '@hono/zod-openapi'
import type { AuthenticatedEnv } from '@/lib/types'
import { schema } from '@/db'
import { upsertConsumer } from '@/lib/billing/upsert-consumer'
import { upsertConsumer } from '@/lib/consumers/upsert-consumer'
import {
openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404,

Wyświetl plik

@ -3,6 +3,7 @@ import type Stripe from 'stripe'
import { assert, HttpError } from '@agentic/platform-core'
import { and, db, eq, schema } from '@/db'
import { setConsumerStripeSubscriptionStatus } from '@/lib/consumers/utils'
import { env, isStripeLive } from '@/lib/env'
import { stripe } from '@/lib/stripe'
@ -10,13 +11,6 @@ const relevantStripeEvents = new Set<Stripe.Event.Type>([
'customer.subscription.updated'
])
const stripeValidSubscriptionStatuses = new Set([
'active',
'trialing',
'incomplete',
'past_due'
])
export function registerV1StripeWebhook(app: OpenAPIHono) {
return app.post('/webhooks/stripe', async (ctx) => {
const body = await ctx.req.text()
@ -81,15 +75,13 @@ export function registerV1StripeWebhook(app: OpenAPIHono) {
if (consumer.stripeStatus !== subscription.status) {
consumer.stripeStatus = subscription.status
consumer.enabled =
consumer.plan === 'free' ||
stripeValidSubscriptionStatuses.has(consumer.stripeStatus)
setConsumerStripeSubscriptionStatus(consumer)
await db
.update(schema.consumers)
.set({
stripeStatus: consumer.stripeStatus,
enabled: consumer.enabled
isStripeSubscriptionActive: consumer.isStripeSubscriptionActive
})
.where(eq(schema.consumers.id, consumer.id))

Wyświetl plik

@ -1,6 +1,6 @@
// The only place we allow `@agentic/platform-db` imports is in this directory.
import {
drizzle,
postgres,
@ -16,5 +16,4 @@ const postgresClient =
export const db = drizzle({ client: postgresClient, schema })
export * from '@agentic/platform-db'

Wyświetl plik

@ -1,4 +1,4 @@
// The only place we allow `@agentic/platform-db` imports is in this directory.
export * from '@agentic/platform-db'

Wyświetl plik

@ -3,11 +3,11 @@ import { assert } from '@agentic/platform-core'
import type { AuthenticatedContext } from '@/lib/types'
import {
type ConsumerUpdate,
db,
eq,
getStripePriceIdForPricingPlanLineItem,
type RawConsumer,
type RawConsumerUpdate,
type RawDeployment,
type RawProject,
type RawUser,
@ -15,6 +15,8 @@ import {
} from '@/db'
import { stripe } from '@/lib/stripe'
import { setConsumerStripeSubscriptionStatus } from '../consumers/utils'
export async function upsertStripeSubscription(
ctx: AuthenticatedContext,
{
@ -292,8 +294,9 @@ export async function upsertStripeSubscription(
assert(subscription, 500, 'Missing stripe subscription')
logger.debug('subscription', subscription)
const consumerUpdate: ConsumerUpdate = consumer
const consumerUpdate: RawConsumerUpdate = consumer
consumerUpdate.stripeStatus = subscription.status
setConsumerStripeSubscriptionStatus(consumerUpdate)
// if (!plan) {
// TODO: we cancel at the end of the billing interval, so we shouldn't

Wyświetl plik

@ -4,13 +4,12 @@ import { parseFaasIdentifier } from '@agentic/platform-validators'
import type { AuthenticatedContext } from '@/lib/types'
import { and, db, eq, schema } from '@/db'
import { acl } from '@/lib/acl'
import { upsertStripeConnectCustomer } from '@/lib/billing/upsert-stripe-connect-customer'
import { upsertStripeCustomer } from '@/lib/billing/upsert-stripe-customer'
import { upsertStripePricing } from '@/lib/billing/upsert-stripe-pricing'
import { upsertStripeSubscription } from '@/lib/billing/upsert-stripe-subscription'
import { createConsumerToken } from '@/lib/create-consumer-token'
import { upsertStripeConnectCustomer } from './upsert-stripe-connect-customer'
import { upsertStripeCustomer } from './upsert-stripe-customer'
import { upsertStripePricing } from './upsert-stripe-pricing'
import { upsertStripeSubscription } from './upsert-stripe-subscription'
export async function upsertConsumer(
c: AuthenticatedContext,
{
@ -80,7 +79,7 @@ export async function upsertConsumer(
assert(
!existingConsumer ||
!existingConsumer.enabled ||
!existingConsumer.isStripeSubscriptionActive ||
existingConsumer.plan !== plan ||
existingConsumer.deploymentId !== deploymentId,
409,
@ -102,9 +101,9 @@ export async function upsertConsumer(
`Project not found "${projectId}" for deployment "${deploymentId}"`
)
assert(
deployment.enabled,
!deployment.deletedAt,
410,
`Deployment has been disabled by its owner "${deployment.id}"`
`Deployment has been deleted by its owner "${deployment.id}"`
)
if (plan) {

Wyświetl plik

@ -0,0 +1,20 @@
import type { RawConsumerUpdate } from '@/db'
const stripeValidSubscriptionStatuses = new Set([
'active',
'trialing',
'incomplete',
'past_due'
])
export function setConsumerStripeSubscriptionStatus(
consumer: Pick<
RawConsumerUpdate,
'plan' | 'stripeStatus' | 'isStripeSubscriptionActive'
>
) {
consumer.isStripeSubscriptionActive =
consumer.plan === 'free' ||
(!!consumer.stripeStatus &&
stripeValidSubscriptionStatuses.has(consumer.stripeStatus))
}

Wyświetl plik

@ -87,7 +87,8 @@ export const timestamps = {
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp()
.notNull()
.default(sql`now()`)
.default(sql`now()`),
deletedAt: timestamp()
}
export const userRoleEnum = pgEnum('UserRole', ['user', 'admin'])

Wyświetl plik

@ -59,9 +59,6 @@ export const consumers = pgTable(
// initializing their subscription.
activated: boolean().default(false).notNull(),
// Whether the consumer's subscription is currently active
enabled: boolean().default(true).notNull(),
// TODO: Re-add coupon support
// coupon: text(),
@ -90,6 +87,10 @@ export const consumers = pgTable(
// Stripe subscription status (synced via webhooks)
stripeStatus: text(),
// Whether the consumer's subscription is currently active, depending on
// `stripeStatus`.
isStripeSubscriptionActive: boolean().default(true).notNull(),
// Main Stripe Subscription id
_stripeSubscriptionId: stripeId(),
@ -108,8 +109,12 @@ export const consumers = pgTable(
index('consumer_userId_idx').on(table.userId),
index('consumer_projectId_idx').on(table.projectId),
index('consumer_deploymentId_idx').on(table.deploymentId),
index('consumer_isStripeSubscriptionActive_idx').on(
table.isStripeSubscriptionActive
),
index('consumer_createdAt_idx').on(table.createdAt),
index('consumer_updatedAt_idx').on(table.updatedAt)
index('consumer_updatedAt_idx').on(table.updatedAt),
index('consumer_deletedAt_idx').on(table.deletedAt)
]
)

Wyświetl plik

@ -38,7 +38,6 @@ export const deployments = pgTable(
hash: text().notNull(),
version: text(),
enabled: boolean().default(true).notNull(),
published: boolean().default(false).notNull(),
description: text().default('').notNull(),
@ -82,11 +81,11 @@ export const deployments = pgTable(
index('deployment_userId_idx').on(table.userId),
index('deployment_teamId_idx').on(table.teamId),
index('deployment_projectId_idx').on(table.projectId),
index('deployment_enabled_idx').on(table.enabled),
index('deployment_published_idx').on(table.published),
index('deployment_version_idx').on(table.version),
index('deployment_createdAt_idx').on(table.createdAt),
index('deployment_updatedAt_idx').on(table.updatedAt)
index('deployment_updatedAt_idx').on(table.updatedAt),
index('deployment_deletedAt_idx').on(table.deletedAt)
]
)
@ -190,7 +189,7 @@ NOTE: Agentic currently only supports \`external\` API servers. If you'd like to
export const deploymentUpdateSchema = createUpdateSchema(deployments)
.pick({
enabled: true,
deletedAt: true,
description: true
})
.strict()

Wyświetl plik

@ -58,7 +58,8 @@ export const logEntries = pgTable(
index('log_entry_deploymentId_idx').on(table.deploymentId),
index('log_entry_consumerId_idx').on(table.consumerId),
index('log_entry_createdAt_idx').on(table.createdAt),
index('log_entry_updatedAt_idx').on(table.updatedAt)
index('log_entry_updatedAt_idx').on(table.updatedAt),
index('log_entry_deletedAt_idx').on(table.deletedAt)
]
)

Wyświetl plik

@ -126,7 +126,8 @@ export const projects = pgTable(
index('project_teamId_idx').on(table.teamId),
index('project_alias_idx').on(table.alias),
index('project_createdAt_idx').on(table.createdAt),
index('project_updatedAt_idx').on(table.updatedAt)
index('project_updatedAt_idx').on(table.updatedAt),
index('project_deletedAt_idx').on(table.deletedAt)
]
)

Wyświetl plik

@ -44,7 +44,8 @@ export const teamMembers = pgTable(
index('team_member_team_idx').on(table.teamId),
index('team_member_slug_idx').on(table.teamSlug),
index('team_member_createdAt_idx').on(table.createdAt),
index('team_member_updatedAt_idx').on(table.updatedAt)
index('team_member_updatedAt_idx').on(table.updatedAt),
index('team_member_deletedAt_idx').on(table.deletedAt)
]
)

Wyświetl plik

@ -33,7 +33,8 @@ export const teams = pgTable(
(table) => [
uniqueIndex('team_slug_idx').on(table.slug),
index('team_createdAt_idx').on(table.createdAt),
index('team_updatedAt_idx').on(table.updatedAt)
index('team_updatedAt_idx').on(table.updatedAt),
index('team_deletedAt_idx').on(table.deletedAt)
]
)

Wyświetl plik

@ -60,7 +60,8 @@ export const users = pgTable(
uniqueIndex('user_emailConfirmToken_idx').on(table.emailConfirmToken),
uniqueIndex('user_passwordResetToken_idx').on(table.passwordResetToken),
index('user_createdAt_idx').on(table.createdAt),
index('user_updatedAt_idx').on(table.updatedAt)
index('user_updatedAt_idx').on(table.updatedAt),
index('user_deletedAt_idx').on(table.deletedAt)
]
)

Wyświetl plik

@ -1,9 +1,18 @@
import { expectTypeOf, test } from 'vitest'
import type { LogEntry, RawLogEntry, RawUser, User } from './types'
import type {
Consumer,
LogEntry,
RawConsumer,
RawConsumerUpdate,
RawLogEntry,
RawUser,
User
} from './types'
type UserKeys = Exclude<keyof User & keyof RawUser, 'authProviders'>
type LogEntryKeys = keyof RawLogEntry & keyof LogEntry
type ConsumerKeys = keyof RawConsumer & keyof Consumer
test('User types are compatible', () => {
expectTypeOf<RawUser>().toExtend<User>()
@ -18,3 +27,17 @@ test('LogEntry types are compatible', () => {
RawLogEntry[LogEntryKeys]
>()
})
test('Consumer types are compatible', () => {
expectTypeOf<RawConsumer>().toExtend<Consumer>()
expectTypeOf<Consumer[ConsumerKeys]>().toEqualTypeOf<
RawConsumer[ConsumerKeys]
>()
// Ensure that we can pass any Consumer as a RawConsumerUpdate
expectTypeOf<Consumer>().toExtend<RawConsumerUpdate>()
// Ensure that we can pass any RawConsumer as a RawConsumerUpdate
expectTypeOf<RawConsumer>().toExtend<RawConsumerUpdate>()
})

Wyświetl plik

@ -68,7 +68,7 @@ export type RawConsumer = Simplify<
deployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes)
}
>
export type ConsumerUpdate = Partial<
export type RawConsumerUpdate = Partial<
Omit<
InferInsertModel<typeof schema.consumers>,
'id' | 'projectId' | 'userId' | 'deploymentId'

Wyświetl plik

@ -10,6 +10,7 @@
- **replace Project.id and Deployment.id with cuids**
- move others to `alias` or `publicIdentifier`?
- won't work with hono routing? test this
- add prefixes to model ids
---
@ -27,8 +28,6 @@
- https://github.com/NangoHQ/nango
- https://github.com/transitive-bullshit?submit=Search&q=oauth&tab=stars&type=&sort=&direction=&submit=Search
- clerk / workos / auth0
- db
- replace `enabled` with soft `deletedAt` timestamp
- consider switching to [consola](https://github.com/unjs/consola) for logging?
## License