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 type { AuthenticatedEnv } from '@/lib/types'
import { schema } from '@/db' import { schema } from '@/db'
import { upsertConsumer } from '@/lib/billing/upsert-consumer' import { upsertConsumer } from '@/lib/consumers/upsert-consumer'
import { import {
openapiAuthenticatedSecuritySchemas, openapiAuthenticatedSecuritySchemas,
openapiErrorResponse404, openapiErrorResponse404,

Wyświetl plik

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

Wyświetl plik

@ -3,6 +3,7 @@ import type Stripe from 'stripe'
import { assert, HttpError } from '@agentic/platform-core' import { assert, HttpError } from '@agentic/platform-core'
import { and, db, eq, schema } from '@/db' import { and, db, eq, schema } from '@/db'
import { setConsumerStripeSubscriptionStatus } from '@/lib/consumers/utils'
import { env, isStripeLive } from '@/lib/env' import { env, isStripeLive } from '@/lib/env'
import { stripe } from '@/lib/stripe' import { stripe } from '@/lib/stripe'
@ -10,13 +11,6 @@ const relevantStripeEvents = new Set<Stripe.Event.Type>([
'customer.subscription.updated' 'customer.subscription.updated'
]) ])
const stripeValidSubscriptionStatuses = new Set([
'active',
'trialing',
'incomplete',
'past_due'
])
export function registerV1StripeWebhook(app: OpenAPIHono) { export function registerV1StripeWebhook(app: OpenAPIHono) {
return app.post('/webhooks/stripe', async (ctx) => { return app.post('/webhooks/stripe', async (ctx) => {
const body = await ctx.req.text() const body = await ctx.req.text()
@ -81,15 +75,13 @@ export function registerV1StripeWebhook(app: OpenAPIHono) {
if (consumer.stripeStatus !== subscription.status) { if (consumer.stripeStatus !== subscription.status) {
consumer.stripeStatus = subscription.status consumer.stripeStatus = subscription.status
consumer.enabled = setConsumerStripeSubscriptionStatus(consumer)
consumer.plan === 'free' ||
stripeValidSubscriptionStatuses.has(consumer.stripeStatus)
await db await db
.update(schema.consumers) .update(schema.consumers)
.set({ .set({
stripeStatus: consumer.stripeStatus, stripeStatus: consumer.stripeStatus,
enabled: consumer.enabled isStripeSubscriptionActive: consumer.isStripeSubscriptionActive
}) })
.where(eq(schema.consumers.id, consumer.id)) .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. // The only place we allow `@agentic/platform-db` imports is in this directory.
import { import {
drizzle, drizzle,
postgres, postgres,
@ -16,5 +16,4 @@ const postgresClient =
export const db = drizzle({ client: postgresClient, schema }) export const db = drizzle({ client: postgresClient, schema })
export * from '@agentic/platform-db' 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. // The only place we allow `@agentic/platform-db` imports is in this directory.
export * from '@agentic/platform-db' 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 { AuthenticatedContext } from '@/lib/types'
import { import {
type ConsumerUpdate,
db, db,
eq, eq,
getStripePriceIdForPricingPlanLineItem, getStripePriceIdForPricingPlanLineItem,
type RawConsumer, type RawConsumer,
type RawConsumerUpdate,
type RawDeployment, type RawDeployment,
type RawProject, type RawProject,
type RawUser, type RawUser,
@ -15,6 +15,8 @@ import {
} from '@/db' } from '@/db'
import { stripe } from '@/lib/stripe' import { stripe } from '@/lib/stripe'
import { setConsumerStripeSubscriptionStatus } from '../consumers/utils'
export async function upsertStripeSubscription( export async function upsertStripeSubscription(
ctx: AuthenticatedContext, ctx: AuthenticatedContext,
{ {
@ -292,8 +294,9 @@ export async function upsertStripeSubscription(
assert(subscription, 500, 'Missing stripe subscription') assert(subscription, 500, 'Missing stripe subscription')
logger.debug('subscription', subscription) logger.debug('subscription', subscription)
const consumerUpdate: ConsumerUpdate = consumer const consumerUpdate: RawConsumerUpdate = consumer
consumerUpdate.stripeStatus = subscription.status consumerUpdate.stripeStatus = subscription.status
setConsumerStripeSubscriptionStatus(consumerUpdate)
// if (!plan) { // if (!plan) {
// TODO: we cancel at the end of the billing interval, so we shouldn't // 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 type { AuthenticatedContext } from '@/lib/types'
import { and, db, eq, schema } from '@/db' import { and, db, eq, schema } from '@/db'
import { acl } from '@/lib/acl' 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 { 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( export async function upsertConsumer(
c: AuthenticatedContext, c: AuthenticatedContext,
{ {
@ -80,7 +79,7 @@ export async function upsertConsumer(
assert( assert(
!existingConsumer || !existingConsumer ||
!existingConsumer.enabled || !existingConsumer.isStripeSubscriptionActive ||
existingConsumer.plan !== plan || existingConsumer.plan !== plan ||
existingConsumer.deploymentId !== deploymentId, existingConsumer.deploymentId !== deploymentId,
409, 409,
@ -102,9 +101,9 @@ export async function upsertConsumer(
`Project not found "${projectId}" for deployment "${deploymentId}"` `Project not found "${projectId}" for deployment "${deploymentId}"`
) )
assert( assert(
deployment.enabled, !deployment.deletedAt,
410, 410,
`Deployment has been disabled by its owner "${deployment.id}"` `Deployment has been deleted by its owner "${deployment.id}"`
) )
if (plan) { 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(), createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp() updatedAt: timestamp()
.notNull() .notNull()
.default(sql`now()`) .default(sql`now()`),
deletedAt: timestamp()
} }
export const userRoleEnum = pgEnum('UserRole', ['user', 'admin']) export const userRoleEnum = pgEnum('UserRole', ['user', 'admin'])

Wyświetl plik

@ -59,9 +59,6 @@ export const consumers = pgTable(
// initializing their subscription. // initializing their subscription.
activated: boolean().default(false).notNull(), activated: boolean().default(false).notNull(),
// Whether the consumer's subscription is currently active
enabled: boolean().default(true).notNull(),
// TODO: Re-add coupon support // TODO: Re-add coupon support
// coupon: text(), // coupon: text(),
@ -90,6 +87,10 @@ export const consumers = pgTable(
// Stripe subscription status (synced via webhooks) // Stripe subscription status (synced via webhooks)
stripeStatus: text(), stripeStatus: text(),
// Whether the consumer's subscription is currently active, depending on
// `stripeStatus`.
isStripeSubscriptionActive: boolean().default(true).notNull(),
// Main Stripe Subscription id // Main Stripe Subscription id
_stripeSubscriptionId: stripeId(), _stripeSubscriptionId: stripeId(),
@ -108,8 +109,12 @@ export const consumers = pgTable(
index('consumer_userId_idx').on(table.userId), index('consumer_userId_idx').on(table.userId),
index('consumer_projectId_idx').on(table.projectId), index('consumer_projectId_idx').on(table.projectId),
index('consumer_deploymentId_idx').on(table.deploymentId), index('consumer_deploymentId_idx').on(table.deploymentId),
index('consumer_isStripeSubscriptionActive_idx').on(
table.isStripeSubscriptionActive
),
index('consumer_createdAt_idx').on(table.createdAt), 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(), hash: text().notNull(),
version: text(), version: text(),
enabled: boolean().default(true).notNull(),
published: boolean().default(false).notNull(), published: boolean().default(false).notNull(),
description: text().default('').notNull(), description: text().default('').notNull(),
@ -82,11 +81,11 @@ export const deployments = pgTable(
index('deployment_userId_idx').on(table.userId), index('deployment_userId_idx').on(table.userId),
index('deployment_teamId_idx').on(table.teamId), index('deployment_teamId_idx').on(table.teamId),
index('deployment_projectId_idx').on(table.projectId), index('deployment_projectId_idx').on(table.projectId),
index('deployment_enabled_idx').on(table.enabled),
index('deployment_published_idx').on(table.published), index('deployment_published_idx').on(table.published),
index('deployment_version_idx').on(table.version), index('deployment_version_idx').on(table.version),
index('deployment_createdAt_idx').on(table.createdAt), 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) export const deploymentUpdateSchema = createUpdateSchema(deployments)
.pick({ .pick({
enabled: true, deletedAt: true,
description: true description: true
}) })
.strict() .strict()

Wyświetl plik

@ -58,7 +58,8 @@ export const logEntries = pgTable(
index('log_entry_deploymentId_idx').on(table.deploymentId), index('log_entry_deploymentId_idx').on(table.deploymentId),
index('log_entry_consumerId_idx').on(table.consumerId), index('log_entry_consumerId_idx').on(table.consumerId),
index('log_entry_createdAt_idx').on(table.createdAt), 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_teamId_idx').on(table.teamId),
index('project_alias_idx').on(table.alias), index('project_alias_idx').on(table.alias),
index('project_createdAt_idx').on(table.createdAt), 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_team_idx').on(table.teamId),
index('team_member_slug_idx').on(table.teamSlug), index('team_member_slug_idx').on(table.teamSlug),
index('team_member_createdAt_idx').on(table.createdAt), 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) => [ (table) => [
uniqueIndex('team_slug_idx').on(table.slug), uniqueIndex('team_slug_idx').on(table.slug),
index('team_createdAt_idx').on(table.createdAt), 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_emailConfirmToken_idx').on(table.emailConfirmToken),
uniqueIndex('user_passwordResetToken_idx').on(table.passwordResetToken), uniqueIndex('user_passwordResetToken_idx').on(table.passwordResetToken),
index('user_createdAt_idx').on(table.createdAt), 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 { 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 UserKeys = Exclude<keyof User & keyof RawUser, 'authProviders'>
type LogEntryKeys = keyof RawLogEntry & keyof LogEntry type LogEntryKeys = keyof RawLogEntry & keyof LogEntry
type ConsumerKeys = keyof RawConsumer & keyof Consumer
test('User types are compatible', () => { test('User types are compatible', () => {
expectTypeOf<RawUser>().toExtend<User>() expectTypeOf<RawUser>().toExtend<User>()
@ -18,3 +27,17 @@ test('LogEntry types are compatible', () => {
RawLogEntry[LogEntryKeys] 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) deployment?: RawDeployment | null // TODO: remove null (requires drizzle-orm changes)
} }
> >
export type ConsumerUpdate = Partial< export type RawConsumerUpdate = Partial<
Omit< Omit<
InferInsertModel<typeof schema.consumers>, InferInsertModel<typeof schema.consumers>,
'id' | 'projectId' | 'userId' | 'deploymentId' 'id' | 'projectId' | 'userId' | 'deploymentId'

Wyświetl plik

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