From 70d7425ccb65dce5f2fbd99107b38c7124f51717 Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 13 May 2025 21:50:55 -0700 Subject: [PATCH 1/6] Events API --- .env | 1 + .storybook/preview.ts | 2 +- .../extension/sign-in/DeepLink.tsx | 51 ++++++++++ .../extension/sign-in/page.tsx | 31 +++++++ .../extension/sign-in/route.ts | 25 ----- src/app/api/events/route.ts | 17 ++++ src/app/api/ping/route.ts | 42 +++------ src/components/ui/button.tsx | 2 +- src/components/ui/card.tsx | 92 +++++++++++++++++++ src/components/ui/index.ts | 1 + src/lib/server/env.ts | 2 + src/types/Auth.ts | 14 --- src/types/Subscription.ts | 28 ------ src/types/enum.ts | 1 - src/types/global.d.ts | 8 -- 15 files changed, 209 insertions(+), 108 deletions(-) create mode 100644 src/app/(authenticated)/extension/sign-in/DeepLink.tsx create mode 100644 src/app/(authenticated)/extension/sign-in/page.tsx delete mode 100644 src/app/(authenticated)/extension/sign-in/route.ts create mode 100644 src/app/api/events/route.ts create mode 100644 src/components/ui/card.tsx delete mode 100644 src/types/Auth.ts delete mode 100644 src/types/Subscription.ts delete mode 100644 src/types/enum.ts delete mode 100644 src/types/global.d.ts diff --git a/.env b/.env index 8e115d0fe5..bedfcf9eea 100644 --- a/.env +++ b/.env @@ -1,6 +1,7 @@ BILLING_PLAN_ENV=dev VSCODE_EXTENSION_BASE_URL=vscode://RooVeterinaryInc.roo-cline +CURSOR_EXTENSION_BASE_URL=cursor://RooVeterinaryInc.roo-cline # Clerk # https://clerk.com/docs/deployments/clerk-environment-variables#sign-in-and-sign-up-redirects diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 0e436a643c..211803d989 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,4 +1,4 @@ -import '../src/styles/global.css'; +import '../src/styles/globals.css'; import type { Preview } from '@storybook/react'; diff --git a/src/app/(authenticated)/extension/sign-in/DeepLink.tsx b/src/app/(authenticated)/extension/sign-in/DeepLink.tsx new file mode 100644 index 0000000000..221e20544d --- /dev/null +++ b/src/app/(authenticated)/extension/sign-in/DeepLink.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; + +import { Button, Card, CardContent, CardFooter } from '@/components/ui'; +import { Logo } from '@/components/layout'; + +interface DeepLinkProps { + vsCodeUrl: string; + cursorUrl: string; +} + +export const DeepLink = ({ vsCodeUrl, cursorUrl }: DeepLinkProps) => { + const [redirectAttempted, setRedirectAttempted] = useState(false); + + useEffect(() => { + window.location.href = vsCodeUrl; + const timer = setTimeout(() => setRedirectAttempted(true), 2000); + return () => clearTimeout(timer); + }, [vsCodeUrl]); + + return ( +
+ + + + + +
+ You've successfully authenticated. We're attempting to + open VSCode with your credentials. +
+
+ + +
+
+ + {redirectAttempted + ? 'You can close this window after VSCode opens.' + : 'Redirecting automatically...'} + +
+
+ ); +}; diff --git a/src/app/(authenticated)/extension/sign-in/page.tsx b/src/app/(authenticated)/extension/sign-in/page.tsx new file mode 100644 index 0000000000..1aab66ee91 --- /dev/null +++ b/src/app/(authenticated)/extension/sign-in/page.tsx @@ -0,0 +1,31 @@ +import { redirect } from 'next/navigation'; +import { auth } from '@clerk/nextjs/server'; + +import { getSignInToken } from '@/lib/server/clerk'; +import { Env } from '@/lib/server/env'; + +import { DeepLink } from './DeepLink'; + +export default async function Page(params: { + searchParams: { state?: string }; +}) { + const { userId } = await auth(); + + if (!userId) { + redirect('/sign-in'); + } + + const code = await getSignInToken(userId); + + if (!code) { + redirect('/sign-in'); + } + + const { state = '' } = await params.searchParams; + const searchParams = new URLSearchParams({ state, code }); + const path = `/auth/clerk/callback?${searchParams.toString()}`; + const vsCodeUrl = new URL(path, Env.VSCODE_EXTENSION_BASE_URL); + const cursorUrl = new URL(path, Env.CURSOR_EXTENSION_BASE_URL); + + return ; +} diff --git a/src/app/(authenticated)/extension/sign-in/route.ts b/src/app/(authenticated)/extension/sign-in/route.ts deleted file mode 100644 index 99f59233d9..0000000000 --- a/src/app/(authenticated)/extension/sign-in/route.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { getSignInToken } from '@/lib/server/clerk'; -import { Env } from '@/lib/server/env'; -import { auth } from '@clerk/nextjs/server'; -import { redirect } from 'next/navigation'; -import { type NextRequest } from 'next/server'; - -export async function GET(request: NextRequest) { - const authObj = await auth(); - const userId = authObj.userId; - const state = request.nextUrl.searchParams.get('state') || ''; - - if (!userId) { - return new Response('Unauthorized', { status: 401 }); - } - - const signInToken = await getSignInToken(authObj.userId); - if (!signInToken) { - throw new Error("couldn't sign in"); - } - - const url = new URL(`${Env.VSCODE_EXTENSION_BASE_URL}/auth/clerk/callback`); - url.searchParams.append('state', state); - url.searchParams.append('code', signInToken); - redirect(url.href); -} diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts new file mode 100644 index 0000000000..30fcee7f07 --- /dev/null +++ b/src/app/api/events/route.ts @@ -0,0 +1,17 @@ +import { auth } from '@clerk/nextjs/server'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + const { userId } = await auth(); + + if (!userId) { + return NextResponse.json( + { error: 'Unauthorized request' }, + { status: 401 }, + ); + } + + console.log(await request.json()); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/ping/route.ts b/src/app/api/ping/route.ts index ac768c175e..e27943b52e 100644 --- a/src/app/api/ping/route.ts +++ b/src/app/api/ping/route.ts @@ -4,13 +4,12 @@ import { NextResponse } from 'next/server'; import { logger } from '@/lib/server/logger'; /** - * API endpoint for testing Clerk authentication - * Verifies/parses JWT and logs authenticated user information + * API endpoint for testing Clerk authentication. + * Verifies/parses JWT and logs authenticated user information. */ export async function GET() { const authObj = await auth(); - // If not authenticated if (!authObj.userId) { return NextResponse.json( { error: 'Unauthorized request' }, @@ -18,35 +17,18 @@ export async function GET() { ); } - // Get the JWT token + // Get the JWT token. const token = await authObj.getToken(); - // Extract user information from the auth object - // Only include properties that exist on the auth object - const userInfo = { - userId: authObj.userId, - sessionId: authObj.sessionId, - orgId: authObj.orgId, - orgRole: authObj.orgRole, - // Note: To get additional user data like email, firstName, lastName, - // you would need to use Clerk's methods like clerkClient.users.getUser() - }; + // Extract user information from the auth object. + // Only include properties that exist on the auth object. + // Note: To get additional user data like email, firstName, lastName, + // you would need to use Clerk's methods like clerkClient.users.getUser(). + const { userId, sessionId, orgId, orgRole } = authObj; + const userInfo = { userId, sessionId, orgId, orgRole }; - // Log the user information - logger.info({ - event: 'ping_endpoint_accessed', - userInfo: { - userId: authObj.userId, - sessionId: authObj.sessionId, - orgId: authObj.orgId, - orgRole: authObj.orgRole, - }, - hasToken: !!token, // Just log if token exists, not the actual token for security - }); + // Just log if token exists, not the actual token for security. + logger.info({ event: 'ping_endpoint_accessed', userInfo, hasToken: !!token }); - // Return the user information - return NextResponse.json({ - authenticated: true, - userInfo, - }); + return NextResponse.json(userInfo); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 29f98ef9ee..903736b26f 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { cva, type VariantProps } from 'class-variance-authority'; import { cn } from '@/lib/utils'; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer", + "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-sm text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer", { variants: { variant: { diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000000..7cda3847f4 --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Card({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index b64503aeb9..e395365f1f 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -1,3 +1,4 @@ export * from './button'; +export * from './card'; export * from './dropdown-menu'; export * from './separator'; diff --git a/src/lib/server/env.ts b/src/lib/server/env.ts index 6912a58da3..d27e9e92ee 100644 --- a/src/lib/server/env.ts +++ b/src/lib/server/env.ts @@ -10,6 +10,7 @@ export const Env = createEnv({ STRIPE_WEBHOOK_SECRET: z.string().min(1), BILLING_PLAN_ENV: z.enum(['dev', 'test', 'prod']), VSCODE_EXTENSION_BASE_URL: z.string().min(1), + CURSOR_EXTENSION_BASE_URL: z.string().min(1), }, client: { NEXT_PUBLIC_APP_URL: z.string().optional(), @@ -34,6 +35,7 @@ export const Env = createEnv({ STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET, BILLING_PLAN_ENV: process.env.BILLING_PLAN_ENV, VSCODE_EXTENSION_BASE_URL: process.env.VSCODE_EXTENSION_BASE_URL, + CURSOR_EXTENSION_BASE_URL: process.env.CURSOR_EXTENSION_BASE_URL, NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL, NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY, diff --git a/src/types/Auth.ts b/src/types/Auth.ts deleted file mode 100644 index 7903e44bfd..0000000000 --- a/src/types/Auth.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { EnumValues } from './enum'; - -export const ORG_ROLE = { - ADMIN: 'org:admin', - MEMBER: 'org:member', -} as const; - -export type OrgRole = EnumValues; - -export const ORG_PERMISSION = { - // Add Organization Permissions here. -} as const; - -export type OrgPermission = EnumValues; diff --git a/src/types/Subscription.ts b/src/types/Subscription.ts deleted file mode 100644 index c35827213a..0000000000 --- a/src/types/Subscription.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { EnumValues } from './enum'; - -export const PLAN_ID = { - FREE: 'free', - PREMIUM: 'premium', - ENTERPRISE: 'enterprise', -} as const; - -export type PlanId = EnumValues; - -export const BILLING_INTERVAL = { - MONTH: 'month', - YEAR: 'year', -} as const; - -export type BillingInterval = EnumValues; - -export const SUBSCRIPTION_STATUS = { - ACTIVE: 'active', - PENDING: 'pending', -} as const; - -export type IStripeSubscription = { - stripeSubscriptionId: string | null; - stripeSubscriptionPriceId: string | null; - stripeSubscriptionStatus: string | null; - stripeSubscriptionCurrentPeriodEnd: number | null; -}; diff --git a/src/types/enum.ts b/src/types/enum.ts deleted file mode 100644 index c273087f69..0000000000 --- a/src/types/enum.ts +++ /dev/null @@ -1 +0,0 @@ -export type EnumValues = Type[keyof Type]; diff --git a/src/types/global.d.ts b/src/types/global.d.ts deleted file mode 100644 index 5c887a534f..0000000000 --- a/src/types/global.d.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { OrgPermission, OrgRole } from '@/types/auth'; - -declare global { - interface ClerkAuthorization { - permission: OrgPermission; - role: OrgRole; - } -} From e674a1c720a3e6e39d65925202dea60ea3bc5a2c Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 13 May 2025 21:55:33 -0700 Subject: [PATCH 2/6] Remove these --- tests/e2e/I18n.e2e.ts | 34 ---------------------------------- tests/e2e/Sanity.check.e2e.ts | 25 ------------------------- tests/e2e/Visual.e2e.ts | 26 -------------------------- tests/integration/.gitkeep | 1 - 4 files changed, 86 deletions(-) delete mode 100644 tests/e2e/I18n.e2e.ts delete mode 100644 tests/e2e/Sanity.check.e2e.ts delete mode 100644 tests/e2e/Visual.e2e.ts delete mode 100644 tests/integration/.gitkeep diff --git a/tests/e2e/I18n.e2e.ts b/tests/e2e/I18n.e2e.ts deleted file mode 100644 index 98bf37defd..0000000000 --- a/tests/e2e/I18n.e2e.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { expect, test } from '@playwright/test'; - -test.describe('I18n', () => { - test.describe('Language Switching', () => { - test('should switch language from English to French using dropdown and verify text on the homepage', async ({ - page, - }) => { - await page.goto('/'); - - await expect( - page.getByText('The perfect SaaS template to build'), - ).toBeVisible(); - - await page.getByRole('button', { name: 'lang-switcher' }).click(); - await page.getByText('Français').click(); - - await expect( - page.getByText('Le parfait SaaS template pour construire'), - ).toBeVisible(); - }); - - test('should switch language from English to French using URL and verify text on the sign-in page', async ({ - page, - }) => { - await page.goto('/sign-in'); - - await expect(page.getByText('Email address')).toBeVisible(); - - await page.goto('/fr/sign-in'); - - await expect(page.getByText('Adresse e-mail')).toBeVisible(); - }); - }); -}); diff --git a/tests/e2e/Sanity.check.e2e.ts b/tests/e2e/Sanity.check.e2e.ts deleted file mode 100644 index f0c25315d3..0000000000 --- a/tests/e2e/Sanity.check.e2e.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { expect, test } from '@playwright/test'; - -// Checkly is a tool used to monitor deployed environments, such as production or preview environments. -// It runs end-to-end tests with the `.check.e2e.ts` extension after each deployment to ensure that the environment is up and running. -// With Checkly, you can monitor your production environment and run `*.check.e2e.ts` tests regularly at a frequency of your choice. -// If the tests fail, Checkly will notify you via email, Slack, or other channels of your choice. -// On the other hand, E2E tests ending with `*.e2e.ts` are only run before deployment. -// You can run them locally or on CI to ensure that the application is ready for deployment. - -// BaseURL needs to be explicitly defined in the test file. -// Otherwise, Checkly runtime will throw an exception: `CHECKLY_INVALID_URL: Only URL's that start with http(s)` -// You can't use `goto` function directly with a relative path like with other *.e2e.ts tests. -// Check the example at https://feedback.checklyhq.com/changelog/new-changelog-436 - -test.describe('Sanity', () => { - test.describe('Static pages', () => { - test('should display the homepage', async ({ page, baseURL }) => { - await page.goto(`${baseURL}/`); - - await expect( - page.getByText('The perfect SaaS template to build'), - ).toBeVisible(); - }); - }); -}); diff --git a/tests/e2e/Visual.e2e.ts b/tests/e2e/Visual.e2e.ts deleted file mode 100644 index ed75720cbe..0000000000 --- a/tests/e2e/Visual.e2e.ts +++ /dev/null @@ -1,26 +0,0 @@ -import percySnapshot from '@percy/playwright'; -import { expect, test } from '@playwright/test'; - -test.describe('Visual testing', () => { - test.describe('Static pages', () => { - test('should take screenshot of the homepage', async ({ page }) => { - await page.goto('/'); - - await expect( - page.getByText('The perfect SaaS template to build'), - ).toBeVisible(); - - await percySnapshot(page, 'Homepage'); - }); - - test('should take screenshot of the French homepage', async ({ page }) => { - await page.goto('/fr'); - - await expect( - page.getByText('Le parfait SaaS template pour construire'), - ).toBeVisible(); - - await percySnapshot(page, 'Homepage - French'); - }); - }); -}); diff --git a/tests/integration/.gitkeep b/tests/integration/.gitkeep deleted file mode 100644 index aa2234e186..0000000000 --- a/tests/integration/.gitkeep +++ /dev/null @@ -1 +0,0 @@ -Place your integration tests in this folder by creating files with the `*.spec.ts` extension. These files will be automatically detected by Playwright. For a real-world example, you can check out the Next.js Boilerplate Pro at https://nextjs-boilerplate.com/pro-saas-starter-kit From 1acc88c0a24ae15a87c3845949d3d009784ed553 Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 13 May 2025 22:33:30 -0700 Subject: [PATCH 3/6] Add eslint ignore --- eslint.config.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index fd78887650..ae1e4d0403 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -28,7 +28,7 @@ export default [ }, }, { - ignores: ['dist/**', '.next'], + ignores: ['dist/**', '.next', 'storybook-static'], }, { ...pluginReact.configs.flat.recommended, From 331c26afec2fe07e7eb92e87338ce94568c9162e Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 13 May 2025 22:59:20 -0700 Subject: [PATCH 4/6] Pass state param through auth process --- .../extension/sign-in/DeepLink.tsx | 0 .../extension/sign-in/page.tsx | 12 ++++++------ src/app/sign-in/[[...sign-in]]/SignIn.tsx | 13 ++++++++++++- src/app/sign-up/[[...sign-up]]/SignUp.tsx | 13 ++++++++++++- 4 files changed, 30 insertions(+), 8 deletions(-) rename src/app/{(authenticated) => }/extension/sign-in/DeepLink.tsx (100%) rename src/app/{(authenticated) => }/extension/sign-in/page.tsx (78%) diff --git a/src/app/(authenticated)/extension/sign-in/DeepLink.tsx b/src/app/extension/sign-in/DeepLink.tsx similarity index 100% rename from src/app/(authenticated)/extension/sign-in/DeepLink.tsx rename to src/app/extension/sign-in/DeepLink.tsx diff --git a/src/app/(authenticated)/extension/sign-in/page.tsx b/src/app/extension/sign-in/page.tsx similarity index 78% rename from src/app/(authenticated)/extension/sign-in/page.tsx rename to src/app/extension/sign-in/page.tsx index 1aab66ee91..5f7e1fd52d 100644 --- a/src/app/(authenticated)/extension/sign-in/page.tsx +++ b/src/app/extension/sign-in/page.tsx @@ -9,19 +9,19 @@ import { DeepLink } from './DeepLink'; export default async function Page(params: { searchParams: { state?: string }; }) { - const { userId } = await auth(); + const { state } = await params.searchParams; - if (!userId) { - redirect('/sign-in'); + if (!state) { + redirect(`/sign-in`); } - const code = await getSignInToken(userId); + const { userId } = await auth(); + const code = userId ? await getSignInToken(userId) : undefined; if (!code) { - redirect('/sign-in'); + redirect(`/sign-in?state=${state}`); } - const { state = '' } = await params.searchParams; const searchParams = new URLSearchParams({ state, code }); const path = `/auth/clerk/callback?${searchParams.toString()}`; const vsCodeUrl = new URL(path, Env.VSCODE_EXTENSION_BASE_URL); diff --git a/src/app/sign-in/[[...sign-in]]/SignIn.tsx b/src/app/sign-in/[[...sign-in]]/SignIn.tsx index a12e84d7b9..068eb7e5cd 100644 --- a/src/app/sign-in/[[...sign-in]]/SignIn.tsx +++ b/src/app/sign-in/[[...sign-in]]/SignIn.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useMemo } from 'react'; +import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { useTheme } from 'next-themes'; import { dark } from '@clerk/themes'; @@ -10,13 +12,22 @@ import { Logo } from '@/components/layout'; export const SignIn = () => { const { resolvedTheme } = useTheme(); const baseTheme = resolvedTheme === 'dark' ? dark : undefined; + const searchParams = useSearchParams(); + + const forceRedirectUrl = useMemo(() => { + const state = searchParams.get('state'); + return state ? `/extension/sign-in?state=${state}` : undefined; + }, [searchParams]); return (
- +
); }; diff --git a/src/app/sign-up/[[...sign-up]]/SignUp.tsx b/src/app/sign-up/[[...sign-up]]/SignUp.tsx index 6e5b6d25f8..48c68094fb 100644 --- a/src/app/sign-up/[[...sign-up]]/SignUp.tsx +++ b/src/app/sign-up/[[...sign-up]]/SignUp.tsx @@ -1,5 +1,7 @@ 'use client'; +import { useMemo } from 'react'; +import { useSearchParams } from 'next/navigation'; import Link from 'next/link'; import { useTheme } from 'next-themes'; import { dark } from '@clerk/themes'; @@ -10,13 +12,22 @@ import { Logo } from '@/components/layout'; export const SignUp = () => { const { resolvedTheme } = useTheme(); const baseTheme = resolvedTheme === 'dark' ? dark : undefined; + const searchParams = useSearchParams(); + + const forceRedirectUrl = useMemo(() => { + const state = searchParams.get('state'); + return state ? `/extension/sign-in?state=${state}` : undefined; + }, [searchParams]); return (
- +
); }; From 1fa656a012831d712db2ab7a2804cbefe4a62a15 Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 13 May 2025 23:24:02 -0700 Subject: [PATCH 5/6] Add events table --- .docker/scripts/postgres/create-databases.sh | 2 +- migrations/0001_glamorous_thunderbird.sql | 8 + migrations/meta/0001_snapshot.json | 149 +++++++++++++++++++ migrations/meta/_journal.json | 7 + src/db/schema.ts | 19 ++- 5 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 migrations/0001_glamorous_thunderbird.sql create mode 100644 migrations/meta/0001_snapshot.json diff --git a/.docker/scripts/postgres/create-databases.sh b/.docker/scripts/postgres/create-databases.sh index 481352f2c3..347f026f8a 100755 --- a/.docker/scripts/postgres/create-databases.sh +++ b/.docker/scripts/postgres/create-databases.sh @@ -6,6 +6,6 @@ set -u if [ -n "$POSTGRES_DATABASES" ]; then for db in $(echo $POSTGRES_DATABASES | tr ',' ' '); do echo "Creating $db..." - psql -v ON_ERROR_STOP=1 -c "CREATE DATABASE $db;" + psql -U postgres -v ON_ERROR_STOP=1 -c "CREATE DATABASE $db;" done fi diff --git a/migrations/0001_glamorous_thunderbird.sql b/migrations/0001_glamorous_thunderbird.sql new file mode 100644 index 0000000000..e7a22c4ef1 --- /dev/null +++ b/migrations/0001_glamorous_thunderbird.sql @@ -0,0 +1,8 @@ +CREATE TABLE "event" ( + "id" text PRIMARY KEY NOT NULL, + "type" text NOT NULL, + "timestamp" bigint NOT NULL, + "properties" json NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json new file mode 100644 index 0000000000..6383fb94f8 --- /dev/null +++ b/migrations/meta/0001_snapshot.json @@ -0,0 +1,149 @@ +{ + "id": "37608d29-a721-43ec-91e6-c4a356d23b3e", + "prevId": "013accf9-070a-47cd-9b49-1318a1c8a237", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.event": { + "name": "event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timestamp": { + "name": "timestamp", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "properties": { + "name": "properties", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_price_id": { + "name": "stripe_subscription_price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_status": { + "name": "stripe_subscription_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_current_period_end": { + "name": "stripe_subscription_current_period_end", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "stripe_customer_id_idx": { + "name": "stripe_customer_id_idx", + "columns": [ + { + "expression": "stripe_customer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index c6f820ea20..35730d6981 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1747031111132, "tag": "0000_misty_leo", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1747203099634, + "tag": "0001_glamorous_thunderbird", + "breakpoints": true } ] } diff --git a/src/db/schema.ts b/src/db/schema.ts index 94ca4c5e2a..06f42c482c 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -4,6 +4,7 @@ import { text, timestamp, uniqueIndex, + json, } from 'drizzle-orm/pg-core'; export const organizationSchema = pgTable( @@ -24,9 +25,17 @@ export const organizationSchema = pgTable( .notNull(), createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(), }, - (table) => ({ - stripeCustomerIdIdx: uniqueIndex('stripe_customer_id_idx').on( - table.stripeCustomerId, - ), - }), + (table) => [uniqueIndex('stripe_customer_id_idx').on(table.stripeCustomerId)], ); + +export const eventSchema = pgTable('event', { + id: text('id').primaryKey(), + type: text('type').notNull(), + timestamp: bigint('timestamp', { mode: 'number' }).notNull(), + properties: json('properties').notNull(), + createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { mode: 'date' }) + .defaultNow() + .$onUpdate(() => new Date()) + .notNull(), +}); From 93fc57bb0d152820490cb090483303e2c3d247f2 Mon Sep 17 00:00:00 2001 From: cte Date: Tue, 13 May 2025 23:42:42 -0700 Subject: [PATCH 6/6] Persist event data --- ...hunderbird.sql => 0000_hesitant_kylun.sql} | 4 +- migrations/0000_misty_leo.sql | 12 -- migrations/meta/0000_snapshot.json | 89 +++++------ migrations/meta/0001_snapshot.json | 149 ------------------ migrations/meta/_journal.json | 11 +- src/app/api/events/route.ts | 15 +- src/db/schema.ts | 46 +++--- src/schemas/index.ts | 53 +++++++ 8 files changed, 129 insertions(+), 250 deletions(-) rename migrations/{0001_glamorous_thunderbird.sql => 0000_hesitant_kylun.sql} (50%) delete mode 100644 migrations/0000_misty_leo.sql delete mode 100644 migrations/meta/0001_snapshot.json create mode 100644 src/schemas/index.ts diff --git a/migrations/0001_glamorous_thunderbird.sql b/migrations/0000_hesitant_kylun.sql similarity index 50% rename from migrations/0001_glamorous_thunderbird.sql rename to migrations/0000_hesitant_kylun.sql index e7a22c4ef1..2c76dcc0fd 100644 --- a/migrations/0001_glamorous_thunderbird.sql +++ b/migrations/0000_hesitant_kylun.sql @@ -1,5 +1,5 @@ -CREATE TABLE "event" ( - "id" text PRIMARY KEY NOT NULL, +CREATE TABLE "events" ( + "id" integer PRIMARY KEY GENERATED ALWAYS AS IDENTITY (sequence name "events_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1), "type" text NOT NULL, "timestamp" bigint NOT NULL, "properties" json NOT NULL, diff --git a/migrations/0000_misty_leo.sql b/migrations/0000_misty_leo.sql deleted file mode 100644 index 8854258552..0000000000 --- a/migrations/0000_misty_leo.sql +++ /dev/null @@ -1,12 +0,0 @@ -CREATE TABLE "organization" ( - "id" text PRIMARY KEY NOT NULL, - "stripe_customer_id" text, - "stripe_subscription_id" text, - "stripe_subscription_price_id" text, - "stripe_subscription_status" text, - "stripe_subscription_current_period_end" bigint, - "updated_at" timestamp DEFAULT now() NOT NULL, - "created_at" timestamp DEFAULT now() NOT NULL -); ---> statement-breakpoint -CREATE UNIQUE INDEX "stripe_customer_id_idx" ON "organization" USING btree ("stripe_customer_id"); \ No newline at end of file diff --git a/migrations/meta/0000_snapshot.json b/migrations/meta/0000_snapshot.json index 8d2c1bea6f..4ebacc9d10 100644 --- a/migrations/meta/0000_snapshot.json +++ b/migrations/meta/0000_snapshot.json @@ -1,55 +1,47 @@ { - "id": "013accf9-070a-47cd-9b49-1318a1c8a237", + "id": "5ab333f5-a980-4564-a919-3d15608505d0", "prevId": "00000000-0000-0000-0000-000000000000", "version": "7", "dialect": "postgresql", "tables": { - "public.organization": { - "name": "organization", + "public.events": { + "name": "events", "schema": "", "columns": { "id": { "name": "id", - "type": "text", + "type": "integer", "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "events_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "2147483647", + "cache": "1", + "cycle": false + } + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, "notNull": true }, - "stripe_customer_id": { - "name": "stripe_customer_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "stripe_subscription_id": { - "name": "stripe_subscription_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "stripe_subscription_price_id": { - "name": "stripe_subscription_price_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "stripe_subscription_status": { - "name": "stripe_subscription_status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "stripe_subscription_current_period_end": { - "name": "stripe_subscription_current_period_end", + "timestamp": { + "name": "timestamp", "type": "bigint", "primaryKey": false, - "notNull": false + "notNull": true }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", + "properties": { + "name": "properties", + "type": "json", "primaryKey": false, - "notNull": true, - "default": "now()" + "notNull": true }, "created_at": { "name": "created_at", @@ -57,25 +49,16 @@ "primaryKey": false, "notNull": true, "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" } }, - "indexes": { - "stripe_customer_id_idx": { - "name": "stripe_customer_id_idx", - "columns": [ - { - "expression": "stripe_customer_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, + "indexes": {}, "foreignKeys": {}, "compositePrimaryKeys": {}, "uniqueConstraints": {}, diff --git a/migrations/meta/0001_snapshot.json b/migrations/meta/0001_snapshot.json deleted file mode 100644 index 6383fb94f8..0000000000 --- a/migrations/meta/0001_snapshot.json +++ /dev/null @@ -1,149 +0,0 @@ -{ - "id": "37608d29-a721-43ec-91e6-c4a356d23b3e", - "prevId": "013accf9-070a-47cd-9b49-1318a1c8a237", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.event": { - "name": "event", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "timestamp": { - "name": "timestamp", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "properties": { - "name": "properties", - "type": "json", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.organization": { - "name": "organization", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "stripe_customer_id": { - "name": "stripe_customer_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "stripe_subscription_id": { - "name": "stripe_subscription_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "stripe_subscription_price_id": { - "name": "stripe_subscription_price_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "stripe_subscription_status": { - "name": "stripe_subscription_status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "stripe_subscription_current_period_end": { - "name": "stripe_subscription_current_period_end", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - }, - "created_at": { - "name": "created_at", - "type": "timestamp", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "stripe_customer_id_idx": { - "name": "stripe_customer_id_idx", - "columns": [ - { - "expression": "stripe_customer_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - } - }, - "enums": {}, - "schemas": {}, - "sequences": {}, - "roles": {}, - "policies": {}, - "views": {}, - "_meta": { - "columns": {}, - "schemas": {}, - "tables": {} - } -} diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 35730d6981..11751f2659 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -5,15 +5,8 @@ { "idx": 0, "version": "7", - "when": 1747031111132, - "tag": "0000_misty_leo", - "breakpoints": true - }, - { - "idx": 1, - "version": "7", - "when": 1747203099634, - "tag": "0001_glamorous_thunderbird", + "when": 1747204609794, + "tag": "0000_hesitant_kylun", "breakpoints": true } ] diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 30fcee7f07..e1d5964a8c 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -1,6 +1,10 @@ import { auth } from '@clerk/nextjs/server'; import { NextRequest, NextResponse } from 'next/server'; +import { eventSchema } from '@/schemas'; +import { db } from '@/db'; +import { eventsTable } from '@/db/schema'; + export async function POST(request: NextRequest) { const { userId } = await auth(); @@ -11,7 +15,14 @@ export async function POST(request: NextRequest) { ); } - console.log(await request.json()); + const payload = await request.json(); + const result = eventSchema.safeParse(payload); - return NextResponse.json({ success: true }); + if (!result.success) { + return NextResponse.json({ success: false }); + } + + const [record] = await db.insert(eventsTable).values(result.data).returning(); + + return NextResponse.json({ success: true, id: record?.id }); } diff --git a/src/db/schema.ts b/src/db/schema.ts index 06f42c482c..d600638eed 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -3,33 +3,33 @@ import { pgTable, text, timestamp, - uniqueIndex, json, + integer, } from 'drizzle-orm/pg-core'; -export const organizationSchema = pgTable( - 'organization', - { - id: text('id').primaryKey(), - stripeCustomerId: text('stripe_customer_id'), - stripeSubscriptionId: text('stripe_subscription_id'), - stripeSubscriptionPriceId: text('stripe_subscription_price_id'), - stripeSubscriptionStatus: text('stripe_subscription_status'), - stripeSubscriptionCurrentPeriodEnd: bigint( - 'stripe_subscription_current_period_end', - { mode: 'number' }, - ), - updatedAt: timestamp('updated_at', { mode: 'date' }) - .defaultNow() - .$onUpdate(() => new Date()) - .notNull(), - createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(), - }, - (table) => [uniqueIndex('stripe_customer_id_idx').on(table.stripeCustomerId)], -); +// export const organizationTable = pgTable( +// 'organizations', +// { +// id: integer().primaryKey().generatedAlwaysAsIdentity(), +// stripeCustomerId: text('stripe_customer_id'), +// stripeSubscriptionId: text('stripe_subscription_id'), +// stripeSubscriptionPriceId: text('stripe_subscription_price_id'), +// stripeSubscriptionStatus: text('stripe_subscription_status'), +// stripeSubscriptionCurrentPeriodEnd: bigint( +// 'stripe_subscription_current_period_end', +// { mode: 'number' }, +// ), +// updatedAt: timestamp('updated_at', { mode: 'date' }) +// .defaultNow() +// .$onUpdate(() => new Date()) +// .notNull(), +// createdAt: timestamp('created_at', { mode: 'date' }).defaultNow().notNull(), +// }, +// (table) => [uniqueIndex('stripe_customer_id_idx').on(table.stripeCustomerId)], +// ); -export const eventSchema = pgTable('event', { - id: text('id').primaryKey(), +export const eventsTable = pgTable('events', { + id: integer().primaryKey().generatedAlwaysAsIdentity(), type: text('type').notNull(), timestamp: bigint('timestamp', { mode: 'number' }).notNull(), properties: json('properties').notNull(), diff --git a/src/schemas/index.ts b/src/schemas/index.ts new file mode 100644 index 0000000000..27671fd376 --- /dev/null +++ b/src/schemas/index.ts @@ -0,0 +1,53 @@ +import { z } from 'zod'; + +export const providerNames = [ + 'anthropic', + 'glama', + 'openrouter', + 'bedrock', + 'vertex', + 'openai', + 'ollama', + 'vscode-lm', + 'lmstudio', + 'gemini', + 'openai-native', + 'mistral', + 'deepseek', + 'unbound', + 'requesty', + 'human-relay', + 'fake-ai', + 'xai', + 'groq', + 'chutes', + 'litellm', +] as const; + +export const eventSchema = z.discriminatedUnion('type', [ + z.object({ + type: z.literal('task_created'), + timestamp: z.number(), + properties: z.object({ + taskId: z.string(), + provider: z.enum(providerNames), + modelId: z.string(), + prompt: z.string(), + mode: z.string(), + }), + }), + z.object({ + type: z.literal('completion'), + timestamp: z.number(), + properties: z.object({ + taskId: z.string(), + provider: z.enum(providerNames), + modelId: z.string(), + inputTokens: z.number(), + outputTokens: z.number(), + cost: z.number(), + }), + }), +]); + +export type Event = z.infer;