From 7d755ec5c51631116916f46ea83abd625229d022 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Sat, 9 Sep 2023 22:29:28 -0500 Subject: [PATCH] feat: add webhook endpoint to support syncing clerk user data into users table --- .vscode/settings.json | 2 +- convex/README.md | 90 -------------------------------- convex/_generated/api.d.ts | 7 ++- convex/_generated/dataModel.d.ts | 31 +++++------ convex/http.ts | 86 ++++++++++++++++++++++++++++++ convex/schema.ts | 8 +++ convex/users.ts | 49 +++++++++++++++++ package-lock.json | 61 ++++++++++++++++++++++ package.json | 1 + 9 files changed, 226 insertions(+), 109 deletions(-) delete mode 100644 convex/README.md create mode 100644 convex/http.ts create mode 100644 convex/schema.ts create mode 100644 convex/users.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 30a46fb..3253d91 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,4 @@ { "css.lint.unknownAtRules": "ignore", - "cSpell.words": ["conve", "nextjs", "signup"] + "cSpell.words": ["conve", "nextjs", "signup", "svix"] } diff --git a/convex/README.md b/convex/README.md deleted file mode 100644 index 388a119..0000000 --- a/convex/README.md +++ /dev/null @@ -1,90 +0,0 @@ -# Welcome to your Convex functions directory! - -Write your Convex functions here. See -https://docs.convex.dev/using/writing-convex-functions for more. - -A query function that takes two arguments looks like: - -```ts -// functions.js -import { query } from "./_generated/server"; -import { v } from "convex/values"; - -export const myQueryFunction = query({ - // Validators for arguments. - args: { - first: v.number(), - second: v.string(), - }, - - // Function implementation. - hander: async (ctx, args) => { - // Read the database as many times as you need here. - // See https://docs.convex.dev/database/reading-data. - const documents = await ctx.db.query("tablename").collect(); - - // Arguments passed from the client are properties of the args object. - console.log(args.first, args.second); - - // Write arbitrary JavaScript here: filter, aggregate, build derived data, - // remove non-public properties, or create new objects. - return documents; - }, -}); -``` - -Using this query function in a React component looks like: - -```ts -const data = useQuery(api.functions.myQueryFunction, { - first: 10, - second: "hello", -}); -``` - -A mutation function looks like: - -```ts -// functions.js -import { mutation } from "./_generated/server"; -import { v } from "convex/values"; - -export const myMutationFunction = mutation({ - // Validators for arguments. - args: { - first: v.string(), - second: v.string(), - }, - - // Function implementation. - hander: async (ctx, args) => { - // Insert or modify documents in the database here. - // Mutations can also read from the database like queries. - // See https://docs.convex.dev/database/writing-data. - const message = { body: args.first, author: args.second }; - const id = await ctx.db.insert("messages", message); - - // Optionally, return a value from your mutation. - return await ctx.db.get(id); - }, -}); -``` - -Using this mutation function in a React component looks like: - -```ts -const mutation = useMutation(api.functions.myMutationFunction); -function handleButtonPress() { - // fire and forget, the most common way to use mutations - mutation({ first: "Hello!", second: "me" }); - // OR - // use the result once the mutation has completed - mutation({ first: "Hello!", second: "me" }).then((result) => - console.log(result) - ); -} -``` - -Use the Convex CLI to push your functions to a deployment. See everything -the Convex CLI can do by running `npx convex -h` in your project root -directory. To learn more, launch the docs with `npx convex docs`. diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 6317cec..295087d 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -14,6 +14,8 @@ import type { FilterApi, FunctionReference, } from "convex/server"; +import type * as http from "../http"; +import type * as users from "../users"; /** * A utility for referencing Convex functions in your app's API. @@ -23,7 +25,10 @@ import type { * const myFunctionReference = api.myModule.myFunction; * ``` */ -declare const fullApi: ApiFromModules<{}>; +declare const fullApi: ApiFromModules<{ + http: typeof http; + users: typeof users; +}>; export declare const api: FilterApi< typeof fullApi, FunctionReference diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts index 5ef6a16..2b3beb4 100644 --- a/convex/_generated/dataModel.d.ts +++ b/convex/_generated/dataModel.d.ts @@ -9,29 +9,25 @@ * @module */ -import { AnyDataModel } from "convex/server"; +import type { DataModelFromSchemaDefinition } from "convex/server"; +import type { DocumentByName, TableNamesInDataModel } from "convex/server"; import type { GenericId } from "convex/values"; - -/** - * No `schema.ts` file found! - * - * This generated code has permissive types like `Doc = any` because - * Convex doesn't know your schema. If you'd like more type safety, see - * https://docs.convex.dev/using/schemas for instructions on how to add a - * schema file. - * - * After you change a schema, rerun codegen with `npx convex dev`. - */ +import schema from "../schema"; /** * The names of all of your Convex tables. */ -export type TableNames = string; +export type TableNames = TableNamesInDataModel; /** * The type of a document stored in Convex. + * + * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Doc = any; +export type Doc = DocumentByName< + DataModel, + TableName +>; /** * An identifier for a document in Convex. @@ -43,9 +39,10 @@ export type Doc = any; * * IDs are just strings at runtime, but this type can be used to distinguish them from other * strings when type checking. + * + * @typeParam TableName - A string literal type of the table name (like "users"). */ -export type Id = - GenericId; +export type Id = GenericId; /** * A type describing your Convex data model. @@ -56,4 +53,4 @@ export type Id = * This type is used to parameterize methods like `queryGeneric` and * `mutationGeneric` to make them type-safe. */ -export type DataModel = AnyDataModel; +export type DataModel = DataModelFromSchemaDefinition; diff --git a/convex/http.ts b/convex/http.ts new file mode 100644 index 0000000..cfd65c4 --- /dev/null +++ b/convex/http.ts @@ -0,0 +1,86 @@ +import type { WebhookEvent } from '@clerk/backend'; +import { httpRouter } from 'convex/server'; +import { Webhook } from 'svix'; +import { internal } from './_generated/api'; +import { httpAction } from './_generated/server'; + +async function validateRequest( + req: Request +): Promise { + const payloadString = await req.text(); + + const svixHeaders = { + 'svix-id': req.headers.get('svix-id')!, + 'svix-timestamp': req.headers.get('svix-timestamp')!, + 'svix-signature': req.headers.get('svix-signature')!, + }; + + const webhookSecret = process.env.CLERK_WEBHOOK_SECRET; + + if (webhookSecret === undefined) { + throw Error('CLERK_WEBHOOK_SECRET is undefined'); + } + + const wh = new Webhook(webhookSecret); + + let evt: Event | null = null; + + try { + evt = wh.verify(payloadString, svixHeaders) as Event; + } catch (error) { + console.log(error); + return; + } + + return evt as unknown as WebhookEvent; +} + +const handleClerkWebhook = httpAction(async (ctx, request) => { + const event = await validateRequest(request); + if (!event) { + return new Response('Error occurred', { + status: 400, + }); + } + switch (event.type) { + case 'user.created': + case 'user.updated': { + const existingUser = await ctx.runQuery(internal.users.getUser, { + subject: event.data.id, + }); + + if (existingUser && event.type === 'user.created') { + console.warn('Overwriting user', event.data.id, 'with', event.data); + } + + console.log('creating/updating user', event.data.id); + + await ctx.runMutation(internal.users.updateOrCreateUser, { + clerkUser: event.data, + }); + + break; + } + case 'user.deleted': { + const id = event.data.id!; + await ctx.runMutation(internal.users.deleteUser, { id }); + break; + } + default: { + console.log('ignored Clerk webhook event', event.type); + } + } + return new Response(null, { + status: 200, + }); +}); + +const http = httpRouter(); + +http.route({ + path: '/clerk-users-webhook', + method: 'POST', + handler: handleClerkWebhook, +}); + +export default http; diff --git a/convex/schema.ts b/convex/schema.ts new file mode 100644 index 0000000..4880818 --- /dev/null +++ b/convex/schema.ts @@ -0,0 +1,8 @@ +import { defineSchema, defineTable } from 'convex/server'; +import { v } from 'convex/values'; + +export default defineSchema({ + users: defineTable({ + clerkUser: v.any(), + }).index('by_clerk_id', ['clerkUser.id']), +}); diff --git a/convex/users.ts b/convex/users.ts new file mode 100644 index 0000000..308257a --- /dev/null +++ b/convex/users.ts @@ -0,0 +1,49 @@ +import { UserJSON } from '@clerk/nextjs/dist/types/server'; +import { v } from 'convex/values'; +import { Doc } from './_generated/dataModel'; +import { QueryCtx, internalMutation, internalQuery } from './_generated/server'; + +export const getUser = internalQuery({ + args: { subject: v.string() }, + async handler(ctx, args) { + return await userQuery(ctx, args.subject); + }, +}); + +export async function userQuery( + ctx: QueryCtx, + clerkUserId: string +): Promise<(Omit, 'clerkUser'> & { clerkUser: UserJSON }) | null> { + return await ctx.db + .query('users') + .withIndex('by_clerk_id', q => q.eq('clerkUser.id', clerkUserId)) + .unique(); +} + +export const updateOrCreateUser = internalMutation({ + args: { clerkUser: v.any() }, + async handler(ctx, { clerkUser }: { clerkUser: UserJSON }) { + const userRecord = await userQuery(ctx, clerkUser.id); + + if (userRecord === null) { + await ctx.db.insert('users', { clerkUser }); + return; + } + + await ctx.db.patch(userRecord._id, { clerkUser }); + }, +}); + +export const deleteUser = internalMutation({ + args: { id: v.string() }, + async handler(ctx, { id }) { + const userRecord = await userQuery(ctx, id); + + if (userRecord === null) { + console.warn("can't delete user, does not exist", id); + return; + } + + await ctx.db.delete(userRecord._id); + }, +}); diff --git a/package-lock.json b/package-lock.json index efa1e2c..4248fc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-icons": "^4.11.0", + "svix": "^1.11.0", "tailwindcss": "3.3.3", "typescript": "5.2.2" } @@ -871,6 +872,11 @@ "resolved": "https://registry.npmjs.org/@rushstack/eslint-patch/-/eslint-patch-1.3.3.tgz", "integrity": "sha512-0xd7qez0AQ+MbHatZTlI1gu5vkG8r7MYRUJAHPAHJBmGLs16zpkrpAVLvjQKQOqaXPDUBwOiJzNc00znHSCVBw==" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==" + }, "node_modules/@swc/helpers": { "version": "0.5.1", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.1.tgz", @@ -1979,6 +1985,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es6-promise": { + "version": "4.2.8", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", + "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==" + }, "node_modules/esbuild": { "version": "0.17.19", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.19.tgz", @@ -2468,6 +2479,11 @@ "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==" + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -4039,6 +4055,11 @@ "node": ">=6.0.0" } }, + "node_modules/querystringify": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", + "integrity": "sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4161,6 +4182,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/requires-port": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz", + "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==" + }, "node_modules/resolve": { "version": "1.22.4", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.4.tgz", @@ -4553,6 +4579,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svix": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/svix/-/svix-1.11.0.tgz", + "integrity": "sha512-H7sZ3xfZ0siFzCOZQDhk8HRYq3PqO2Lv8dS7R/JLelAJumNVu7W3sDE2cEwdtNMT2VHPPlgnBRQvD+oymhz+gw==", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "es6-promise": "^4.2.4", + "fast-sha256": "^1.3.0", + "svix-fetch": "^3.0.0", + "url-parse": "^1.4.3" + } + }, + "node_modules/svix-fetch": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/svix-fetch/-/svix-fetch-3.0.0.tgz", + "integrity": "sha512-rcADxEFhSqHbraZIsjyZNh4TF6V+koloX1OzZ+AQuObX9mZ2LIMhm1buZeuc5BIZPftZpJCMBsSiBaeszo9tRw==", + "dependencies": { + "node-fetch": "^2.6.1", + "whatwg-fetch": "^3.4.1" + } + }, "node_modules/swr": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/swr/-/swr-2.2.0.tgz", @@ -4847,6 +4894,15 @@ "punycode": "^2.1.0" } }, + "node_modules/url-parse": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/url-parse/-/url-parse-1.5.10.tgz", + "integrity": "sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ==", + "dependencies": { + "querystringify": "^2.1.1", + "requires-port": "^1.0.0" + } + }, "node_modules/use-sync-external-store": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", @@ -4889,6 +4945,11 @@ "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, + "node_modules/whatwg-fetch": { + "version": "3.6.18", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.18.tgz", + "integrity": "sha512-ltN7j66EneWn5TFDO4L9inYC1D+Czsxlrw2SalgjMmEMkLfA5SIZxEFdE6QtHFiiM6Q7WL32c7AkI3w6yxM84Q==" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index f36290a..9c953c0 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react": "18.2.0", "react-dom": "18.2.0", "react-icons": "^4.11.0", + "svix": "^1.11.0", "tailwindcss": "3.3.3", "typescript": "5.2.2" }