kopia lustrzana https://github.com/transitive-bullshit/chatgpt-api
feat: replace enabled with deletedAt for soft deletes
rodzic
1303926c9d
commit
2484e7efdb
|
@ -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,
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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))
|
||||||
|
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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) {
|
|
@ -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))
|
||||||
|
}
|
|
@ -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'])
|
||||||
|
|
|
@ -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)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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)
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
@ -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>()
|
||||||
|
})
|
||||||
|
|
|
@ -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'
|
||||||
|
|
|
@ -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
|
||||||
|
|
Ładowanie…
Reference in New Issue