From 22eaafd160abe60aaa5637a282bc0545b1f876ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 16:57:52 +0000 Subject: [PATCH 1/7] chore: remove Convex backend and dependency Co-authored-by: Alhwyn --- .gitignore | 1 - bun.lock | 56 ----------- convex/_generated/api.d.ts | 55 ----------- convex/_generated/api.js | 23 ----- convex/_generated/dataModel.d.ts | 60 ------------ convex/_generated/server.d.ts | 143 --------------------------- convex/_generated/server.js | 93 ------------------ convex/credits.ts | 161 ------------------------------- convex/email.ts | 84 ---------------- convex/emailHelpers.ts | 72 -------------- convex/people.ts | 62 ------------ convex/schema.ts | 31 ------ package.json | 2 +- 13 files changed, 1 insertion(+), 842 deletions(-) delete mode 100644 convex/_generated/api.d.ts delete mode 100644 convex/_generated/api.js delete mode 100644 convex/_generated/dataModel.d.ts delete mode 100644 convex/_generated/server.d.ts delete mode 100644 convex/_generated/server.js delete mode 100644 convex/credits.ts delete mode 100644 convex/email.ts delete mode 100644 convex/emailHelpers.ts delete mode 100644 convex/people.ts delete mode 100644 convex/schema.ts diff --git a/.gitignore b/.gitignore index f0b3199..54b402e 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,3 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json .DS_Store cafe_cursor.csv credits.csv -# convex/_generated is now committed with stubs for CI diff --git a/bun.lock b/bun.lock index 7dcab6b..74f9a96 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 1, "workspaces": { "": { "name": "bun-react-template", @@ -8,7 +7,6 @@ "@react-email/components": "^1.0.1", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", - "convex": "^1.31.0", "figlet": "^1.9.4", "ink": "^6.5.1", "ink-select-input": "^6.2.0", @@ -34,56 +32,6 @@ "packages": { "@alcalzone/ansi-tokenize": ["@alcalzone/ansi-tokenize@0.2.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-mkOh+Wwawzuf5wa30bvc4nA+Qb6DIrGWgBhRR/Pw4T9nsgYait8izvXkNyU78D6Wcu3Z+KUdwCmLCxlWjEotYA=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.4", "", { "os": "android", "cpu": "arm" }, "sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.4", "", { "os": "android", "cpu": "arm64" }, "sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.4", "", { "os": "android", "cpu": "x64" }, "sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.4", "", { "os": "linux", "cpu": "arm" }, "sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.4", "", { "os": "linux", "cpu": "none" }, "sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.4", "", { "os": "linux", "cpu": "x64" }, "sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.4", "", { "os": "none", "cpu": "arm64" }, "sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.4", "", { "os": "none", "cpu": "x64" }, "sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.4", "", { "os": "win32", "cpu": "x64" }, "sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ=="], - "@oven/bun-darwin-aarch64": ["@oven/bun-darwin-aarch64@1.3.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Ie4jDGvNGuPSD+pyyBKL8dJmX+bZfDNYEalwgROImVtwB1XYAatJK20dMaRlPA7jOhjvS9Io+4IZAJu7Js0AA=="], "@oven/bun-darwin-x64": ["@oven/bun-darwin-x64@1.3.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-4/BJojT8hk5g6Gecjn5yI7y96/+9Mtzsvdp9+2dcy9sTMdlV7jBvDzswqyJPZyQqw0F3HV3Vu9XuMubZwKd9lA=="], @@ -194,8 +142,6 @@ "convert-to-spaces": ["convert-to-spaces@2.0.1", "", {}, "sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ=="], - "convex": ["convex@1.31.0", "", { "dependencies": { "esbuild": "0.25.4", "prettier": "^3.0.0" }, "peerDependencies": { "@auth0/auth0-react": "^2.0.1", "@clerk/clerk-react": "^4.12.8 || ^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0" }, "optionalPeers": ["@auth0/auth0-react", "@clerk/clerk-react", "react"], "bin": { "convex": "bin/main.js" } }, "sha512-ht3dtpWQmxX62T8PT3p/5PDlRzSW5p2IDTP4exKjQ5dqmvhtn1wLFakJAX4CCeu1s0Ch0dKY5g2dk/wETTRAOw=="], - "css-select": ["css-select@5.2.2", "", { "dependencies": { "boolbase": "^1.0.0", "css-what": "^6.1.0", "domhandler": "^5.0.2", "domutils": "^3.0.1", "nth-check": "^2.0.1" } }, "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw=="], "css-what": ["css-what@6.2.2", "", {}, "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA=="], @@ -222,8 +168,6 @@ "es6-promise": ["es6-promise@4.2.8", "", {}, "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w=="], - "esbuild": ["esbuild@0.25.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.4", "@esbuild/android-arm": "0.25.4", "@esbuild/android-arm64": "0.25.4", "@esbuild/android-x64": "0.25.4", "@esbuild/darwin-arm64": "0.25.4", "@esbuild/darwin-x64": "0.25.4", "@esbuild/freebsd-arm64": "0.25.4", "@esbuild/freebsd-x64": "0.25.4", "@esbuild/linux-arm": "0.25.4", "@esbuild/linux-arm64": "0.25.4", "@esbuild/linux-ia32": "0.25.4", "@esbuild/linux-loong64": "0.25.4", "@esbuild/linux-mips64el": "0.25.4", "@esbuild/linux-ppc64": "0.25.4", "@esbuild/linux-riscv64": "0.25.4", "@esbuild/linux-s390x": "0.25.4", "@esbuild/linux-x64": "0.25.4", "@esbuild/netbsd-arm64": "0.25.4", "@esbuild/netbsd-x64": "0.25.4", "@esbuild/openbsd-arm64": "0.25.4", "@esbuild/openbsd-x64": "0.25.4", "@esbuild/sunos-x64": "0.25.4", "@esbuild/win32-arm64": "0.25.4", "@esbuild/win32-ia32": "0.25.4", "@esbuild/win32-x64": "0.25.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q=="], - "escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts deleted file mode 100644 index abe4afe..0000000 --- a/convex/_generated/api.d.ts +++ /dev/null @@ -1,55 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type * as credits from "../credits.js"; -import type * as email from "../email.js"; -import type * as emailHelpers from "../emailHelpers.js"; -import type * as people from "../people.js"; - -import type { - ApiFromModules, - FilterApi, - FunctionReference, -} from "convex/server"; - -declare const fullApi: ApiFromModules<{ - credits: typeof credits; - email: typeof email; - emailHelpers: typeof emailHelpers; - people: typeof people; -}>; - -/** - * A utility for referencing Convex functions in your app's public API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export declare const api: FilterApi< - typeof fullApi, - FunctionReference ->; - -/** - * A utility for referencing Convex functions in your app's internal API. - * - * Usage: - * ```js - * const myFunctionReference = internal.myModule.myFunction; - * ``` - */ -export declare const internal: FilterApi< - typeof fullApi, - FunctionReference ->; - -export declare const components: {}; diff --git a/convex/_generated/api.js b/convex/_generated/api.js deleted file mode 100644 index 44bf985..0000000 --- a/convex/_generated/api.js +++ /dev/null @@ -1,23 +0,0 @@ -/* eslint-disable */ -/** - * Generated `api` utility. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { anyApi, componentsGeneric } from "convex/server"; - -/** - * A utility for referencing Convex functions in your app's API. - * - * Usage: - * ```js - * const myFunctionReference = api.myModule.myFunction; - * ``` - */ -export const api = anyApi; -export const internal = anyApi; -export const components = componentsGeneric(); diff --git a/convex/_generated/dataModel.d.ts b/convex/_generated/dataModel.d.ts deleted file mode 100644 index f97fd19..0000000 --- a/convex/_generated/dataModel.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable */ -/** - * Generated data model types. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import type { - DataModelFromSchemaDefinition, - DocumentByName, - TableNamesInDataModel, - SystemTableNames, -} from "convex/server"; -import type { GenericId } from "convex/values"; -import schema from "../schema.js"; - -/** - * The names of all of your Convex tables. - */ -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 = DocumentByName< - DataModel, - TableName ->; - -/** - * An identifier for a document in Convex. - * - * Convex documents are uniquely identified by their `Id`, which is accessible - * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). - * - * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. - * - * 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; - -/** - * A type describing your Convex data model. - * - * This type includes information about what tables you have, the type of - * documents stored in those tables, and the indexes defined on them. - * - * This type is used to parameterize methods like `queryGeneric` and - * `mutationGeneric` to make them type-safe. - */ -export type DataModel = DataModelFromSchemaDefinition; diff --git a/convex/_generated/server.d.ts b/convex/_generated/server.d.ts deleted file mode 100644 index bec05e6..0000000 --- a/convex/_generated/server.d.ts +++ /dev/null @@ -1,143 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - ActionBuilder, - HttpActionBuilder, - MutationBuilder, - QueryBuilder, - GenericActionCtx, - GenericMutationCtx, - GenericQueryCtx, - GenericDatabaseReader, - GenericDatabaseWriter, -} from "convex/server"; -import type { DataModel } from "./dataModel.js"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export declare const query: QueryBuilder; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export declare const internalQuery: QueryBuilder; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export declare const mutation: MutationBuilder; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export declare const internalMutation: MutationBuilder; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @param func - The action. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped action. Include this as an `export` to name it and make it accessible. - */ -export declare const action: ActionBuilder; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Include this as an `export` to name it and make it accessible. - */ -export declare const internalAction: ActionBuilder; - -/** - * Define an HTTP action. - * - * The wrapped function will be used to respond to HTTP requests received - * by a Convex deployment if the requests matches the path and method where - * this action is routed. Be sure to route your httpAction in `convex/http.js`. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument - * and a Fetch API `Request` object as its second. - * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. - */ -export declare const httpAction: HttpActionBuilder; - -/** - * A set of services for use within Convex query functions. - * - * The query context is passed as the first argument to any Convex query - * function run on the server. - * - * This differs from the {@link MutationCtx} because all of the services are - * read-only. - */ -export type QueryCtx = GenericQueryCtx; - -/** - * A set of services for use within Convex mutation functions. - * - * The mutation context is passed as the first argument to any Convex mutation - * function run on the server. - */ -export type MutationCtx = GenericMutationCtx; - -/** - * A set of services for use within Convex action functions. - * - * The action context is passed as the first argument to any Convex action - * function run on the server. - */ -export type ActionCtx = GenericActionCtx; - -/** - * An interface to read from the database within Convex query functions. - * - * The two entry points are {@link DatabaseReader.get}, which fetches a single - * document by its {@link Id}, or {@link DatabaseReader.query}, which starts - * building a query. - */ -export type DatabaseReader = GenericDatabaseReader; - -/** - * An interface to read from and write to the database within Convex mutation - * functions. - * - * Convex guarantees that all writes within a single mutation are - * executed atomically, so you never have to worry about partial writes leaving - * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) - * for the guarantees Convex provides your functions. - */ -export type DatabaseWriter = GenericDatabaseWriter; diff --git a/convex/_generated/server.js b/convex/_generated/server.js deleted file mode 100644 index bf3d25a..0000000 --- a/convex/_generated/server.js +++ /dev/null @@ -1,93 +0,0 @@ -/* eslint-disable */ -/** - * Generated utilities for implementing server-side Convex query and mutation functions. - * - * THIS CODE IS AUTOMATICALLY GENERATED. - * - * To regenerate, run `npx convex dev`. - * @module - */ - -import { - actionGeneric, - httpActionGeneric, - queryGeneric, - mutationGeneric, - internalActionGeneric, - internalMutationGeneric, - internalQueryGeneric, -} from "convex/server"; - -/** - * Define a query in this Convex app's public API. - * - * This function will be allowed to read your Convex database and will be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const query = queryGeneric; - -/** - * Define a query that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to read from your Convex database. It will not be accessible from the client. - * - * @param func - The query function. It receives a {@link QueryCtx} as its first argument. - * @returns The wrapped query. Include this as an `export` to name it and make it accessible. - */ -export const internalQuery = internalQueryGeneric; - -/** - * Define a mutation in this Convex app's public API. - * - * This function will be allowed to modify your Convex database and will be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const mutation = mutationGeneric; - -/** - * Define a mutation that is only accessible from other Convex functions (but not from the client). - * - * This function will be allowed to modify your Convex database. It will not be accessible from the client. - * - * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. - * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. - */ -export const internalMutation = internalMutationGeneric; - -/** - * Define an action in this Convex app's public API. - * - * An action is a function which can execute any JavaScript code, including non-deterministic - * code and code with side-effects, like calling third-party services. - * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. - * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. - * - * @param func - The action. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped action. Include this as an `export` to name it and make it accessible. - */ -export const action = actionGeneric; - -/** - * Define an action that is only accessible from other Convex functions (but not from the client). - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument. - * @returns The wrapped function. Include this as an `export` to name it and make it accessible. - */ -export const internalAction = internalActionGeneric; - -/** - * Define an HTTP action. - * - * The wrapped function will be used to respond to HTTP requests received - * by a Convex deployment if the requests matches the path and method where - * this action is routed. Be sure to route your httpAction in `convex/http.js`. - * - * @param func - The function. It receives an {@link ActionCtx} as its first argument - * and a Fetch API `Request` object as its second. - * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. - */ -export const httpAction = httpActionGeneric; diff --git a/convex/credits.ts b/convex/credits.ts deleted file mode 100644 index ac470f5..0000000 --- a/convex/credits.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { query, mutation } from "./_generated/server"; -import { v } from "convex/values"; - -// Get all available credits -export const listAvailable = query({ - handler: async (ctx) => { - return await ctx.db - .query("credits") - .withIndex("by_status", (q) => q.eq("status", "available")) - .collect(); - }, -}); - -// Get total available credits -export const totalAvailable = query({ - handler: async (ctx) => { - const credits = await ctx.db - .query("credits") - .withIndex("by_status", (q) => q.eq("status", "available")) - .collect(); - return credits.reduce((sum, c) => sum + c.amount, 0); - }, -}); - -// Tally all credits with breakdown by status -export const tallyAll = query({ - handler: async (ctx) => { - const allCredits = await ctx.db.query("credits").collect(); - - const tally = { - total: 0, - available: 0, - assigned: 0, - sent: 0, - redeemed: 0, - count: { - total: allCredits.length, - available: 0, - assigned: 0, - sent: 0, - redeemed: 0, - }, - }; - - for (const credit of allCredits) { - tally.total += credit.amount; - if (credit.status === "available") { - tally.available += credit.amount; - tally.count.available++; - } else if (credit.status === "assigned") { - tally.assigned += credit.amount; - tally.count.assigned++; - } else if (credit.status === "sent") { - tally.sent += credit.amount; - tally.count.sent++; - } else if (credit.status === "redeemed") { - tally.redeemed += credit.amount; - tally.count.redeemed++; - } - } - - return tally; - }, -}); - -export const add = mutation({ - args: { - url: v.string(), - code: v.string(), - amount: v.number(), - }, - handler: async (ctx, args) => { - return await ctx.db.insert("credits", { - ...args, - status: "available", - checkedAt: Date.now(), - }); - }, -}); - -export const addIfNotExists = mutation({ - args: { - url: v.string(), - code: v.string(), - amount: v.number(), - }, - handler: async (ctx, args) => { - const existing = await ctx.db - .query("credits") - .withIndex("by_code", (q) => q.eq("code", args.code)) - .first(); - - if (existing) { - return { added: false, existing: true }; - } - - const id = await ctx.db.insert("credits", { - ...args, - status: "available", - checkedAt: Date.now(), - }); - - return { added: true, id }; - }, -}); - -// Assign credit to person -export const assign = mutation({ - args: { - creditId: v.id("credits"), - personId: v.id("people"), - }, - handler: async (ctx, args) => { - await ctx.db.patch(args.creditId, { - assignedTo: args.personId, - status: "assigned", - }); - }, -}); - -// Mark as sent -export const markSent = mutation({ - args: { creditId: v.id("credits") }, - handler: async (ctx, args) => { - await ctx.db.patch(args.creditId, { - status: "sent", - sentAt: Date.now(), - }); - }, -}); - -// List all sent credits with person info (for redemption checking) -export const listSentWithPerson = query({ - handler: async (ctx) => { - const credits = await ctx.db - .query("credits") - .withIndex("by_status", (q) => q.eq("status", "sent")) - .collect(); - - // Join with people to get names - return Promise.all( - credits.map(async (credit) => { - const person = credit.assignedTo - ? await ctx.db.get(credit.assignedTo) - : null; - return { ...credit, person }; - }) - ); - }, -}); - -// Mark credit as redeemed -export const markRedeemed = mutation({ - args: { creditId: v.id("credits") }, - handler: async (ctx, args) => { - await ctx.db.patch(args.creditId, { - status: "redeemed", - checkedAt: Date.now(), - }); - }, -}); \ No newline at end of file diff --git a/convex/email.ts b/convex/email.ts deleted file mode 100644 index 6889ff0..0000000 --- a/convex/email.ts +++ /dev/null @@ -1,84 +0,0 @@ -"use node"; - -import { action } from "./_generated/server"; -import { v } from "convex/values"; -import { Resend } from "resend"; -import { internal } from "./_generated/api"; -import { render } from "@react-email/render"; -import { CreditEmail } from "../src/emails/CreditEmail"; - -// Main action to send credit email -export const sendCreditEmail = action({ - args: { personId: v.id("people") }, - handler: async (ctx, args): Promise<{ success: boolean; error?: string }> => { - // 1. Get person and available credit - const result = await ctx.runMutation(internal.emailHelpers.getPersonAndAvailableCredit, { - personId: args.personId, - }); - - if ("error" in result) { - return { success: false, error: result.error }; - } - - const { person, credit } = result; - - // 2. Assign credit to person - await ctx.runMutation(internal.emailHelpers.assignCreditToPerson, { - creditId: credit._id, - personId: args.personId, - }); - - // 3. Render email template - const emailHtml = await render( - CreditEmail({ - firstName: person.firstName, - creditUrl: credit.url, - code: credit.code, - amount: credit.amount, - }) - ); - - // 4. Send email via Resend - const apiKey = process.env.RESEND_API_KEY; - if (!apiKey) { - // Revert credit back to available - await ctx.runMutation(internal.emailHelpers.revertCreditToAvailable, { - creditId: credit._id, - }); - return { success: false, error: "RESEND_API_KEY not configured" }; - } - - const fromEmail = process.env.RESEND_FROM_EMAIL; - if (!fromEmail) { - // Revert credit back to available - await ctx.runMutation(internal.emailHelpers.revertCreditToAvailable, { - creditId: credit._id, - }); - return { success: false, error: "RESEND_FROM_EMAIL not configured" }; - } - - const resend = new Resend(apiKey); - const { error } = await resend.emails.send({ - from: fromEmail, - to: person.email, - subject: `Your Cursor Credits - $${credit.amount}`, - html: emailHtml, - }); - - if (error) { - // Revert credit back to available on send failure - await ctx.runMutation(internal.emailHelpers.revertCreditToAvailable, { - creditId: credit._id, - }); - return { success: false, error: error.message }; - } - - // 5. Mark credit and person as sent - await ctx.runMutation(internal.emailHelpers.markAsSent, { - creditId: credit._id, - personId: args.personId, - }); - - return { success: true }; - }, -}); diff --git a/convex/emailHelpers.ts b/convex/emailHelpers.ts deleted file mode 100644 index a024275..0000000 --- a/convex/emailHelpers.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { internalMutation } from "./_generated/server"; -import { v } from "convex/values"; - -// Internal mutation to get person and available credit -export const getPersonAndAvailableCredit = internalMutation({ - args: { personId: v.id("people") }, - handler: async (ctx, args) => { - const person = await ctx.db.get(args.personId); - if (!person) { - return { error: "Person not found" }; - } - - // Get an available credit - const credit = await ctx.db - .query("credits") - .withIndex("by_status", (q) => q.eq("status", "available")) - .first(); - - if (!credit) { - return { error: "No available credits" }; - } - - return { person, credit }; - }, -}); - -// Internal mutation to assign credit to person -export const assignCreditToPerson = internalMutation({ - args: { - creditId: v.id("credits"), - personId: v.id("people"), - }, - handler: async (ctx, args) => { - await ctx.db.patch(args.creditId, { - assignedTo: args.personId, - status: "assigned", - }); - }, -}); - -// Internal mutation to mark everything as sent -export const markAsSent = internalMutation({ - args: { - creditId: v.id("credits"), - personId: v.id("people"), - }, - handler: async (ctx, args) => { - // Mark credit as sent - await ctx.db.patch(args.creditId, { - status: "sent", - sentAt: Date.now(), - }); - - // Mark person as having received credits - await ctx.db.patch(args.personId, { - sentCredits: true, - }); - }, -}); - -// Internal mutation to revert credit back to available (on send failure) -export const revertCreditToAvailable = internalMutation({ - args: { - creditId: v.id("credits"), - }, - handler: async (ctx, args) => { - await ctx.db.patch(args.creditId, { - status: "available", - assignedTo: undefined, - }); - }, -}); diff --git a/convex/people.ts b/convex/people.ts deleted file mode 100644 index 611721e..0000000 --- a/convex/people.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { query, mutation } from "./_generated/server"; -import { v } from "convex/values"; - -// Get all people -export const list = query({ - handler: async (ctx) => { - return await ctx.db.query("people").collect(); - }, -}); - -// Get count of people -export const count = query({ - handler: async (ctx) => { - const people = await ctx.db.query("people").collect(); - return people.length; - }, -}); - -// Add a person -export const add = mutation({ - args: { - firstName: v.string(), - lastName: v.string(), - email: v.string(), - linkedin: v.optional(v.string()), - twitter: v.optional(v.string()), - drink: v.optional(v.string()), - food: v.optional(v.string()), - workingOn: v.optional(v.string()), - }, - handler: async (ctx, args) => { - // Check for duplicate by email - const existing = await ctx.db - .query("people") - .withIndex("by_email", (q) => q.eq("email", args.email)) - .first(); - if (existing) { - return { skipped: true, id: existing._id }; - } - const id = await ctx.db.insert("people", { - ...args, - sentCredits: false, - }); - return { skipped: false, id }; - }, -}); - -// Mark person as sent credits -export const markSent = mutation({ - args: { id: v.id("people") }, - handler: async (ctx, args) => { - await ctx.db.patch(args.id, { sentCredits: true }); - }, -}); - -// Delete a person -export const remove = mutation({ - args: { id: v.id("people") }, - handler: async (ctx, args) => { - await ctx.db.delete(args.id); - }, -}); \ No newline at end of file diff --git a/convex/schema.ts b/convex/schema.ts deleted file mode 100644 index 7809673..0000000 --- a/convex/schema.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { defineSchema, defineTable } from "convex/server"; -import { v } from "convex/values"; - -export default defineSchema({ - // People/Attendees table - people: defineTable({ - firstName: v.string(), - lastName: v.string(), - email: v.string(), - linkedin: v.optional(v.string()), - twitter: v.optional(v.string()), - drink: v.optional(v.string()), - food: v.optional(v.string()), - workingOn: v.optional(v.string()), - sentCredits: v.boolean(), - }).index("by_email", ["email"]), - - // Credits table - credits: defineTable({ - url: v.string(), - code: v.string(), - amount: v.number(), - status: v.string(), // "available", "assigned", "sent", "redeemed" - assignedTo: v.optional(v.id("people")), - checkedAt: v.number(), // timestamp - sentAt: v.optional(v.number()), - }) - .index("by_status", ["status"]) - .index("by_code", ["code"]) - .index("by_assignedTo", ["assignedTo"]), -}); \ No newline at end of file diff --git a/package.json b/package.json index c826bbc..ab41118 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "cli": "bun src/cli.tsx", + "luma:webhook": "bun src/lumaWebhookServer.ts", "dev": "bun --hot src/index.ts", "start": "NODE_ENV=production bun src/index.ts", "build": "bun run build.ts", @@ -16,7 +17,6 @@ "@react-email/components": "^1.0.1", "bun-plugin-tailwind": "^0.1.2", "chalk": "^5.6.2", - "convex": "^1.31.0", "figlet": "^1.9.4", "ink": "^6.5.1", "ink-select-input": "^6.2.0", From 21de941dbe748d8420474dc44c749e92eb95dbdd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 16:58:01 +0000 Subject: [PATCH 2/7] feat: add Luma check-in webhook and CSV-only storage - Drop cloud/local mode selector; use CAFE_DATA_PATH for CSV data directory - Share Resend send logic between CLI and automation - Add guest.updated webhook server with signature verification and check-in API check - Dedupe Luma deliveries with cafe_luma_sent_guests.txt - Document setup and add webhook signature tests Co-authored-by: Alhwyn --- README.md | 119 +++++------- src/cli.tsx | 108 +++-------- src/components/ModeSelector.tsx | 45 ----- src/context/StorageContext.tsx | 23 +-- src/hooks/useStorageHooks.ts | 248 +++++++++----------------- src/luma/lumaClient.ts | 45 +++++ src/luma/verifyWebhookSignature.ts | 57 ++++++ src/lumaWebhookServer.ts | 212 ++++++++++++++++++++++ src/screens/CheckRedemptions.tsx | 12 +- src/screens/ProfileConfirm.tsx | 42 +++-- src/screens/SendCredits.tsx | 26 ++- src/screens/UploadAttendees.tsx | 27 ++- src/screens/UploadCredits.tsx | 27 ++- src/services/sendCursorCreditEmail.ts | 82 +++++++++ src/utils/localStorage.ts | 38 ++++ test/lumaWebhookSignature.test.ts | 39 ++++ 16 files changed, 702 insertions(+), 448 deletions(-) delete mode 100644 src/components/ModeSelector.tsx create mode 100644 src/luma/lumaClient.ts create mode 100644 src/luma/verifyWebhookSignature.ts create mode 100644 src/lumaWebhookServer.ts create mode 100644 src/services/sendCursorCreditEmail.ts create mode 100644 test/lumaWebhookSignature.test.ts diff --git a/README.md b/README.md index 9c57ad4..3d4f71a 100644 --- a/README.md +++ b/README.md @@ -10,21 +10,21 @@ # Cafe Cursor CLI [![CI](https://github.com/Alhwyn/cafe-cursor-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Alhwyn/cafe-cursor-cli/actions/workflows/ci.yml) -A CLI tool for managing and sending Cursor credits to event attendees via email. +A CLI tool for managing and sending Cursor credits to event attendees. Data lives in CSV files on disk. Optional [Resend](https://resend.com) sends the credit email; optional [Luma](https://docs.luma.com/reference/getting-started-with-your-api) webhooks automate sending when a guest checks in. ## Features - Upload and manage attendee lists from CSV - Upload and track Cursor credit codes -- Send personalized emails with credit codes using Resend +- Send personalized emails with credit codes using Resend (when configured) - Track credit status (available, assigned, sent, redeemed) -- **Local Mode**: Run without a database using CSV files for storage +- **Luma check-in automation**: webhook server verifies check-in via the Luma API and sends a credit email ## Prerequisites - [Bun](https://bun.sh) (v1.0 or later) -- [Convex](https://convex.dev) account (optional, only for Cloud Mode) -- [Resend](https://resend.com) account with verified domain (optional, only for Cloud Mode) +- [Resend](https://resend.com) account with verified domain (optional, for email delivery) +- [Luma Plus](https://luma.com/pricing) and a Luma API key (optional, for webhook automation) ## Setup @@ -47,72 +47,54 @@ bun install bun run cli ``` -That's it for **Local Mode**! The CLI will use CSV files for storage and no external services are required. +By default, attendee and credit data is stored as `cafe_people.csv` and `cafe_credits.csv` in the current working directory. Set `CAFE_DATA_PATH` to use a fixed directory (recommended for the Luma webhook server). ---- +### Environment variables -### Optional: Set up Convex (Cloud Mode) +| Variable | Purpose | +|----------|---------| +| `CAFE_DATA_PATH` | Directory for `cafe_people.csv`, `cafe_credits.csv`, and `cafe_luma_sent_guests.txt` | +| `RESEND_API_KEY` | Send credit emails via Resend | +| `RESEND_FROM_EMAIL` | Verified sender address for Resend | -If you want to use Cloud Mode with database sync and email sending, follow these additional steps: +If Resend is not configured, the CLI and webhook still assign credits in CSV only (no outbound email). -#### 3a. Set up Convex +## Luma check-in webhook -```bash -# Login to Convex -bunx convex login +When a guest checks in on Luma, Luma can call your server with a `guest.updated` webhook. This repo includes a small HTTP server that: -# Initialize Convex (creates a new project) -bunx convex dev -``` +1. Verifies the [Luma webhook signature](https://help.lu.ma/p/webhooks) +2. Confirms the guest is checked in using `GET /v1/event/get-guest` ([Luma API](https://docs.luma.com/reference/getting-started-with-your-api)) +3. Sends one Cursor credit email per event + guest (deduped via `cafe_luma_sent_guests.txt`) -This will create a new Convex project and start syncing your schema. +### Webhook server env -#### 3b. Configure environment variables +| Variable | Required | Description | +|----------|----------|-------------| +| `LUMA_API_KEY` | Yes | `x-luma-api-key` for API calls | +| `LUMA_WEBHOOK_SECRET` | Yes | `whsec_...` secret from Luma webhook settings | +| `LUMA_DATA_PATH` | No | Same role as `CAFE_DATA_PATH` (defaults to cwd) | +| `LUMA_WEBHOOK_PORT` | No | Listen port (default `3847`) | +| `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | For email | Same as CLI | -Create a `.env` file in the root directory: +### Run the webhook server -```env -CONVEX_URL=https://your-deployment.convex.cloud -RESEND_API_KEY=re_xxxxxxxxxxxxx -RESEND_FROM_EMAIL=credits@yourdomain.com +```bash +bun run luma:webhook ``` -You can find your `CONVEX_URL` in the Convex dashboard after running `bunx convex dev`. - -#### 3c. Set up Convex environment variables - -Go to your [Convex Dashboard](https://dashboard.convex.dev): -1. Select your project -2. Go to **Settings** > **Environment Variables** -3. Add: - - `RESEND_API_KEY` - Your Resend API key - - `RESEND_FROM_EMAIL` - Your verified sender email +Expose `http://your-host:/luma/webhook` publicly (for example via a tunnel), then in Luma: **Settings → Developer → Webhooks**, create a webhook with event type **Guest Updated** pointing at that URL. ## Usage -### Storage Modes - -When you start the CLI, you'll be prompted to select a storage mode: - -1. **Cloud Mode (Convex Database)** - - Uses Convex database for storage - - Requires environment variables (CONVEX_URL, RESEND_API_KEY, RESEND_FROM_EMAIL) - - Data synced across devices - - Sends actual emails via Resend +### Main menu -2. **Local Mode (CSV Files)** - - Uses CSV files in the current directory for storage - - No database setup required - - Files created: `cafe_people.csv`, `cafe_credits.csv` - - Credits are assigned but no emails are sent - -### Main Menu Options - -1. **Send Cursor Credits** - Browse attendees and send credits via email +1. **Send Cursor Credits** - Browse attendees and send or assign credits 2. **Upload Cursor Credits** - Import credit codes from a CSV file 3. **Upload Attendees** - Import attendees from a CSV file +4. **Check Credit Redemptions** - Check sent codes against Cursor -### CSV Formats +### CSV formats #### Attendees CSV @@ -144,29 +126,20 @@ bun test bun run build ``` -### Start Convex dev server (optional, for Cloud Mode) - -```bash -bunx convex dev -``` - -## Project Structure +## Project structure ``` cafe-cursor-cli/ -├── convex/ # Convex backend (optional, for Cloud Mode) -│ ├── schema.ts # Database schema -│ ├── credits.ts # Credit mutations/queries -│ ├── people.ts # People mutations/queries -│ ├── email.ts # Email sending action -│ └── emailHelpers.ts # Email helper mutations ├── src/ -│ ├── cli.tsx # Main CLI entry point -│ ├── screens/ # CLI screens -│ ├── components/ # Reusable components (includes ModeSelector) -│ ├── context/ # React contexts (StorageContext for mode) -│ ├── hooks/ # Custom hooks (storage abstraction) -│ ├── emails/ # Email templates -│ └── utils/ # Utility functions (includes localStorage) -└── test/ # Test files +│ ├── cli.tsx # Main CLI entry point +│ ├── lumaWebhookServer.ts # Luma guest.updated webhook HTTP server +│ ├── luma/ # Luma API client and signature verification +│ ├── services/ # Shared send-credit logic (CLI + webhook) +│ ├── screens/ # CLI screens +│ ├── components/ # Reusable components +│ ├── context/ # Storage path context +│ ├── hooks/ # CSV-backed data hooks +│ ├── emails/ # Email templates +│ └── utils/ # CSV storage and helpers +└── test/ # Tests ``` diff --git a/src/cli.tsx b/src/cli.tsx index a5979c1..d754cce 100755 --- a/src/cli.tsx +++ b/src/cli.tsx @@ -3,23 +3,11 @@ import React, { useState, useEffect } from "react"; import { render, Text, Box, useInput, useApp } from "ink"; import figlet from "figlet"; import SelectInput from "ink-select-input"; -import { ConvexProvider, ConvexReactClient } from "convex/react"; import SendCredits from "./screens/SendCredits.js"; import UploadCredits from "./screens/UploadCredits.js"; import UploadAttendees from "./screens/UploadAttendees.js"; import CheckRedemptions from "./screens/CheckRedemptions.js"; -import ModeSelector from "./components/ModeSelector.js"; -import { StorageProvider, useStorage, type StorageMode } from "./context/StorageContext.js"; - -// Check if cloud mode can be used (has required env vars) -const hasCloudConfig = Boolean( - process.env.CONVEX_URL && - process.env.RESEND_API_KEY && - process.env.RESEND_FROM_EMAIL -); - -// Only create Convex client if we have the URL -const convex = new ConvexReactClient(process.env.CONVEX_URL!); +import { StorageProvider, useStorage } from "./context/StorageContext.js"; type Screen = "menu" | "send" | "upload" | "attendees" | "check"; @@ -32,14 +20,12 @@ const exitApp = () => { process.exit(0); }; -// Handle process exit signals process.on("SIGINT", exitApp); process.on("SIGTERM", exitApp); -// Main menu component (used after mode is selected) const MainMenu = () => { const { exit } = useApp(); - const { isLocal, setMode } = useStorage(); + const { dataPath } = useStorage(); const [banner, setBanner] = useState(""); const [screen, setScreen] = useState("menu"); const [highlighted, setHighlighted] = useState("send"); @@ -51,10 +37,9 @@ const MainMenu = () => { }); }, []); - // Handle keyboard shortcuts from main menu useInput((input, key) => { if (screen !== "menu") return; - + if (input === "q" || key.escape) { clearScreen(); exit(); @@ -90,105 +75,64 @@ const MainMenu = () => { setScreen("menu"); }; - // Render Send Credits screen if (screen === "send") { return ; } - // Render Upload screen if (screen === "upload") { return ; } - // Render Upload Attendees screen if (screen === "attendees") { return ; } - // Render Check Redemptions screen if (screen === "check") { return ; } - // Render main menu return ( {banner} - - {/* Mode indicator banner */} - - - {isLocal ? "[LOCAL MODE]" : "[CLOUD MODE]"} - - - {isLocal - ? " - Using CSV files in current directory" - : " - Using Convex database" - } - + + + [LOCAL DATA] + {dataPath} Select an option: - setHighlighted(item.value)} /> - 1 Send - 2 Credits - 3 Attendees - 4 Check - Q Quit + + 1 Send + + + 2 Credits + + + 3 Attendees + + + 4 Check + + + Q Quit + ); }; -// Root app component that handles mode selection -const App = () => { - const { mode, setMode } = useStorage(); - const [modeSelected, setModeSelected] = useState(false); - - const handleModeSelect = (selectedMode: StorageMode) => { - // Validate cloud mode has required env vars - if (selectedMode === "cloud" && !hasCloudConfig) { - // Can't use cloud mode without config - show error - console.error("\x1b[31mError: Cloud mode requires environment variables:\x1b[0m"); - console.error(" CONVEX_URL, RESEND_API_KEY, RESEND_FROM_EMAIL"); - console.error("\nUsing local mode instead...\n"); - selectedMode = "local"; - } - - setMode(selectedMode); - clearScreen(); - setModeSelected(true); - }; - - // Show mode selector first - if (!modeSelected) { - return ; - } - - // Always provide Convex client (even if dummy) so hooks don't crash in local mode - return ( - - - - ); -}; - render( - + ); diff --git a/src/components/ModeSelector.tsx b/src/components/ModeSelector.tsx deleted file mode 100644 index 1b6df32..0000000 --- a/src/components/ModeSelector.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import React from "react"; -import { Box, Text, useInput } from "ink"; -import SelectInput from "ink-select-input"; -import type { StorageMode } from "../context/StorageContext.js"; - -interface ModeSelectorProps { - onSelect: (mode: StorageMode) => void; -} - -const ModeSelector = ({ onSelect }: ModeSelectorProps) => { - - const items = [ - { - label: "1. Cloud Mode (Convex Database)", - value: "cloud" as StorageMode, - }, - { - label: "2. Local Mode (CSV Files)", - value: "local" as StorageMode, - }, - ]; - - useInput((input) => { - if (input === "1") { - onSelect("cloud"); - } else if (input === "2") { - onSelect("local"); - } - }); - - const handleSelect = (item: { label: string; value: StorageMode }) => { - onSelect(item.value); - }; - - return ( - - Select Storage Mode: - - - - - ); -}; - -export default ModeSelector; diff --git a/src/context/StorageContext.tsx b/src/context/StorageContext.tsx index b9a22b5..2b8a7cd 100644 --- a/src/context/StorageContext.tsx +++ b/src/context/StorageContext.tsx @@ -1,11 +1,6 @@ -import React, { createContext, useContext, useState, type ReactNode } from "react"; - -export type StorageMode = "local" | "cloud"; +import React, { createContext, useContext, type ReactNode } from "react"; interface StorageContextType { - mode: StorageMode; - setMode: (mode: StorageMode) => void; - isLocal: boolean; dataPath: string; } @@ -16,18 +11,14 @@ interface StorageProviderProps { } export const StorageProvider = ({ children }: StorageProviderProps) => { - const [mode, setMode] = useState("cloud"); - - // Default local data path - can be customized - const dataPath = process.cwd(); + const dataPath = process.env.CAFE_DATA_PATH || process.cwd(); return ( - + {children} ); diff --git a/src/hooks/useStorageHooks.ts b/src/hooks/useStorageHooks.ts index 25ac236..e34bbb8 100644 --- a/src/hooks/useStorageHooks.ts +++ b/src/hooks/useStorageHooks.ts @@ -1,11 +1,11 @@ import { useState, useEffect, useCallback } from "react"; -import { useQuery as useConvexQuery, useMutation as useConvexMutation, useAction as useConvexAction } from "convex/react"; -import { api } from "../../convex/_generated/api"; -import type { Id } from "../../convex/_generated/dataModel"; import { useStorage } from "../context/StorageContext.js"; import * as localStorage from "../utils/localStorage.js"; +import { + sendCursorCreditToGuest, + type CreditDelivery, +} from "../services/sendCursorCreditEmail.js"; -// Type for people list export interface Person { id: string; firstName: string; @@ -19,7 +19,6 @@ export interface Person { sentCredits: boolean; } -// Type for credit tally export interface CreditTally { total: number; available: number; @@ -30,20 +29,15 @@ export interface CreditTally { }; } -// Hook for listing people export function usePeopleList(): { data: Person[] | undefined; refresh: () => void } { - const { isLocal, dataPath } = useStorage(); + const { dataPath } = useStorage(); const [localData, setLocalData] = useState(undefined); const [refreshKey, setRefreshKey] = useState(0); - // Cloud query - use "skip" when in local mode - const cloudData = useConvexQuery(api.people.list, isLocal ? "skip" : {}); - - // Load local data useEffect(() => { - if (isLocal) { - const people = localStorage.loadPeople(dataPath); - setLocalData(people.map(p => ({ + const people = localStorage.loadPeople(dataPath); + setLocalData( + people.map((p) => ({ id: p.id, firstName: p.firstName, lastName: p.lastName, @@ -54,140 +48,89 @@ export function usePeopleList(): { data: Person[] | undefined; refresh: () => vo food: p.food, workingOn: p.workingOn, sentCredits: p.sentCredits, - }))); - } - }, [isLocal, dataPath, refreshKey]); + })) + ); + }, [dataPath, refreshKey]); const refresh = useCallback(() => { - setRefreshKey(k => k + 1); + setRefreshKey((k) => k + 1); }, []); - if (isLocal) { - return { data: localData, refresh }; - } - - const data = cloudData?.map(p => ({ - id: p._id, - firstName: p.firstName, - lastName: p.lastName, - email: p.email, - linkedin: p.linkedin, - twitter: p.twitter, - drink: p.drink, - food: p.food, - workingOn: p.workingOn, - sentCredits: p.sentCredits, - })); - - return { data, refresh }; + return { data: localData, refresh }; } -// Hook for credit tally export function useCreditTally(): { data: CreditTally | undefined; refresh: () => void } { - const { isLocal, dataPath } = useStorage(); + const { dataPath } = useStorage(); const [localData, setLocalData] = useState(undefined); const [refreshKey, setRefreshKey] = useState(0); - // Cloud query - use "skip" when in local mode - const cloudData = useConvexQuery(api.credits.tallyAll, isLocal ? "skip" : {}); - - // Load local data useEffect(() => { - if (isLocal) { - const tally = localStorage.tallyCredits(dataPath); - setLocalData(tally); - } - }, [isLocal, dataPath, refreshKey]); + const tally = localStorage.tallyCredits(dataPath); + setLocalData(tally); + }, [dataPath, refreshKey]); const refresh = useCallback(() => { - setRefreshKey(k => k + 1); + setRefreshKey((k) => k + 1); }, []); - if (isLocal) { - return { data: localData, refresh }; - } - - const data = cloudData ? { - total: cloudData.total, - available: cloudData.available, - count: { - total: cloudData.count.total, - available: cloudData.count.available, - sent: cloudData.count.sent, - }, - } : undefined; - - return { data, refresh }; + return { data: localData, refresh }; } -// Hook for adding a person export function useAddPerson() { - const { isLocal, dataPath } = useStorage(); - const cloudMutation = useConvexMutation(api.people.add); - - return useCallback(async (person: { - firstName: string; - lastName: string; - email: string; - linkedin?: string; - twitter?: string; - drink?: string; - food?: string; - workingOn?: string; - }) => { - if (isLocal) { - const result = localStorage.addPerson(person, dataPath); - return { skipped: result.skipped }; - } - - return cloudMutation(person); - }, [isLocal, dataPath, cloudMutation]); + const { dataPath } = useStorage(); + + return useCallback( + async (person: { + firstName: string; + lastName: string; + email: string; + linkedin?: string; + twitter?: string; + drink?: string; + food?: string; + workingOn?: string; + }) => { + return localStorage.addPerson(person, dataPath); + }, + [dataPath] + ); } -// Hook for adding credits export function useAddCredit() { - const { isLocal, dataPath } = useStorage(); - const cloudMutation = useConvexMutation(api.credits.addIfNotExists); + const { dataPath } = useStorage(); - return useCallback(async (credit: { - url: string; - code: string; - amount: number; - }) => { - if (isLocal) { + return useCallback( + async (credit: { url: string; code: string; amount: number }) => { return localStorage.addCreditIfNotExists(credit, dataPath); - } - - return cloudMutation(credit); - }, [isLocal, dataPath, cloudMutation]); + }, + [dataPath] + ); } -// Hook for sending credits (email in cloud mode, mark as sent in local mode) export function useSendCredits() { - const { isLocal, dataPath } = useStorage(); - const cloudAction = useConvexAction(api.email.sendCreditEmail); + const { dataPath } = useStorage(); - return useCallback(async (personId: string): Promise<{ success: boolean; error?: string }> => { - if (isLocal) { - // Get next available credit - const credit = localStorage.getNextAvailableCredit(dataPath); - - if (!credit) { - return { success: false, error: "No available credits. Please upload more credits first." }; + return useCallback( + async ( + personId: string + ): Promise<{ success: boolean; error?: string; delivery?: CreditDelivery }> => { + const people = localStorage.loadPeople(dataPath); + const person = people.find((p) => p.id === personId); + if (!person) { + return { success: false, error: "Person not found" }; } - // Assign credit to person and mark as sent - localStorage.assignCreditToPerson(credit.id, personId, dataPath); - localStorage.markPersonSent(personId, dataPath); - - return { success: true }; - } - - return cloudAction({ personId: personId as Id<"people"> }); - }, [isLocal, dataPath, cloudAction]); + return sendCursorCreditToGuest({ + dataPath, + email: person.email, + firstName: person.firstName, + lastName: person.lastName, + }); + }, + [dataPath] + ); } -// Type for sent credit with person info export interface SentCreditWithPerson { id: string; url: string; @@ -200,71 +143,44 @@ export interface SentCreditWithPerson { } | null; } -// Hook for listing sent credits with person info export function useSentCredits(): { data: SentCreditWithPerson[] | undefined; refresh: () => void } { - const { isLocal, dataPath } = useStorage(); + const { dataPath } = useStorage(); const [localData, setLocalData] = useState(undefined); const [refreshKey, setRefreshKey] = useState(0); - // Cloud query - use "skip" when in local mode - const cloudData = useConvexQuery(api.credits.listSentWithPerson, isLocal ? "skip" : {}); - - // Load local data useEffect(() => { - if (isLocal) { - const sentCredits = localStorage.loadSentCreditsWithPerson(dataPath); - setLocalData(sentCredits.map(({ credit, person }) => ({ + const sentCredits = localStorage.loadSentCreditsWithPerson(dataPath); + setLocalData( + sentCredits.map(({ credit, person }) => ({ id: credit.id, url: credit.url, code: credit.code, amount: credit.amount, - person: person ? { - firstName: person.firstName, - lastName: person.lastName, - email: person.email, - } : null, - }))); - } - }, [isLocal, dataPath, refreshKey]); + person: person + ? { + firstName: person.firstName, + lastName: person.lastName, + email: person.email, + } + : null, + })) + ); + }, [dataPath, refreshKey]); const refresh = useCallback(() => { - setRefreshKey(k => k + 1); + setRefreshKey((k) => k + 1); }, []); - if (isLocal) { - return { data: localData, refresh }; - } - - const data = cloudData?.map(item => ({ - id: item._id, - url: item.url, - code: item.code, - amount: item.amount, - person: item.person ? { - firstName: item.person.firstName, - lastName: item.person.lastName, - email: item.person.email, - } : null, - })); - - return { data, refresh }; + return { data: localData, refresh }; } -// Hook for marking credit as redeemed export function useMarkRedeemed() { - const { isLocal, dataPath } = useStorage(); - const cloudMutation = useConvexMutation(api.credits.markRedeemed); + const { dataPath } = useStorage(); - return useCallback(async (creditId: string): Promise => { - if (isLocal) { + return useCallback( + async (creditId: string): Promise => { return localStorage.markCreditRedeemed(creditId, dataPath); - } - - try { - await cloudMutation({ creditId: creditId as Id<"credits"> }); - return true; - } catch { - return false; - } - }, [isLocal, dataPath, cloudMutation]); + }, + [dataPath] + ); } diff --git a/src/luma/lumaClient.ts b/src/luma/lumaClient.ts new file mode 100644 index 0000000..c20a617 --- /dev/null +++ b/src/luma/lumaClient.ts @@ -0,0 +1,45 @@ +const LUMA_BASE = "https://public-api.luma.com"; + +export interface LumaGuestTicket { + checked_in_at?: string | null; +} + +export interface LumaGuest { + id?: string; + user_email?: string | null; + user_first_name?: string | null; + user_last_name?: string | null; + event_tickets?: LumaGuestTicket[] | null; +} + +export async function fetchLumaGuest(params: { + apiKey: string; + eventId: string; + guestId: string; +}): Promise { + const url = new URL(`${LUMA_BASE}/v1/event/get-guest`); + url.searchParams.set("event_id", params.eventId); + url.searchParams.set("id", params.guestId); + + const res = await fetch(url.toString(), { + headers: { + "x-luma-api-key": params.apiKey, + Accept: "application/json", + }, + }); + + if (!res.ok) { + return null; + } + + const json = (await res.json()) as { guest?: LumaGuest }; + return json.guest ?? null; +} + +export function guestHasCheckedIn(guest: LumaGuest): boolean { + const tickets = guest.event_tickets; + if (!tickets || tickets.length === 0) { + return false; + } + return tickets.some((t) => Boolean(t.checked_in_at)); +} diff --git a/src/luma/verifyWebhookSignature.ts b/src/luma/verifyWebhookSignature.ts new file mode 100644 index 0000000..d0e2337 --- /dev/null +++ b/src/luma/verifyWebhookSignature.ts @@ -0,0 +1,57 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +/** + * Verifies Luma webhook signatures per https://help.lu.ma/p/webhooks + */ +export function verifyLumaWebhookSignature( + secret: string, + signatureHeader: string | undefined, + rawBody: string +): boolean { + if (!signatureHeader || !secret) { + return false; + } + + const parts: Record = {}; + for (const part of signatureHeader.split(",")) { + const idx = part.indexOf("="); + if (idx === -1) continue; + parts[part.slice(0, idx).trim()] = part.slice(idx + 1).trim(); + } + + const t = parts["t"]; + const v1 = parts["v1"]; + if (!t || !v1) { + return false; + } + + const signedPayload = `${t}.${rawBody}`; + const expected = createHmac("sha256", secret).update(signedPayload).digest("hex"); + + try { + const expectedBuf = Buffer.from(expected, "utf8"); + const actualBuf = Buffer.from(v1, "utf8"); + return ( + expectedBuf.length === actualBuf.length && timingSafeEqual(expectedBuf, actualBuf) + ); + } catch { + return false; + } +} + +const WEBHOOK_MAX_AGE_SEC = 300; + +export function isWebhookTimestampFresh( + timestampHeader: string | undefined, + maxAgeSec: number = WEBHOOK_MAX_AGE_SEC +): boolean { + if (!timestampHeader) { + return false; + } + const ts = Number.parseInt(timestampHeader, 10); + if (!Number.isFinite(ts)) { + return false; + } + const now = Math.floor(Date.now() / 1000); + return Math.abs(now - ts) <= maxAgeSec; +} diff --git a/src/lumaWebhookServer.ts b/src/lumaWebhookServer.ts new file mode 100644 index 0000000..4516b4d --- /dev/null +++ b/src/lumaWebhookServer.ts @@ -0,0 +1,212 @@ +#!/usr/bin/env bun +/** + * HTTP server for Luma webhooks: on guest.updated, verify check-in via Luma API + * and email a Cursor credit (Resend). See https://docs.luma.com/reference/getting-started-with-your-api + * + * Env: + * LUMA_API_KEY - x-luma-api-key (Luma Plus) + * LUMA_WEBHOOK_SECRET - whsec_... from Luma webhook settings + * RESEND_API_KEY, RESEND_FROM_EMAIL - required to actually send email + * LUMA_DATA_PATH - optional; directory for cafe_*.csv and dedupe file (default: cwd) + */ + +import { sendCursorCreditToGuest } from "./services/sendCursorCreditEmail.js"; +import { fetchLumaGuest, guestHasCheckedIn } from "./luma/lumaClient.js"; +import { + verifyLumaWebhookSignature, + isWebhookTimestampFresh, +} from "./luma/verifyWebhookSignature.js"; +import * as localStorage from "./utils/localStorage.js"; + +function extractGuestPayload(body: unknown): { + eventId: string; + guestId: string; + email: string; + firstName: string; + lastName: string; +} | null { + if (!body || typeof body !== "object") { + return null; + } + const root = body as Record; + const type = typeof root.type === "string" ? root.type : ""; + if (type && type !== "guest.updated") { + return null; + } + + const data = root.data; + if (!data || typeof data !== "object") { + return null; + } + const d = data as Record; + + const guestObj = + d.guest && typeof d.guest === "object" ? (d.guest as Record) : d; + + const eventId = + (typeof d.event_id === "string" && d.event_id) || + (typeof root.event_id === "string" && root.event_id) || + ""; + + const guestId = + (typeof guestObj.id === "string" && guestObj.id) || + (typeof d.api_id === "string" && d.api_id) || + (typeof d.guest_api_id === "string" && d.guest_api_id) || + (typeof root.api_id === "string" && root.api_id) || + ""; + + const email = + (typeof guestObj.email === "string" && guestObj.email) || + (typeof d.user_email === "string" && d.user_email) || + (typeof guestObj.user_email === "string" && guestObj.user_email) || + ""; + + const firstName = + (typeof guestObj.first_name === "string" && guestObj.first_name) || + (typeof guestObj.user_first_name === "string" && guestObj.user_first_name) || + ""; + + const lastName = + (typeof guestObj.last_name === "string" && guestObj.last_name) || + (typeof guestObj.user_last_name === "string" && guestObj.user_last_name) || + ""; + + if (!eventId || !guestId) { + return null; + } + + return { + eventId, + guestId, + email: email.trim(), + firstName: firstName.trim(), + lastName: lastName.trim(), + }; +} + +async function handleGuestUpdated( + payload: NonNullable>, + lumaApiKey: string, + dataPath: string +): Promise<{ ok: boolean; status: number; message: string }> { + if (localStorage.hasLumaGuestBeenCredited(payload.eventId, payload.guestId, dataPath)) { + return { ok: true, status: 200, message: "already_credited" }; + } + + const guest = await fetchLumaGuest({ + apiKey: lumaApiKey, + eventId: payload.eventId, + guestId: payload.guestId, + }); + + if (!guest) { + return { ok: false, status: 502, message: "luma_get_guest_failed" }; + } + + if (!guestHasCheckedIn(guest)) { + return { ok: true, status: 200, message: "not_checked_in" }; + } + + const email = + payload.email || + (typeof guest.user_email === "string" ? guest.user_email : "") || + ""; + if (!email) { + return { ok: false, status: 422, message: "missing_guest_email" }; + } + + const firstName = + payload.firstName || + (typeof guest.user_first_name === "string" ? guest.user_first_name : "") || + "Guest"; + const lastName = + payload.lastName || + (typeof guest.user_last_name === "string" ? guest.user_last_name : "") || + ""; + + const result = await sendCursorCreditToGuest({ + dataPath, + email, + firstName, + lastName, + }); + + if (!result.success) { + return { ok: false, status: 500, message: result.error ?? "send_failed" }; + } + + localStorage.markLumaGuestCredited(payload.eventId, payload.guestId, dataPath); + return { ok: true, status: 200, message: "credited" }; +} + +const port = Number.parseInt(process.env.LUMA_WEBHOOK_PORT ?? "3847", 10); +const lumaApiKey = process.env.LUMA_API_KEY ?? ""; +const webhookSecret = process.env.LUMA_WEBHOOK_SECRET ?? ""; +const dataPath = process.env.LUMA_DATA_PATH || process.cwd(); + +if (!lumaApiKey || !webhookSecret) { + console.error( + "Missing LUMA_API_KEY or LUMA_WEBHOOK_SECRET. Set both before starting the webhook server." + ); + process.exit(1); +} + +Bun.serve({ + port, + async fetch(req) { + if (req.method !== "POST") { + return new Response("Method Not Allowed", { status: 405 }); + } + + const url = new URL(req.url); + if (url.pathname !== "/luma/webhook" && url.pathname !== "/") { + return new Response("Not Found", { status: 404 }); + } + + const rawBody = await req.text(); + const sig = req.headers.get("webhook-signature") ?? undefined; + const ts = req.headers.get("webhook-timestamp") ?? undefined; + + if (!isWebhookTimestampFresh(ts)) { + return new Response(JSON.stringify({ error: "stale_timestamp" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + if (!verifyLumaWebhookSignature(webhookSecret, sig, rawBody)) { + return new Response(JSON.stringify({ error: "invalid_signature" }), { + status: 401, + headers: { "Content-Type": "application/json" }, + }); + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawBody) as unknown; + } catch { + return new Response(JSON.stringify({ error: "invalid_json" }), { + status: 400, + headers: { "Content-Type": "application/json" }, + }); + } + + const payload = extractGuestPayload(parsed); + if (!payload) { + return new Response(JSON.stringify({ ok: true, ignored: true }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + + const out = await handleGuestUpdated(payload, lumaApiKey, dataPath); + return new Response(JSON.stringify({ ok: out.ok, message: out.message }), { + status: out.status, + headers: { "Content-Type": "application/json" }, + }); + }, +}); + +console.log( + `Luma webhook server listening on http://127.0.0.1:${port}/luma/webhook (data: ${dataPath})` +); diff --git a/src/screens/CheckRedemptions.tsx b/src/screens/CheckRedemptions.tsx index 2c40375..c507142 100644 --- a/src/screens/CheckRedemptions.tsx +++ b/src/screens/CheckRedemptions.tsx @@ -16,7 +16,7 @@ interface CheckResult { } const CheckRedemptions = ({ onBack }: CheckRedemptionsProps) => { - const { isLocal } = useStorage(); + const { dataPath } = useStorage(); const { data: sentCredits, refresh } = useSentCredits(); const markRedeemed = useMarkRedeemed(); @@ -142,12 +142,10 @@ const CheckRedemptions = ({ onBack }: CheckRedemptionsProps) => { return ( {/* Mode indicator */} - {isLocal && ( - - [LOCAL MODE] - - Changes saved to CSV files - - )} + + Data: + {dataPath} + {/* Header */} diff --git a/src/screens/ProfileConfirm.tsx b/src/screens/ProfileConfirm.tsx index 08b9e71..cf9706c 100644 --- a/src/screens/ProfileConfirm.tsx +++ b/src/screens/ProfileConfirm.tsx @@ -1,6 +1,7 @@ import React, { useState } from "react"; import { Box, Text, useInput } from "ink"; import { exec } from "child_process"; +import type { CreditDelivery } from "../services/sendCursorCreditEmail.js"; const openUrl = (url: string) => { let command; @@ -42,7 +43,9 @@ interface ProfileConfirmProps { isSending?: boolean; sendError?: string | null; sendSuccess?: boolean; - isLocal?: boolean; + /** When Resend is configured, credits are emailed; otherwise only assigned in CSV. */ + sendsEmail?: boolean; + delivery?: CreditDelivery | null; } const ProfileConfirm = ({ @@ -52,7 +55,8 @@ const ProfileConfirm = ({ isSending = false, sendError = null, sendSuccess = false, - isLocal = false, + sendsEmail = true, + delivery = null, }: ProfileConfirmProps) => { const [selected, setSelected] = useState<"yes" | "no">("yes"); @@ -93,19 +97,25 @@ const ProfileConfirm = ({ // Show success screen if (sendSuccess) { + const emailed = delivery === "email" || (delivery == null && sendsEmail); return ( - {isLocal ? "Credit Assigned Successfully!" : "Email Sent Successfully!"} + {emailed ? "Email Sent Successfully!" : "Credit Assigned Successfully!"} - {isLocal ? "Credit has been assigned to:" : "Credits have been sent to:"} + + {emailed ? "Credits have been sent to:" : "Credit has been assigned to:"} + {contact.email} - {isLocal && ( - (Saved to cafe_credits.csv and cafe_people.csv) + {!emailed && ( + + Set RESEND_API_KEY and RESEND_FROM_EMAIL to email codes automatically. + )} + (Saved to cafe_credits.csv and cafe_people.csv) Press any key to continue... @@ -137,15 +147,14 @@ const ProfileConfirm = ({ - {isLocal ? "Assigning Credit..." : "Sending Credits..."} + {sendsEmail ? "Sending Credits..." : "Assigning Credit..."} - {isLocal - ? `Assigning credit to ${contact.email}` - : `Sending email to ${contact.email}` - } + {sendsEmail + ? `Sending email to ${contact.email}` + : `Assigning credit to ${contact.email}`} Please wait... @@ -206,14 +215,11 @@ const ProfileConfirm = ({ - {isLocal - ? `Assign Credit to ${fullName}?` - : `Send Cursor Credits to ${fullName}?` - } + {sendsEmail + ? `Send Cursor Credits to ${fullName}?` + : `Assign Credit to ${fullName}?`} - {isLocal && ( - (no email will be sent) - )} + {!sendsEmail && (no email will be sent)} diff --git a/src/screens/SendCredits.tsx b/src/screens/SendCredits.tsx index 7b76f94..6fd130a 100644 --- a/src/screens/SendCredits.tsx +++ b/src/screens/SendCredits.tsx @@ -6,13 +6,14 @@ import ProfileConfirm, { type Contact } from "../screens/ProfileConfirm.js"; import { ProgressBar } from "../components/ProgressBar.js"; import { useStorage } from "../context/StorageContext.js"; import { usePeopleList, useCreditTally, useSendCredits } from "../hooks/useStorageHooks.js"; +import type { CreditDelivery } from "../services/sendCursorCreditEmail.js"; interface SendCreditsProps { onBack: () => void; } const SendCredits = ({ onBack }: SendCreditsProps) => { - const { isLocal } = useStorage(); + const { dataPath } = useStorage(); const [searchQuery, setSearchQuery] = useState(""); const [isSearchFocused, setIsSearchFocused] = useState(false); const [selectedContact, setSelectedContact] = useState(null); @@ -23,6 +24,11 @@ const SendCredits = ({ onBack }: SendCreditsProps) => { const [isSending, setIsSending] = useState(false); const [sendError, setSendError] = useState(null); const [sendSuccess, setSendSuccess] = useState(false); + const [lastDelivery, setLastDelivery] = useState(null); + + const sendsEmail = Boolean( + process.env.RESEND_API_KEY && process.env.RESEND_FROM_EMAIL + ); // Use custom hooks for data const { data: people, refresh: refreshPeople } = usePeopleList(); @@ -110,6 +116,7 @@ const SendCredits = ({ onBack }: SendCreditsProps) => { setIsSending(false); setSendError(null); setSendSuccess(false); + setLastDelivery(null); }; const handleConfirmSend = async () => { @@ -118,12 +125,14 @@ const SendCredits = ({ onBack }: SendCreditsProps) => { setIsSending(true); setSendError(null); setSendSuccess(false); + setLastDelivery(null); const result = await sendCredits(selectedContact.id); setIsSending(false); if (result.success) { + setLastDelivery(result.delivery ?? null); setSendSuccess(true); // Refresh data after successful send refreshPeople(); @@ -138,6 +147,7 @@ const SendCredits = ({ onBack }: SendCreditsProps) => { setIsSending(false); setSendError(null); setSendSuccess(false); + setLastDelivery(null); // Refresh data when closing modal refreshPeople(); refreshCredits(); @@ -162,20 +172,18 @@ const SendCredits = ({ onBack }: SendCreditsProps) => { isSending={isSending} sendError={sendError} sendSuccess={sendSuccess} - isLocal={isLocal} + sendsEmail={sendsEmail} + delivery={lastDelivery} /> ); } return ( - {/* Mode indicator */} - {isLocal && ( - - [LOCAL MODE] - - Changes saved to CSV files - - )} + + Data: + {dataPath} + {/* Credits Progress Bar */} diff --git a/src/screens/UploadAttendees.tsx b/src/screens/UploadAttendees.tsx index 7595dbf..e0eb9c3 100644 --- a/src/screens/UploadAttendees.tsx +++ b/src/screens/UploadAttendees.tsx @@ -21,7 +21,7 @@ interface ImportResult { } const UploadAttendees = ({ onBack }: UploadAttendeesProps) => { - const { isLocal } = useStorage(); + const { dataPath } = useStorage(); const [stage, setStage] = useState("input"); const [filepath, setFilepath] = useState(""); const [error, setError] = useState(null); @@ -137,9 +137,6 @@ const UploadAttendees = ({ onBack }: UploadAttendeesProps) => { Upload Attendees - {isLocal && ( - [LOCAL MODE] - )} @@ -154,12 +151,12 @@ const UploadAttendees = ({ onBack }: UploadAttendeesProps) => { - What are you working on? - {isLocal && ( - - Attendees will be saved to: - cafe_people.csv - - )} + + Attendees will be saved under: + {dataPath} + as + cafe_people.csv + @@ -203,7 +200,6 @@ const UploadAttendees = ({ onBack }: UploadAttendeesProps) => { Importing Attendees - {isLocal && [LOCAL]} @@ -215,7 +211,7 @@ const UploadAttendees = ({ onBack }: UploadAttendeesProps) => { - {isLocal ? "Saving to CSV file..." : "Importing attendees to database..."} + Saving to cafe_people.csv... @@ -229,7 +225,6 @@ const UploadAttendees = ({ onBack }: UploadAttendeesProps) => { Import Complete - {isLocal && [LOCAL]} @@ -247,10 +242,10 @@ const UploadAttendees = ({ onBack }: UploadAttendeesProps) => { - {isLocal && result.imported > 0 && ( + {result.imported > 0 && ( - Saved to: - cafe_people.csv + Saved under: + {dataPath} )} diff --git a/src/screens/UploadCredits.tsx b/src/screens/UploadCredits.tsx index fead238..048ceff 100644 --- a/src/screens/UploadCredits.tsx +++ b/src/screens/UploadCredits.tsx @@ -21,7 +21,7 @@ interface CheckResult { } const UploadCredits = ({ onBack }: UploadCreditsProps) => { - const { isLocal } = useStorage(); + const { dataPath } = useStorage(); const [stage, setStage] = useState("input"); const [filepath, setFilepath] = useState(""); const [error, setError] = useState(null); @@ -153,9 +153,6 @@ const UploadCredits = ({ onBack }: UploadCreditsProps) => { Upload Cursor Credits - {isLocal && ( - [LOCAL MODE] - )} {error && ( @@ -170,12 +167,12 @@ const UploadCredits = ({ onBack }: UploadCreditsProps) => { - {isLocal && ( - - Credits will be saved to: - cafe_credits.csv - - )} + + Credits will be saved under: + {dataPath} + as + cafe_credits.csv + @@ -209,7 +206,6 @@ const UploadCredits = ({ onBack }: UploadCreditsProps) => { Checking Credits... - {isLocal && [LOCAL]} @@ -229,18 +225,17 @@ const UploadCredits = ({ onBack }: UploadCreditsProps) => { Results - {isLocal && [LOCAL MODE]} Available Codes: {availableUrls.length} Total New Credits: ${totalCredits} - Saved {isLocal ? "to CSV" : "to Database"}: {savedCount} + Saved to CSV: {savedCount} Skipped (already exists): {skippedCount} - {isLocal && savedCount > 0 && ( + {savedCount > 0 && ( - Credits saved to: - cafe_credits.csv + Credits saved under: + {dataPath} )} diff --git a/src/services/sendCursorCreditEmail.ts b/src/services/sendCursorCreditEmail.ts new file mode 100644 index 0000000..03fb8fd --- /dev/null +++ b/src/services/sendCursorCreditEmail.ts @@ -0,0 +1,82 @@ +import { render } from "@react-email/render"; +import { Resend } from "resend"; +import { CreditEmail } from "../emails/CreditEmail.js"; +import * as localStorage from "../utils/localStorage.js"; + +export interface SendCursorCreditInput { + dataPath: string; + email: string; + firstName: string; + lastName: string; +} + +/** + * Resolves or creates a person, reserves a credit, optionally emails via Resend, + * then assigns the credit and marks the person as sent only after a successful send + * (or immediately when Resend is not configured). + */ +export type CreditDelivery = "email" | "assigned_only"; + +export async function sendCursorCreditToGuest( + input: SendCursorCreditInput +): Promise<{ success: boolean; error?: string; delivery?: CreditDelivery }> { + const { dataPath, email, firstName, lastName } = input; + const normalizedEmail = email.trim().toLowerCase(); + if (!normalizedEmail) { + return { success: false, error: "Missing email" }; + } + + localStorage.addPerson( + { + firstName: firstName.trim() || "Guest", + lastName: lastName.trim() || "", + email: normalizedEmail, + }, + dataPath + ); + + const people = localStorage.loadPeople(dataPath); + const person = people.find((p) => p.email.toLowerCase() === normalizedEmail); + if (!person) { + return { success: false, error: "Could not resolve attendee record" }; + } + + const credit = localStorage.getNextAvailableCredit(dataPath); + if (!credit) { + return { success: false, error: "No available credits" }; + } + + const apiKey = process.env.RESEND_API_KEY; + const fromEmail = process.env.RESEND_FROM_EMAIL; + + if (!apiKey || !fromEmail) { + localStorage.assignCreditToPerson(credit.id, person.id, dataPath); + localStorage.markPersonSent(person.id, dataPath); + return { success: true, delivery: "assigned_only" }; + } + + const emailHtml = await render( + CreditEmail({ + firstName: firstName.trim() || person.firstName, + creditUrl: credit.url, + code: credit.code, + amount: credit.amount, + }) + ); + + const resend = new Resend(apiKey); + const { error } = await resend.emails.send({ + from: fromEmail, + to: person.email, + subject: `Your Cursor Credits - $${credit.amount}`, + html: emailHtml, + }); + + if (error) { + return { success: false, error: error.message }; + } + + localStorage.assignCreditToPerson(credit.id, person.id, dataPath); + localStorage.markPersonSent(person.id, dataPath); + return { success: true, delivery: "email" }; +} diff --git a/src/utils/localStorage.ts b/src/utils/localStorage.ts index 71988d5..29bba31 100644 --- a/src/utils/localStorage.ts +++ b/src/utils/localStorage.ts @@ -5,6 +5,7 @@ import Papa from "papaparse"; // File names for local storage const PEOPLE_FILE = "cafe_people.csv"; const CREDITS_FILE = "cafe_credits.csv"; +const LUMA_SENT_FILE = "cafe_luma_sent_guests.txt"; export interface LocalPerson { id: string; @@ -256,3 +257,40 @@ export const markCreditRedeemed = (creditId: string, basePath?: string): boolean return true; }; + +// --- Luma webhook dedupe (one credit email per event + guest) --- + +const lumaSentKey = (eventId: string, guestId: string) => + `${eventId.trim()}\t${guestId.trim()}`; + +export const loadLumaSentGuestKeys = (basePath?: string): Set => { + const filepath = getFilePath(LUMA_SENT_FILE, basePath); + if (!existsSync(filepath)) { + return new Set(); + } + const content = readFileSync(filepath, "utf-8"); + const keys = new Set(); + for (const line of content.split(/\r?\n/)) { + const t = line.trim(); + if (t) keys.add(t); + } + return keys; +}; + +export const markLumaGuestCredited = ( + eventId: string, + guestId: string, + basePath?: string +): void => { + const filepath = getFilePath(LUMA_SENT_FILE, basePath); + const key = `${lumaSentKey(eventId, guestId)}\n`; + writeFileSync(filepath, key, { flag: "a" }); +}; + +export const hasLumaGuestBeenCredited = ( + eventId: string, + guestId: string, + basePath?: string +): boolean => { + return loadLumaSentGuestKeys(basePath).has(lumaSentKey(eventId, guestId)); +}; diff --git a/test/lumaWebhookSignature.test.ts b/test/lumaWebhookSignature.test.ts new file mode 100644 index 0000000..9851c83 --- /dev/null +++ b/test/lumaWebhookSignature.test.ts @@ -0,0 +1,39 @@ +import { describe, test, expect } from "bun:test"; +import { createHmac } from "node:crypto"; +import { verifyLumaWebhookSignature, isWebhookTimestampFresh } from "../src/luma/verifyWebhookSignature.js"; + +describe("Luma webhook signature", () => { + test("accepts valid HMAC per Luma docs", () => { + const secret = "whsec_test_secret"; + const body = '{"type":"guest.updated","data":{}}'; + const t = "1700000000"; + const expected = createHmac("sha256", secret) + .update(`${t}.${body}`) + .digest("hex"); + const header = `t=${t},v1=${expected}`; + expect(verifyLumaWebhookSignature(secret, header, body)).toBe(true); + }); + + test("rejects wrong body", () => { + const secret = "whsec_test_secret"; + const body = '{"type":"guest.updated"}'; + const t = "1700000000"; + const expected = createHmac("sha256", secret) + .update(`${t}.${body}`) + .digest("hex"); + const header = `t=${t},v1=${expected}`; + expect(verifyLumaWebhookSignature(secret, header, body + "x")).toBe(false); + }); +}); + +describe("Luma webhook timestamp", () => { + test("accepts current time", () => { + const now = Math.floor(Date.now() / 1000).toString(); + expect(isWebhookTimestampFresh(now, 300)).toBe(true); + }); + + test("rejects stale timestamp", () => { + const old = (Math.floor(Date.now() / 1000) - 400).toString(); + expect(isWebhookTimestampFresh(old, 300)).toBe(false); + }); +}); From 86dbe19c99502c2c8525bf454e704139ab412c77 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 16:58:22 +0000 Subject: [PATCH 3/7] chore: run full test suite via npm script Co-authored-by: Alhwyn --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ab41118..61c45f2 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "bun --hot src/index.ts", "start": "NODE_ENV=production bun src/index.ts", "build": "bun run build.ts", - "test": "bun test ./test/businessLogic.test.ts", + "test": "bun test ./test/", "test:creditChecker": "bun test/creditChecker.test.ts", "debug": "bun debug.ts" }, From ef7aa45bd9386b339b2feb929705f448c17fd096 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 16:59:59 +0000 Subject: [PATCH 4/7] fix: treat Luma Guest Updated as check-in webhook and add /luma/check-in URL Luma has no separate check-in event; require guest.updated and document it. Add canonical /luma/check-in path; fix test script to use bun test discovery. Co-authored-by: Alhwyn --- README.md | 10 +++++++--- package.json | 2 +- src/lumaWebhookServer.ts | 36 +++++++++++++++++++++++++----------- 3 files changed, 33 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 3d4f71a..5e25803 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,12 @@ If Resend is not configured, the CLI and webhook still assign credits in CSV onl ## Luma check-in webhook -When a guest checks in on Luma, Luma can call your server with a `guest.updated` webhook. This repo includes a small HTTP server that: +Luma does **not** list a separate webhook type only for check-in. Per [Luma’s webhooks help](https://help.lu.ma/p/webhooks), **Guest Updated** fires when check-in status (and other guest fields) change, with payload type `guest.updated`. This server listens **only** for `guest.updated` and still **re-checks** check-in via the API before sending a code, so profile-only updates do not trigger a credit. + +This repo includes a small HTTP server that: 1. Verifies the [Luma webhook signature](https://help.lu.ma/p/webhooks) -2. Confirms the guest is checked in using `GET /v1/event/get-guest` ([Luma API](https://docs.luma.com/reference/getting-started-with-your-api)) +2. Confirms the guest is checked in using `GET /v1/event/get-guest` ([Luma API](https://docs.luma.com/reference/getting-started-with-your-api)) (per-ticket `checked_in_at`) 3. Sends one Cursor credit email per event + guest (deduped via `cafe_luma_sent_guests.txt`) ### Webhook server env @@ -83,7 +85,9 @@ When a guest checks in on Luma, Luma can call your server with a `guest.updated` bun run luma:webhook ``` -Expose `http://your-host:/luma/webhook` publicly (for example via a tunnel), then in Luma: **Settings → Developer → Webhooks**, create a webhook with event type **Guest Updated** pointing at that URL. +Expose a URL publicly (for example via a tunnel). Recommended path: **`http://your-host:/luma/check-in`**. The same server also accepts **`/luma/webhook`**, **`/luma/webhook/check-in`**, and **`/`** for backwards compatibility. + +In Luma: **Settings → Developer → Webhooks**, create a webhook with action **Guest Updated** only (not “all actions”, unless you accept extra traffic that this server will ignore after the `type` check). ## Usage diff --git a/package.json b/package.json index 61c45f2..0ad85c7 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "dev": "bun --hot src/index.ts", "start": "NODE_ENV=production bun src/index.ts", "build": "bun run build.ts", - "test": "bun test ./test/", + "test": "bun test", "test:creditChecker": "bun test/creditChecker.test.ts", "debug": "bun debug.ts" }, diff --git a/src/lumaWebhookServer.ts b/src/lumaWebhookServer.ts index 4516b4d..7b6ddb4 100644 --- a/src/lumaWebhookServer.ts +++ b/src/lumaWebhookServer.ts @@ -1,7 +1,13 @@ #!/usr/bin/env bun /** - * HTTP server for Luma webhooks: on guest.updated, verify check-in via Luma API - * and email a Cursor credit (Resend). See https://docs.luma.com/reference/getting-started-with-your-api + * Check-in webhook for Luma: Luma does not expose a separate "guest.checked_in" event type; + * check-in is delivered as **guest.updated** (see https://help.lu.ma/p/webhooks). This server + * only credits after confirming check-in via GET /v1/event/get-guest (per-ticket checked_in_at). + * + * Register in Luma (Settings → Developer → Webhooks): event **Guest Updated**, URL e.g. + * https://your-host/luma/check-in + * + * See https://docs.luma.com/reference/getting-started-with-your-api * * Env: * LUMA_API_KEY - x-luma-api-key (Luma Plus) @@ -18,7 +24,7 @@ import { } from "./luma/verifyWebhookSignature.js"; import * as localStorage from "./utils/localStorage.js"; -function extractGuestPayload(body: unknown): { +function extractGuestUpdatedPayload(body: unknown): { eventId: string; guestId: string; email: string; @@ -29,8 +35,8 @@ function extractGuestPayload(body: unknown): { return null; } const root = body as Record; - const type = typeof root.type === "string" ? root.type : ""; - if (type && type !== "guest.updated") { + // Luma's check-in signal is the guest.updated webhook (not a separate event type). + if (root.type !== "guest.updated") { return null; } @@ -84,8 +90,8 @@ function extractGuestPayload(body: unknown): { }; } -async function handleGuestUpdated( - payload: NonNullable>, +async function handleCheckInFromGuestUpdated( + payload: NonNullable>, lumaApiKey: string, dataPath: string ): Promise<{ ok: boolean; status: number; message: string }> { @@ -159,7 +165,14 @@ Bun.serve({ } const url = new URL(req.url); - if (url.pathname !== "/luma/webhook" && url.pathname !== "/") { + const path = url.pathname.replace(/\/$/, "") || "/"; + const allowedPaths = new Set([ + "/", + "/luma/webhook", + "/luma/check-in", + "/luma/webhook/check-in", + ]); + if (!allowedPaths.has(path)) { return new Response("Not Found", { status: 404 }); } @@ -191,7 +204,7 @@ Bun.serve({ }); } - const payload = extractGuestPayload(parsed); + const payload = extractGuestUpdatedPayload(parsed); if (!payload) { return new Response(JSON.stringify({ ok: true, ignored: true }), { status: 200, @@ -199,7 +212,7 @@ Bun.serve({ }); } - const out = await handleGuestUpdated(payload, lumaApiKey, dataPath); + const out = await handleCheckInFromGuestUpdated(payload, lumaApiKey, dataPath); return new Response(JSON.stringify({ ok: out.ok, message: out.message }), { status: out.status, headers: { "Content-Type": "application/json" }, @@ -208,5 +221,6 @@ Bun.serve({ }); console.log( - `Luma webhook server listening on http://127.0.0.1:${port}/luma/webhook (data: ${dataPath})` + `Luma check-in webhook listening on http://127.0.0.1:${port}/luma/check-in (also /luma/webhook, /) data: ${dataPath}` ); +console.log(`Subscribe in Luma to Guest Updated (guest.updated) pointing at this URL.`); From a4564e50179a53fe3acc985cec3efafd3d8e6d2c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 17:01:24 +0000 Subject: [PATCH 5/7] feat: register Luma check-in webhook via API for operators Add POST /v1/webhooks/create helper and luma:register-webhook CLI so setup does not require the Luma dashboard. Document API-first operator flow. Co-authored-by: Alhwyn --- README.md | 30 ++++++-- package.json | 1 + src/luma/webhooksAdmin.ts | 121 ++++++++++++++++++++++++++++++ src/registerLumaCheckInWebhook.ts | 63 ++++++++++++++++ 4 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 src/luma/webhooksAdmin.ts create mode 100644 src/registerLumaCheckInWebhook.ts diff --git a/README.md b/README.md index 5e25803..41be500 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,9 @@ By default, attendee and credit data is stored as `cafe_people.csv` and `cafe_cr If Resend is not configured, the CLI and webhook still assign credits in CSV only (no outbound email). -## Luma check-in webhook +## Luma check-in webhook (API-first for operators) + +Integration is **server-to-server**: your deployment holds a [Luma API key](https://docs.luma.com/reference/getting-started-with-your-api) and optional Resend keys. End users do not configure Luma; they only receive the credit email after check-in. Luma does **not** list a separate webhook type only for check-in. Per [Luma’s webhooks help](https://help.lu.ma/p/webhooks), **Guest Updated** fires when check-in status (and other guest fields) change, with payload type `guest.updated`. This server listens **only** for `guest.updated` and still **re-checks** check-in via the API before sending a code, so profile-only updates do not trigger a credit. @@ -69,12 +71,29 @@ This repo includes a small HTTP server that: 2. Confirms the guest is checked in using `GET /v1/event/get-guest` ([Luma API](https://docs.luma.com/reference/getting-started-with-your-api)) (per-ticket `checked_in_at`) 3. Sends one Cursor credit email per event + guest (deduped via `cafe_luma_sent_guests.txt`) +### Register the webhook via Luma API (recommended) + +Operators run this once (uses [`POST /v1/webhooks/create`](https://docs.luma.com/reference/post_v1-webhooks-create) with `event_types: ["guest.updated"]`): + +```bash +# Public URL must reach your running webhook server (tunnel or real host) +LUMA_API_KEY=your_key bun run luma:register-webhook https://your-host/luma/check-in +``` + +The command prints `LUMA_WEBHOOK_SECRET=whsec_...` — set that on the server that runs `luma:webhook`. List existing webhooks: + +```bash +LUMA_API_KEY=your_key bun run luma:register-webhook list +``` + +Alternatively, you can create the same webhook manually in Luma: **Settings → Developer → Webhooks**, action **Guest Updated** only. + ### Webhook server env | Variable | Required | Description | |----------|----------|-------------| -| `LUMA_API_KEY` | Yes | `x-luma-api-key` for API calls | -| `LUMA_WEBHOOK_SECRET` | Yes | `whsec_...` secret from Luma webhook settings | +| `LUMA_API_KEY` | Yes | `x-luma-api-key` for API calls (webhook handler + optional registration) | +| `LUMA_WEBHOOK_SECRET` | Yes | `whsec_...` from `luma:register-webhook` output or Luma dashboard | | `LUMA_DATA_PATH` | No | Same role as `CAFE_DATA_PATH` (defaults to cwd) | | `LUMA_WEBHOOK_PORT` | No | Listen port (default `3847`) | | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | For email | Same as CLI | @@ -87,8 +106,6 @@ bun run luma:webhook Expose a URL publicly (for example via a tunnel). Recommended path: **`http://your-host:/luma/check-in`**. The same server also accepts **`/luma/webhook`**, **`/luma/webhook/check-in`**, and **`/`** for backwards compatibility. -In Luma: **Settings → Developer → Webhooks**, create a webhook with action **Guest Updated** only (not “all actions”, unless you accept extra traffic that this server will ignore after the `type` check). - ## Usage ### Main menu @@ -137,7 +154,8 @@ cafe-cursor-cli/ ├── src/ │ ├── cli.tsx # Main CLI entry point │ ├── lumaWebhookServer.ts # Luma guest.updated webhook HTTP server -│ ├── luma/ # Luma API client and signature verification +│ ├── registerLumaCheckInWebhook.ts # CLI: POST /v1/webhooks/create +│ ├── luma/ # Luma API client, webhooks admin, signatures │ ├── services/ # Shared send-credit logic (CLI + webhook) │ ├── screens/ # CLI screens │ ├── components/ # Reusable components diff --git a/package.json b/package.json index 0ad85c7..5ad7202 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "scripts": { "cli": "bun src/cli.tsx", "luma:webhook": "bun src/lumaWebhookServer.ts", + "luma:register-webhook": "bun src/registerLumaCheckInWebhook.ts", "dev": "bun --hot src/index.ts", "start": "NODE_ENV=production bun src/index.ts", "build": "bun run build.ts", diff --git a/src/luma/webhooksAdmin.ts b/src/luma/webhooksAdmin.ts new file mode 100644 index 0000000..f38ee27 --- /dev/null +++ b/src/luma/webhooksAdmin.ts @@ -0,0 +1,121 @@ +const LUMA_BASE = "https://public-api.luma.com"; + +export type LumaWebhookEventType = + | "*" + | "calendar.event.added" + | "calendar.person.subscribed" + | "event.canceled" + | "event.created" + | "event.updated" + | "guest.registered" + | "guest.updated" + | "ticket.registered"; + +export interface LumaWebhook { + id: string; + url: string; + event_types: LumaWebhookEventType[]; + status: "active" | "paused"; + secret: string; + created_at: string; +} + +async function lumaPost(apiKey: string, path: string, body: unknown): Promise { + const res = await fetch(`${LUMA_BASE}${path}`, { + method: "POST", + headers: { + "x-luma-api-key": apiKey, + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(body), + }); + + const text = await res.text(); + let json: unknown; + try { + json = text ? JSON.parse(text) : null; + } catch { + throw new Error(`Luma API invalid JSON (${res.status}): ${text.slice(0, 200)}`); + } + + if (!res.ok) { + const msg = + json && typeof json === "object" && "error" in json + ? String((json as { error: unknown }).error) + : text.slice(0, 300); + throw new Error(`Luma API ${res.status}: ${msg}`); + } + + return json as T; +} + +async function lumaGet(apiKey: string, path: string): Promise { + const res = await fetch(`${LUMA_BASE}${path}`, { + headers: { + "x-luma-api-key": apiKey, + Accept: "application/json", + }, + }); + + const text = await res.text(); + let json: unknown; + try { + json = text ? JSON.parse(text) : null; + } catch { + throw new Error(`Luma API invalid JSON (${res.status}): ${text.slice(0, 200)}`); + } + + if (!res.ok) { + const msg = + json && typeof json === "object" && "error" in json + ? String((json as { error: unknown }).error) + : text.slice(0, 300); + throw new Error(`Luma API ${res.status}: ${msg}`); + } + + return json as T; +} + +/** + * Register a webhook via Luma API (operator / server-side only). + * @see https://docs.luma.com/reference/post_v1-webhooks-create + */ +export async function createLumaWebhook(params: { + apiKey: string; + url: string; + eventTypes: LumaWebhookEventType[]; +}): Promise { + const out = await lumaPost<{ webhook: LumaWebhook }>(params.apiKey, "/v1/webhooks/create", { + url: params.url, + event_types: params.eventTypes, + }); + if (!out.webhook) { + throw new Error("Luma API returned no webhook"); + } + return out.webhook; +} + +/** Check-in automation: only guest.updated (Luma's signal for check-in changes). */ +export async function createCheckInWebhook( + apiKey: string, + publicUrl: string +): Promise { + return createLumaWebhook({ + apiKey, + url: publicUrl, + eventTypes: ["guest.updated"], + }); +} + +/** + * List webhooks for the calendar tied to the API key. + * @see https://docs.luma.com/reference/get_v1-webhooks-list + */ +export async function listLumaWebhooks(apiKey: string): Promise<{ + entries: LumaWebhook[]; + has_more: boolean; + next_cursor?: string; +}> { + return lumaGet(apiKey, "/v1/webhooks/list"); +} diff --git a/src/registerLumaCheckInWebhook.ts b/src/registerLumaCheckInWebhook.ts new file mode 100644 index 0000000..f22d35e --- /dev/null +++ b/src/registerLumaCheckInWebhook.ts @@ -0,0 +1,63 @@ +#!/usr/bin/env bun +/** + * Operator tool: register the Cursor Cafe check-in webhook via Luma API + * (no Luma dashboard clicks). Requires LUMA_API_KEY only. + * + * Usage: + * LUMA_API_KEY=... bun src/registerLumaCheckInWebhook.ts https://your-host/luma/check-in + * LUMA_API_KEY=... bun src/registerLumaCheckInWebhook.ts list + * + * After create, set LUMA_WEBHOOK_SECRET on the webhook server to the printed secret (whsec_...). + */ + +import { createCheckInWebhook, listLumaWebhooks } from "./luma/webhooksAdmin.js"; + +const apiKey = process.env.LUMA_API_KEY ?? ""; + +async function main() { + const arg = process.argv[2]; + + if (!apiKey) { + console.error("Set LUMA_API_KEY (from Luma dashboard, server-side only)."); + process.exit(1); + } + + if (arg === "list" || arg === "ls") { + const { entries } = await listLumaWebhooks(apiKey); + if (entries.length === 0) { + console.log("No webhooks."); + return; + } + for (const w of entries) { + console.log( + `${w.id}\t${w.status}\t${w.event_types.join(",")}\t${w.url}` + ); + } + return; + } + + const url = arg || process.env.LUMA_WEBHOOK_PUBLIC_URL || ""; + if (!url || !/^https?:\/\//i.test(url)) { + console.error( + "Provide a public HTTPS (or http) URL as the first argument, or set LUMA_WEBHOOK_PUBLIC_URL.\n" + + "Example: LUMA_API_KEY=... bun src/registerLumaCheckInWebhook.ts https://example.com/luma/check-in\n" + + "List existing: LUMA_API_KEY=... bun src/registerLumaCheckInWebhook.ts list" + ); + process.exit(1); + } + + const webhook = await createCheckInWebhook(apiKey, url); + + console.log("Registered check-in webhook via Luma API:\n"); + console.log(` id: ${webhook.id}`); + console.log(` url: ${webhook.url}`); + console.log(` events: ${webhook.event_types.join(", ")}`); + console.log(` status: ${webhook.status}`); + console.log(`\nSet on your webhook server (keep secret private):\n`); + console.log(` LUMA_WEBHOOK_SECRET=${webhook.secret}\n`); +} + +main().catch((e) => { + console.error(e instanceof Error ? e.message : e); + process.exit(1); +}); From c8611ee90b7b03b5d613a37e4527f2ae3907dc0b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 22:30:52 +0000 Subject: [PATCH 6/7] docs: restructure README around Luma API integration Co-authored-by: Alhwyn --- README.md | 123 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 69 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 41be500..439d0a3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,32 @@ # Cafe Cursor CLI [![CI](https://github.com/Alhwyn/cafe-cursor-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Alhwyn/cafe-cursor-cli/actions/workflows/ci.yml) -A CLI tool for managing and sending Cursor credits to event attendees. Data lives in CSV files on disk. Optional [Resend](https://resend.com) sends the credit email; optional [Luma](https://docs.luma.com/reference/getting-started-with-your-api) webhooks automate sending when a guest checks in. +Two pieces work together: + +1. **Interactive CLI** – manage attendees and Cursor credit codes in CSV files, send credits manually (optional [Resend](https://resend.com) email). +2. **Luma API integration** – a small HTTP service that talks to [Luma’s public API](https://docs.luma.com/reference/getting-started-with-your-api) so **check-in on Luma** can trigger an automatic credit (again via Resend when configured). + +Event attendees never use the Luma API or this repo; operators hold the Luma API key and run the services. + +## Luma API integration (overview) + +| What | Luma API usage | +|------|----------------| +| **Auth** | Send header `x-luma-api-key: ` on every request ([getting started](https://docs.luma.com/reference/getting-started-with-your-api)). Requires [Luma Plus](https://luma.com/pricing). | +| **Base URL** | `https://public-api.luma.com` | +| **Register webhook** | `POST /v1/webhooks/create` with `event_types: ["guest.updated"]` – scripted as `bun run luma:register-webhook` ([create webhook](https://docs.luma.com/reference/post_v1-webhooks-create)). | +| **List webhooks** | `GET /v1/webhooks/list` – `bun run luma:register-webhook list`. | +| **Verify check-in** | After Luma POSTs to your server, this app calls `GET /v1/event/get-guest` and requires per-ticket `checked_in_at` before sending a code ([get guest](https://docs.luma.com/reference/get_v1-event-get-guest)). | +| **Inbound webhooks** | Luma delivers `guest.updated` when guest data changes including check-in ([webhooks overview](https://help.lu.ma/p/webhooks)). There is no separate `guest.checked_in` event type; we filter on `type === "guest.updated"` and confirm check-in via the API. | + +Flow: + +1. Operator registers a public URL with Luma (API or dashboard). +2. Guest checks in on Luma. +3. Luma sends a signed `guest.updated` payload to your server. +4. Server verifies [HMAC signature](https://help.lu.ma/p/webhooks), calls `get-guest`, then assigns/sends one Cursor credit per event+guest (deduped in `cafe_luma_sent_guests.txt`). + +OpenAPI spec (for exploring all endpoints): `https://public-api.luma.com/openapi.json` ## Features @@ -18,13 +43,13 @@ A CLI tool for managing and sending Cursor credits to event attendees. Data live - Upload and track Cursor credit codes - Send personalized emails with credit codes using Resend (when configured) - Track credit status (available, assigned, sent, redeemed) -- **Luma check-in automation**: webhook server verifies check-in via the Luma API and sends a credit email +- **Luma + API**: register webhooks and verify check-in through the official Luma API ## Prerequisites - [Bun](https://bun.sh) (v1.0 or later) -- [Resend](https://resend.com) account with verified domain (optional, for email delivery) -- [Luma Plus](https://luma.com/pricing) and a Luma API key (optional, for webhook automation) +- [Resend](https://resend.com) with a verified domain (optional, for sending credit emails) +- [Luma Plus](https://luma.com/pricing) and a Luma API key (for the check-in automation service) ## Setup @@ -41,79 +66,66 @@ cd cafe-cursor-cli bun install ``` -### 3. Run the CLI +### 3. Run the CLI (CSV + optional Resend) ```bash bun run cli ``` -By default, attendee and credit data is stored as `cafe_people.csv` and `cafe_credits.csv` in the current working directory. Set `CAFE_DATA_PATH` to use a fixed directory (recommended for the Luma webhook server). +By default, data files live in the current working directory: `cafe_people.csv`, `cafe_credits.csv`. For the Luma webhook process, set **`CAFE_DATA_PATH`** (or **`LUMA_DATA_PATH`** on the webhook server) so both the CLI and automation read/write the same folder. -### Environment variables +### Environment variables (CLI) | Variable | Purpose | |----------|---------| -| `CAFE_DATA_PATH` | Directory for `cafe_people.csv`, `cafe_credits.csv`, and `cafe_luma_sent_guests.txt` | +| `CAFE_DATA_PATH` | Directory for `cafe_people.csv`, `cafe_credits.csv`, and (if used) `cafe_luma_sent_guests.txt` | | `RESEND_API_KEY` | Send credit emails via Resend | | `RESEND_FROM_EMAIL` | Verified sender address for Resend | -If Resend is not configured, the CLI and webhook still assign credits in CSV only (no outbound email). - -## Luma check-in webhook (API-first for operators) +Without Resend, credits are still assigned in CSV; no email is sent. -Integration is **server-to-server**: your deployment holds a [Luma API key](https://docs.luma.com/reference/getting-started-with-your-api) and optional Resend keys. End users do not configure Luma; they only receive the credit email after check-in. +## Luma API: operator setup (check-in automation) -Luma does **not** list a separate webhook type only for check-in. Per [Luma’s webhooks help](https://help.lu.ma/p/webhooks), **Guest Updated** fires when check-in status (and other guest fields) change, with payload type `guest.updated`. This server listens **only** for `guest.updated` and still **re-checks** check-in via the API before sending a code, so profile-only updates do not trigger a credit. +### 1. Start the webhook HTTP server -This repo includes a small HTTP server that: - -1. Verifies the [Luma webhook signature](https://help.lu.ma/p/webhooks) -2. Confirms the guest is checked in using `GET /v1/event/get-guest` ([Luma API](https://docs.luma.com/reference/getting-started-with-your-api)) (per-ticket `checked_in_at`) -3. Sends one Cursor credit email per event + guest (deduped via `cafe_luma_sent_guests.txt`) +```bash +bun run luma:webhook +``` -### Register the webhook via Luma API (recommended) +Expose it on the public internet (HTTPS in production). Recommended path: **`https://your-host/luma/check-in`** (also accepts `/luma/webhook`, `/luma/webhook/check-in`, `/`). -Operators run this once (uses [`POST /v1/webhooks/create`](https://docs.luma.com/reference/post_v1-webhooks-create) with `event_types: ["guest.updated"]`): +### 2. Register the webhook using the Luma API ```bash -# Public URL must reach your running webhook server (tunnel or real host) LUMA_API_KEY=your_key bun run luma:register-webhook https://your-host/luma/check-in ``` -The command prints `LUMA_WEBHOOK_SECRET=whsec_...` — set that on the server that runs `luma:webhook`. List existing webhooks: +This calls **`POST /v1/webhooks/create`** with `guest.updated` only. Copy the printed **`LUMA_WEBHOOK_SECRET=whsec_...`** into the environment of the machine running `luma:webhook`. + +List webhooks already on your calendar: ```bash LUMA_API_KEY=your_key bun run luma:register-webhook list ``` -Alternatively, you can create the same webhook manually in Luma: **Settings → Developer → Webhooks**, action **Guest Updated** only. +You can instead create the same webhook in Luma: **Settings → Developer → Webhooks** → **Guest Updated**. -### Webhook server env +### Webhook server environment | Variable | Required | Description | |----------|----------|-------------| -| `LUMA_API_KEY` | Yes | `x-luma-api-key` for API calls (webhook handler + optional registration) | -| `LUMA_WEBHOOK_SECRET` | Yes | `whsec_...` from `luma:register-webhook` output or Luma dashboard | -| `LUMA_DATA_PATH` | No | Same role as `CAFE_DATA_PATH` (defaults to cwd) | +| `LUMA_API_KEY` | Yes | Same key as `x-luma-api-key` for outbound `get-guest` calls | +| `LUMA_WEBHOOK_SECRET` | Yes | `whsec_...` from registration response or Luma UI | +| `LUMA_DATA_PATH` | No | Data directory (defaults to cwd); align with `CAFE_DATA_PATH` for shared CSVs | | `LUMA_WEBHOOK_PORT` | No | Listen port (default `3847`) | | `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | For email | Same as CLI | -### Run the webhook server - -```bash -bun run luma:webhook -``` - -Expose a URL publicly (for example via a tunnel). Recommended path: **`http://your-host:/luma/check-in`**. The same server also accepts **`/luma/webhook`**, **`/luma/webhook/check-in`**, and **`/`** for backwards compatibility. - -## Usage - -### Main menu +## Usage (CLI menu) -1. **Send Cursor Credits** - Browse attendees and send or assign credits -2. **Upload Cursor Credits** - Import credit codes from a CSV file -3. **Upload Attendees** - Import attendees from a CSV file -4. **Check Credit Redemptions** - Check sent codes against Cursor +1. **Send Cursor Credits** – Browse attendees and send or assign credits +2. **Upload Cursor Credits** – Import credit codes from a CSV file +3. **Upload Attendees** – Import attendees from a CSV file +4. **Check Credit Redemptions** – Check sent codes against Cursor ### CSV formats @@ -152,16 +164,19 @@ bun run build ``` cafe-cursor-cli/ ├── src/ -│ ├── cli.tsx # Main CLI entry point -│ ├── lumaWebhookServer.ts # Luma guest.updated webhook HTTP server -│ ├── registerLumaCheckInWebhook.ts # CLI: POST /v1/webhooks/create -│ ├── luma/ # Luma API client, webhooks admin, signatures -│ ├── services/ # Shared send-credit logic (CLI + webhook) -│ ├── screens/ # CLI screens -│ ├── components/ # Reusable components -│ ├── context/ # Storage path context -│ ├── hooks/ # CSV-backed data hooks -│ ├── emails/ # Email templates -│ └── utils/ # CSV storage and helpers -└── test/ # Tests +│ ├── cli.tsx # Interactive CLI (CSV + Resend) +│ ├── lumaWebhookServer.ts # HTTP receiver + Luma signature verify + get-guest +│ ├── registerLumaCheckInWebhook.ts # Operator CLI → POST /v1/webhooks/create +│ ├── luma/ +│ │ ├── lumaClient.ts # GET /v1/event/get-guest +│ │ ├── webhooksAdmin.ts # POST create / GET list webhooks +│ │ └── verifyWebhookSignature.ts # Inbound HMAC verification +│ ├── services/ # Shared send-credit logic (CLI + webhook) +│ ├── screens/ +│ ├── components/ +│ ├── context/ +│ ├── hooks/ +│ ├── emails/ +│ └── utils/ +└── test/ ``` From ab4cd28d6f8fb4b8ea752fff5f30335dedda73fc Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 6 Apr 2026 22:32:42 +0000 Subject: [PATCH 7/7] docs: state local-only storage; drop database wording in UI comments Co-authored-by: Alhwyn --- README.md | 13 ++++++++----- src/screens/CheckRedemptions.tsx | 2 +- src/screens/SendCredits.tsx | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 439d0a3..9f1a21c 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,12 @@ # Cafe Cursor CLI [![CI](https://github.com/Alhwyn/cafe-cursor-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Alhwyn/cafe-cursor-cli/actions/workflows/ci.yml) +**Storage is local only** – attendees, credits, and Luma dedupe state live in CSV/text files under `CAFE_DATA_PATH` (or the process working directory). There is no cloud database or sync layer in this app. + Two pieces work together: -1. **Interactive CLI** – manage attendees and Cursor credit codes in CSV files, send credits manually (optional [Resend](https://resend.com) email). -2. **Luma API integration** – a small HTTP service that talks to [Luma’s public API](https://docs.luma.com/reference/getting-started-with-your-api) so **check-in on Luma** can trigger an automatic credit (again via Resend when configured). +1. **Interactive CLI** – manage attendees and Cursor credit codes in those local files; send credits manually (optional [Resend](https://resend.com) email). +2. **Luma API integration** – a small HTTP service that talks to [Luma’s public API](https://docs.luma.com/reference/getting-started-with-your-api) so **check-in on Luma** can trigger an automatic credit (again via Resend when configured). That service still reads and writes the same local files. Event attendees never use the Luma API or this repo; operators hold the Luma API key and run the services. @@ -39,6 +41,7 @@ OpenAPI spec (for exploring all endpoints): `https://public-api.luma.com/openapi ## Features +- **Local-first data** – `cafe_people.csv`, `cafe_credits.csv`, `cafe_luma_sent_guests.txt` (no hosted backend) - Upload and manage attendee lists from CSV - Upload and track Cursor credit codes - Send personalized emails with credit codes using Resend (when configured) @@ -66,13 +69,13 @@ cd cafe-cursor-cli bun install ``` -### 3. Run the CLI (CSV + optional Resend) +### 3. Run the CLI (local CSV + optional Resend) ```bash bun run cli ``` -By default, data files live in the current working directory: `cafe_people.csv`, `cafe_credits.csv`. For the Luma webhook process, set **`CAFE_DATA_PATH`** (or **`LUMA_DATA_PATH`** on the webhook server) so both the CLI and automation read/write the same folder. +By default, data files live in the current working directory: `cafe_people.csv`, `cafe_credits.csv`. For the Luma webhook process, set **`CAFE_DATA_PATH`** (or **`LUMA_DATA_PATH`** on the webhook server) so the CLI and the webhook automation use the **same** local folder. ### Environment variables (CLI) @@ -164,7 +167,7 @@ bun run build ``` cafe-cursor-cli/ ├── src/ -│ ├── cli.tsx # Interactive CLI (CSV + Resend) +│ ├── cli.tsx # Interactive CLI (local CSV + optional Resend) │ ├── lumaWebhookServer.ts # HTTP receiver + Luma signature verify + get-guest │ ├── registerLumaCheckInWebhook.ts # Operator CLI → POST /v1/webhooks/create │ ├── luma/ diff --git a/src/screens/CheckRedemptions.tsx b/src/screens/CheckRedemptions.tsx index c507142..cbbc34b 100644 --- a/src/screens/CheckRedemptions.tsx +++ b/src/screens/CheckRedemptions.tsx @@ -72,7 +72,7 @@ const CheckRedemptions = ({ onBack }: CheckRedemptionsProps) => { let status: "redeemed" | "active" | "unknown"; if (result.redeemed) { status = "redeemed"; - // Update database + // Persist redeemed status to local CSV await markRedeemed(credit.id); } else if (result.available) { status = "active"; diff --git a/src/screens/SendCredits.tsx b/src/screens/SendCredits.tsx index 6fd130a..9990ee7 100644 --- a/src/screens/SendCredits.tsx +++ b/src/screens/SendCredits.tsx @@ -48,7 +48,7 @@ const SendCredits = ({ onBack }: SendCreditsProps) => { onBack(); }; - // Transform database people to Contact format + // Map stored people to contact rows for the table const contacts: Contact[] = useMemo(() => { if (!people) return []; return people.map((person) => ({