refactor(convex): add Convex backend schema and functions
- commit
- d01ad39b89be7a90c8f489ebde036703e4b456bf
- author
- bnema · · 2026-01-18 18:34 UTC
- parent
- ececade
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
17 files · +1550 -0
A convex.json +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | { | |
| 2 | + | "functions": "src/convex/" | |
| 3 | + | } | |
| @@ -0,0 +1,3 @@ | |||||
| 1 | + | { | |||
| 2 | + | "functions": "src/convex/" | |||
| 3 | + | } | |||
A src/convex/README.md +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | # Welcome to your Convex functions directory! | |
| 2 | + | ||
| 3 | + | Write your Convex functions here. | |
| 4 | + | See https://docs.convex.dev/functions for more. | |
| 5 | + | ||
| 6 | + | A query function that takes two arguments looks like: | |
| 7 | + | ||
| 8 | + | ```ts | |
| 9 | + | // convex/myFunctions.ts | |
| 10 | + | import { query } from "./_generated/server"; | |
| 11 | + | import { v } from "convex/values"; | |
| 12 | + | ||
| 13 | + | export const myQueryFunction = query({ | |
| 14 | + | // Validators for arguments. | |
| 15 | + | args: { | |
| 16 | + | first: v.number(), | |
| 17 | + | second: v.string(), | |
| 18 | + | }, | |
| 19 | + | ||
| 20 | + | // Function implementation. | |
| 21 | + | handler: async (ctx, args) => { | |
| 22 | + | // Read the database as many times as you need here. | |
| 23 | + | // See https://docs.convex.dev/database/reading-data. | |
| 24 | + | const documents = await ctx.db.query("tablename").collect(); | |
| 25 | + | ||
| 26 | + | // Arguments passed from the client are properties of the args object. | |
| 27 | + | console.log(args.first, args.second); | |
| 28 | + | ||
| 29 | + | // Write arbitrary JavaScript here: filter, aggregate, build derived data, | |
| 30 | + | // remove non-public properties, or create new objects. | |
| 31 | + | return documents; | |
| 32 | + | }, | |
| 33 | + | }); | |
| 34 | + | ``` | |
| 35 | + | ||
| 36 | + | Using this query function in a React component looks like: | |
| 37 | + | ||
| 38 | + | ```ts | |
| 39 | + | const data = useQuery(api.myFunctions.myQueryFunction, { | |
| 40 | + | first: 10, | |
| 41 | + | second: "hello", | |
| 42 | + | }); | |
| 43 | + | ``` | |
| 44 | + | ||
| 45 | + | A mutation function looks like: | |
| 46 | + | ||
| 47 | + | ```ts | |
| 48 | + | // convex/myFunctions.ts | |
| 49 | + | import { mutation } from "./_generated/server"; | |
| 50 | + | import { v } from "convex/values"; | |
| 51 | + | ||
| 52 | + | export const myMutationFunction = mutation({ | |
| 53 | + | // Validators for arguments. | |
| 54 | + | args: { | |
| 55 | + | first: v.string(), | |
| 56 | + | second: v.string(), | |
| 57 | + | }, | |
| 58 | + | ||
| 59 | + | // Function implementation. | |
| 60 | + | handler: async (ctx, args) => { | |
| 61 | + | // Insert or modify documents in the database here. | |
| 62 | + | // Mutations can also read from the database like queries. | |
| 63 | + | // See https://docs.convex.dev/database/writing-data. | |
| 64 | + | const message = { body: args.first, author: args.second }; | |
| 65 | + | const id = await ctx.db.insert("messages", message); | |
| 66 | + | ||
| 67 | + | // Optionally, return a value from your mutation. | |
| 68 | + | return await ctx.db.get("messages", id); | |
| 69 | + | }, | |
| 70 | + | }); | |
| 71 | + | ``` | |
| 72 | + | ||
| 73 | + | Using this mutation function in a React component looks like: | |
| 74 | + | ||
| 75 | + | ```ts | |
| 76 | + | const mutation = useMutation(api.myFunctions.myMutationFunction); | |
| 77 | + | function handleButtonPress() { | |
| 78 | + | // fire and forget, the most common way to use mutations | |
| 79 | + | mutation({ first: "Hello!", second: "me" }); | |
| 80 | + | // OR | |
| 81 | + | // use the result once the mutation has completed | |
| 82 | + | mutation({ first: "Hello!", second: "me" }).then((result) => | |
| 83 | + | console.log(result), | |
| 84 | + | ); | |
| 85 | + | } | |
| 86 | + | ``` | |
| 87 | + | ||
| 88 | + | Use the Convex CLI to push your functions to a deployment. See everything | |
| 89 | + | the Convex CLI can do by running `npx convex -h` in your project root | |
| 90 | + | directory. To learn more, launch the docs with `npx convex docs`. | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | # Welcome to your Convex functions directory! | |||
| 2 | + | ||||
| 3 | + | Write your Convex functions here. | |||
| 4 | + | See https://docs.convex.dev/functions for more. | |||
| 5 | + | ||||
| 6 | + | A query function that takes two arguments looks like: | |||
| 7 | + | ||||
| 8 | + | ```ts | |||
| 9 | + | // convex/myFunctions.ts | |||
| 10 | + | import { query } from "./_generated/server"; | |||
| 11 | + | import { v } from "convex/values"; | |||
| 12 | + | ||||
| 13 | + | export const myQueryFunction = query({ | |||
| 14 | + | // Validators for arguments. | |||
| 15 | + | args: { | |||
| 16 | + | first: v.number(), | |||
| 17 | + | second: v.string(), | |||
| 18 | + | }, | |||
| 19 | + | ||||
| 20 | + | // Function implementation. | |||
| 21 | + | handler: async (ctx, args) => { | |||
| 22 | + | // Read the database as many times as you need here. | |||
| 23 | + | // See https://docs.convex.dev/database/reading-data. | |||
| 24 | + | const documents = await ctx.db.query("tablename").collect(); | |||
| 25 | + | ||||
| 26 | + | // Arguments passed from the client are properties of the args object. | |||
| 27 | + | console.log(args.first, args.second); | |||
| 28 | + | ||||
| 29 | + | // Write arbitrary JavaScript here: filter, aggregate, build derived data, | |||
| 30 | + | // remove non-public properties, or create new objects. | |||
| 31 | + | return documents; | |||
| 32 | + | }, | |||
| 33 | + | }); | |||
| 34 | + | ``` | |||
| 35 | + | ||||
| 36 | + | Using this query function in a React component looks like: | |||
| 37 | + | ||||
| 38 | + | ```ts | |||
| 39 | + | const data = useQuery(api.myFunctions.myQueryFunction, { | |||
| 40 | + | first: 10, | |||
| 41 | + | second: "hello", | |||
| 42 | + | }); | |||
| 43 | + | ``` | |||
| 44 | + | ||||
| 45 | + | A mutation function looks like: | |||
| 46 | + | ||||
| 47 | + | ```ts | |||
| 48 | + | // convex/myFunctions.ts | |||
| 49 | + | import { mutation } from "./_generated/server"; | |||
| 50 | + | import { v } from "convex/values"; | |||
| 51 | + | ||||
| 52 | + | export const myMutationFunction = mutation({ | |||
| 53 | + | // Validators for arguments. | |||
| 54 | + | args: { | |||
| 55 | + | first: v.string(), | |||
| 56 | + | second: v.string(), | |||
| 57 | + | }, | |||
| 58 | + | ||||
| 59 | + | // Function implementation. | |||
| 60 | + | handler: async (ctx, args) => { | |||
| 61 | + | // Insert or modify documents in the database here. | |||
| 62 | + | // Mutations can also read from the database like queries. | |||
| 63 | + | // See https://docs.convex.dev/database/writing-data. | |||
| 64 | + | const message = { body: args.first, author: args.second }; | |||
| 65 | + | const id = await ctx.db.insert("messages", message); | |||
| 66 | + | ||||
| 67 | + | // Optionally, return a value from your mutation. | |||
| 68 | + | return await ctx.db.get("messages", id); | |||
| 69 | + | }, | |||
| 70 | + | }); | |||
| 71 | + | ``` | |||
| 72 | + | ||||
| 73 | + | Using this mutation function in a React component looks like: | |||
| 74 | + | ||||
| 75 | + | ```ts | |||
| 76 | + | const mutation = useMutation(api.myFunctions.myMutationFunction); | |||
| 77 | + | function handleButtonPress() { | |||
| 78 | + | // fire and forget, the most common way to use mutations | |||
| 79 | + | mutation({ first: "Hello!", second: "me" }); | |||
| 80 | + | // OR | |||
| 81 | + | // use the result once the mutation has completed | |||
| 82 | + | mutation({ first: "Hello!", second: "me" }).then((result) => | |||
| 83 | + | console.log(result), | |||
| 84 | + | ); | |||
| 85 | + | } | |||
| 86 | + | ``` | |||
| 87 | + | ||||
| 88 | + | Use the Convex CLI to push your functions to a deployment. See everything | |||
| 89 | + | the Convex CLI can do by running `npx convex -h` in your project root | |||
| 90 | + | directory. To learn more, launch the docs with `npx convex docs`. | |||
A src/convex/_generated/api.d.ts +63 -0
| @@ -0,0 +1,63 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated `api` utility. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import type * as auth from "../auth.js"; | |
| 12 | + | import type * as chatRoomsInternal from "../chatRoomsInternal.js"; | |
| 13 | + | import type * as http from "../http.js"; | |
| 14 | + | import type * as messagesInternal from "../messagesInternal.js"; | |
| 15 | + | import type * as mutations from "../mutations.js"; | |
| 16 | + | import type * as queries from "../queries.js"; | |
| 17 | + | import type * as sessionsInternal from "../sessionsInternal.js"; | |
| 18 | + | import type * as usersInternal from "../usersInternal.js"; | |
| 19 | + | ||
| 20 | + | import type { | |
| 21 | + | ApiFromModules, | |
| 22 | + | FilterApi, | |
| 23 | + | FunctionReference, | |
| 24 | + | } from "convex/server"; | |
| 25 | + | ||
| 26 | + | declare const fullApi: ApiFromModules<{ | |
| 27 | + | auth: typeof auth; | |
| 28 | + | chatRoomsInternal: typeof chatRoomsInternal; | |
| 29 | + | http: typeof http; | |
| 30 | + | messagesInternal: typeof messagesInternal; | |
| 31 | + | mutations: typeof mutations; | |
| 32 | + | queries: typeof queries; | |
| 33 | + | sessionsInternal: typeof sessionsInternal; | |
| 34 | + | usersInternal: typeof usersInternal; | |
| 35 | + | }>; | |
| 36 | + | ||
| 37 | + | /** | |
| 38 | + | * A utility for referencing Convex functions in your app's public API. | |
| 39 | + | * | |
| 40 | + | * Usage: | |
| 41 | + | * ```js | |
| 42 | + | * const myFunctionReference = api.myModule.myFunction; | |
| 43 | + | * ``` | |
| 44 | + | */ | |
| 45 | + | export declare const api: FilterApi< | |
| 46 | + | typeof fullApi, | |
| 47 | + | FunctionReference<any, "public"> | |
| 48 | + | >; | |
| 49 | + | ||
| 50 | + | /** | |
| 51 | + | * A utility for referencing Convex functions in your app's internal API. | |
| 52 | + | * | |
| 53 | + | * Usage: | |
| 54 | + | * ```js | |
| 55 | + | * const myFunctionReference = internal.myModule.myFunction; | |
| 56 | + | * ``` | |
| 57 | + | */ | |
| 58 | + | export declare const internal: FilterApi< | |
| 59 | + | typeof fullApi, | |
| 60 | + | FunctionReference<any, "internal"> | |
| 61 | + | >; | |
| 62 | + | ||
| 63 | + | export declare const components: {}; | |
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | /* eslint-disable */ | |||
| 2 | + | /** | |||
| 3 | + | * Generated `api` utility. | |||
| 4 | + | * | |||
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |||
| 6 | + | * | |||
| 7 | + | * To regenerate, run `npx convex dev`. | |||
| 8 | + | * @module | |||
| 9 | + | */ | |||
| 10 | + | ||||
| 11 | + | import type * as auth from "../auth.js"; | |||
| 12 | + | import type * as chatRoomsInternal from "../chatRoomsInternal.js"; | |||
| 13 | + | import type * as http from "../http.js"; | |||
| 14 | + | import type * as messagesInternal from "../messagesInternal.js"; | |||
| 15 | + | import type * as mutations from "../mutations.js"; | |||
| 16 | + | import type * as queries from "../queries.js"; | |||
| 17 | + | import type * as sessionsInternal from "../sessionsInternal.js"; | |||
| 18 | + | import type * as usersInternal from "../usersInternal.js"; | |||
| 19 | + | ||||
| 20 | + | import type { | |||
| 21 | + | ApiFromModules, | |||
| 22 | + | FilterApi, | |||
| 23 | + | FunctionReference, | |||
| 24 | + | } from "convex/server"; | |||
| 25 | + | ||||
| 26 | + | declare const fullApi: ApiFromModules<{ | |||
| 27 | + | auth: typeof auth; | |||
| 28 | + | chatRoomsInternal: typeof chatRoomsInternal; | |||
| 29 | + | http: typeof http; | |||
| 30 | + | messagesInternal: typeof messagesInternal; | |||
| 31 | + | mutations: typeof mutations; | |||
| 32 | + | queries: typeof queries; | |||
| 33 | + | sessionsInternal: typeof sessionsInternal; | |||
| 34 | + | usersInternal: typeof usersInternal; | |||
| 35 | + | }>; | |||
| 36 | + | ||||
| 37 | + | /** | |||
| 38 | + | * A utility for referencing Convex functions in your app's public API. | |||
| 39 | + | * | |||
| 40 | + | * Usage: | |||
| 41 | + | * ```js | |||
| 42 | + | * const myFunctionReference = api.myModule.myFunction; | |||
| 43 | + | * ``` | |||
| 44 | + | */ | |||
| 45 | + | export declare const api: FilterApi< | |||
| 46 | + | typeof fullApi, | |||
| 47 | + | FunctionReference<any, "public"> | |||
| 48 | + | >; | |||
| 49 | + | ||||
| 50 | + | /** | |||
| 51 | + | * A utility for referencing Convex functions in your app's internal API. | |||
| 52 | + | * | |||
| 53 | + | * Usage: | |||
| 54 | + | * ```js | |||
| 55 | + | * const myFunctionReference = internal.myModule.myFunction; | |||
| 56 | + | * ``` | |||
| 57 | + | */ | |||
| 58 | + | export declare const internal: FilterApi< | |||
| 59 | + | typeof fullApi, | |||
| 60 | + | FunctionReference<any, "internal"> | |||
| 61 | + | >; | |||
| 62 | + | ||||
| 63 | + | export declare const components: {}; | |||
A src/convex/_generated/api.js +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated `api` utility. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import { anyApi, componentsGeneric } from "convex/server"; | |
| 12 | + | ||
| 13 | + | /** | |
| 14 | + | * A utility for referencing Convex functions in your app's API. | |
| 15 | + | * | |
| 16 | + | * Usage: | |
| 17 | + | * ```js | |
| 18 | + | * const myFunctionReference = api.myModule.myFunction; | |
| 19 | + | * ``` | |
| 20 | + | */ | |
| 21 | + | export const api = anyApi; | |
| 22 | + | export const internal = anyApi; | |
| 23 | + | export const components = componentsGeneric(); | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | /* eslint-disable */ | |||
| 2 | + | /** | |||
| 3 | + | * Generated `api` utility. | |||
| 4 | + | * | |||
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |||
| 6 | + | * | |||
| 7 | + | * To regenerate, run `npx convex dev`. | |||
| 8 | + | * @module | |||
| 9 | + | */ | |||
| 10 | + | ||||
| 11 | + | import { anyApi, componentsGeneric } from "convex/server"; | |||
| 12 | + | ||||
| 13 | + | /** | |||
| 14 | + | * A utility for referencing Convex functions in your app's API. | |||
| 15 | + | * | |||
| 16 | + | * Usage: | |||
| 17 | + | * ```js | |||
| 18 | + | * const myFunctionReference = api.myModule.myFunction; | |||
| 19 | + | * ``` | |||
| 20 | + | */ | |||
| 21 | + | export const api = anyApi; | |||
| 22 | + | export const internal = anyApi; | |||
| 23 | + | export const components = componentsGeneric(); | |||
A src/convex/_generated/dataModel.d.ts +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated data model types. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import type { | |
| 12 | + | DataModelFromSchemaDefinition, | |
| 13 | + | DocumentByName, | |
| 14 | + | TableNamesInDataModel, | |
| 15 | + | SystemTableNames, | |
| 16 | + | } from "convex/server"; | |
| 17 | + | import type { GenericId } from "convex/values"; | |
| 18 | + | import schema from "../schema.js"; | |
| 19 | + | ||
| 20 | + | /** | |
| 21 | + | * The names of all of your Convex tables. | |
| 22 | + | */ | |
| 23 | + | export type TableNames = TableNamesInDataModel<DataModel>; | |
| 24 | + | ||
| 25 | + | /** | |
| 26 | + | * The type of a document stored in Convex. | |
| 27 | + | * | |
| 28 | + | * @typeParam TableName - A string literal type of the table name (like "users"). | |
| 29 | + | */ | |
| 30 | + | export type Doc<TableName extends TableNames> = DocumentByName< | |
| 31 | + | DataModel, | |
| 32 | + | TableName | |
| 33 | + | >; | |
| 34 | + | ||
| 35 | + | /** | |
| 36 | + | * An identifier for a document in Convex. | |
| 37 | + | * | |
| 38 | + | * Convex documents are uniquely identified by their `Id`, which is accessible | |
| 39 | + | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). | |
| 40 | + | * | |
| 41 | + | * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. | |
| 42 | + | * | |
| 43 | + | * IDs are just strings at runtime, but this type can be used to distinguish them from other | |
| 44 | + | * strings when type checking. | |
| 45 | + | * | |
| 46 | + | * @typeParam TableName - A string literal type of the table name (like "users"). | |
| 47 | + | */ | |
| 48 | + | export type Id<TableName extends TableNames | SystemTableNames> = | |
| 49 | + | GenericId<TableName>; | |
| 50 | + | ||
| 51 | + | /** | |
| 52 | + | * A type describing your Convex data model. | |
| 53 | + | * | |
| 54 | + | * This type includes information about what tables you have, the type of | |
| 55 | + | * documents stored in those tables, and the indexes defined on them. | |
| 56 | + | * | |
| 57 | + | * This type is used to parameterize methods like `queryGeneric` and | |
| 58 | + | * `mutationGeneric` to make them type-safe. | |
| 59 | + | */ | |
| 60 | + | export type DataModel = DataModelFromSchemaDefinition<typeof schema>; | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | /* eslint-disable */ | |||
| 2 | + | /** | |||
| 3 | + | * Generated data model types. | |||
| 4 | + | * | |||
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |||
| 6 | + | * | |||
| 7 | + | * To regenerate, run `npx convex dev`. | |||
| 8 | + | * @module | |||
| 9 | + | */ | |||
| 10 | + | ||||
| 11 | + | import type { | |||
| 12 | + | DataModelFromSchemaDefinition, | |||
| 13 | + | DocumentByName, | |||
| 14 | + | TableNamesInDataModel, | |||
| 15 | + | SystemTableNames, | |||
| 16 | + | } from "convex/server"; | |||
| 17 | + | import type { GenericId } from "convex/values"; | |||
| 18 | + | import schema from "../schema.js"; | |||
| 19 | + | ||||
| 20 | + | /** | |||
| 21 | + | * The names of all of your Convex tables. | |||
| 22 | + | */ | |||
| 23 | + | export type TableNames = TableNamesInDataModel<DataModel>; | |||
| 24 | + | ||||
| 25 | + | /** | |||
| 26 | + | * The type of a document stored in Convex. | |||
| 27 | + | * | |||
| 28 | + | * @typeParam TableName - A string literal type of the table name (like "users"). | |||
| 29 | + | */ | |||
| 30 | + | export type Doc<TableName extends TableNames> = DocumentByName< | |||
| 31 | + | DataModel, | |||
| 32 | + | TableName | |||
| 33 | + | >; | |||
| 34 | + | ||||
| 35 | + | /** | |||
| 36 | + | * An identifier for a document in Convex. | |||
| 37 | + | * | |||
| 38 | + | * Convex documents are uniquely identified by their `Id`, which is accessible | |||
| 39 | + | * on the `_id` field. To learn more, see [Document IDs](https://docs.convex.dev/using/document-ids). | |||
| 40 | + | * | |||
| 41 | + | * Documents can be loaded using `db.get(tableName, id)` in query and mutation functions. | |||
| 42 | + | * | |||
| 43 | + | * IDs are just strings at runtime, but this type can be used to distinguish them from other | |||
| 44 | + | * strings when type checking. | |||
| 45 | + | * | |||
| 46 | + | * @typeParam TableName - A string literal type of the table name (like "users"). | |||
| 47 | + | */ | |||
| 48 | + | export type Id<TableName extends TableNames | SystemTableNames> = | |||
| 49 | + | GenericId<TableName>; | |||
| 50 | + | ||||
| 51 | + | /** | |||
| 52 | + | * A type describing your Convex data model. | |||
| 53 | + | * | |||
| 54 | + | * This type includes information about what tables you have, the type of | |||
| 55 | + | * documents stored in those tables, and the indexes defined on them. | |||
| 56 | + | * | |||
| 57 | + | * This type is used to parameterize methods like `queryGeneric` and | |||
| 58 | + | * `mutationGeneric` to make them type-safe. | |||
| 59 | + | */ | |||
| 60 | + | export type DataModel = DataModelFromSchemaDefinition<typeof schema>; | |||
A src/convex/_generated/server.d.ts +143 -0
| @@ -0,0 +1,143 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated utilities for implementing server-side Convex query and mutation functions. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import { | |
| 12 | + | ActionBuilder, | |
| 13 | + | HttpActionBuilder, | |
| 14 | + | MutationBuilder, | |
| 15 | + | QueryBuilder, | |
| 16 | + | GenericActionCtx, | |
| 17 | + | GenericMutationCtx, | |
| 18 | + | GenericQueryCtx, | |
| 19 | + | GenericDatabaseReader, | |
| 20 | + | GenericDatabaseWriter, | |
| 21 | + | } from "convex/server"; | |
| 22 | + | import type { DataModel } from "./dataModel.js"; | |
| 23 | + | ||
| 24 | + | /** | |
| 25 | + | * Define a query in this Convex app's public API. | |
| 26 | + | * | |
| 27 | + | * This function will be allowed to read your Convex database and will be accessible from the client. | |
| 28 | + | * | |
| 29 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 30 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 31 | + | */ | |
| 32 | + | export declare const query: QueryBuilder<DataModel, "public">; | |
| 33 | + | ||
| 34 | + | /** | |
| 35 | + | * Define a query that is only accessible from other Convex functions (but not from the client). | |
| 36 | + | * | |
| 37 | + | * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |
| 38 | + | * | |
| 39 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 40 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 41 | + | */ | |
| 42 | + | export declare const internalQuery: QueryBuilder<DataModel, "internal">; | |
| 43 | + | ||
| 44 | + | /** | |
| 45 | + | * Define a mutation in this Convex app's public API. | |
| 46 | + | * | |
| 47 | + | * This function will be allowed to modify your Convex database and will be accessible from the client. | |
| 48 | + | * | |
| 49 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 50 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 51 | + | */ | |
| 52 | + | export declare const mutation: MutationBuilder<DataModel, "public">; | |
| 53 | + | ||
| 54 | + | /** | |
| 55 | + | * Define a mutation that is only accessible from other Convex functions (but not from the client). | |
| 56 | + | * | |
| 57 | + | * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |
| 58 | + | * | |
| 59 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 60 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 61 | + | */ | |
| 62 | + | export declare const internalMutation: MutationBuilder<DataModel, "internal">; | |
| 63 | + | ||
| 64 | + | /** | |
| 65 | + | * Define an action in this Convex app's public API. | |
| 66 | + | * | |
| 67 | + | * An action is a function which can execute any JavaScript code, including non-deterministic | |
| 68 | + | * code and code with side-effects, like calling third-party services. | |
| 69 | + | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |
| 70 | + | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |
| 71 | + | * | |
| 72 | + | * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |
| 73 | + | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |
| 74 | + | */ | |
| 75 | + | export declare const action: ActionBuilder<DataModel, "public">; | |
| 76 | + | ||
| 77 | + | /** | |
| 78 | + | * Define an action that is only accessible from other Convex functions (but not from the client). | |
| 79 | + | * | |
| 80 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |
| 81 | + | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |
| 82 | + | */ | |
| 83 | + | export declare const internalAction: ActionBuilder<DataModel, "internal">; | |
| 84 | + | ||
| 85 | + | /** | |
| 86 | + | * Define an HTTP action. | |
| 87 | + | * | |
| 88 | + | * The wrapped function will be used to respond to HTTP requests received | |
| 89 | + | * by a Convex deployment if the requests matches the path and method where | |
| 90 | + | * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |
| 91 | + | * | |
| 92 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument | |
| 93 | + | * and a Fetch API `Request` object as its second. | |
| 94 | + | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |
| 95 | + | */ | |
| 96 | + | export declare const httpAction: HttpActionBuilder; | |
| 97 | + | ||
| 98 | + | /** | |
| 99 | + | * A set of services for use within Convex query functions. | |
| 100 | + | * | |
| 101 | + | * The query context is passed as the first argument to any Convex query | |
| 102 | + | * function run on the server. | |
| 103 | + | * | |
| 104 | + | * This differs from the {@link MutationCtx} because all of the services are | |
| 105 | + | * read-only. | |
| 106 | + | */ | |
| 107 | + | export type QueryCtx = GenericQueryCtx<DataModel>; | |
| 108 | + | ||
| 109 | + | /** | |
| 110 | + | * A set of services for use within Convex mutation functions. | |
| 111 | + | * | |
| 112 | + | * The mutation context is passed as the first argument to any Convex mutation | |
| 113 | + | * function run on the server. | |
| 114 | + | */ | |
| 115 | + | export type MutationCtx = GenericMutationCtx<DataModel>; | |
| 116 | + | ||
| 117 | + | /** | |
| 118 | + | * A set of services for use within Convex action functions. | |
| 119 | + | * | |
| 120 | + | * The action context is passed as the first argument to any Convex action | |
| 121 | + | * function run on the server. | |
| 122 | + | */ | |
| 123 | + | export type ActionCtx = GenericActionCtx<DataModel>; | |
| 124 | + | ||
| 125 | + | /** | |
| 126 | + | * An interface to read from the database within Convex query functions. | |
| 127 | + | * | |
| 128 | + | * The two entry points are {@link DatabaseReader.get}, which fetches a single | |
| 129 | + | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts | |
| 130 | + | * building a query. | |
| 131 | + | */ | |
| 132 | + | export type DatabaseReader = GenericDatabaseReader<DataModel>; | |
| 133 | + | ||
| 134 | + | /** | |
| 135 | + | * An interface to read from and write to the database within Convex mutation | |
| 136 | + | * functions. | |
| 137 | + | * | |
| 138 | + | * Convex guarantees that all writes within a single mutation are | |
| 139 | + | * executed atomically, so you never have to worry about partial writes leaving | |
| 140 | + | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) | |
| 141 | + | * for the guarantees Convex provides your functions. | |
| 142 | + | */ | |
| 143 | + | export type DatabaseWriter = GenericDatabaseWriter<DataModel>; | |
| @@ -0,0 +1,143 @@ | |||||
| 1 | + | /* eslint-disable */ | |||
| 2 | + | /** | |||
| 3 | + | * Generated utilities for implementing server-side Convex query and mutation functions. | |||
| 4 | + | * | |||
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |||
| 6 | + | * | |||
| 7 | + | * To regenerate, run `npx convex dev`. | |||
| 8 | + | * @module | |||
| 9 | + | */ | |||
| 10 | + | ||||
| 11 | + | import { | |||
| 12 | + | ActionBuilder, | |||
| 13 | + | HttpActionBuilder, | |||
| 14 | + | MutationBuilder, | |||
| 15 | + | QueryBuilder, | |||
| 16 | + | GenericActionCtx, | |||
| 17 | + | GenericMutationCtx, | |||
| 18 | + | GenericQueryCtx, | |||
| 19 | + | GenericDatabaseReader, | |||
| 20 | + | GenericDatabaseWriter, | |||
| 21 | + | } from "convex/server"; | |||
| 22 | + | import type { DataModel } from "./dataModel.js"; | |||
| 23 | + | ||||
| 24 | + | /** | |||
| 25 | + | * Define a query in this Convex app's public API. | |||
| 26 | + | * | |||
| 27 | + | * This function will be allowed to read your Convex database and will be accessible from the client. | |||
| 28 | + | * | |||
| 29 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |||
| 30 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |||
| 31 | + | */ | |||
| 32 | + | export declare const query: QueryBuilder<DataModel, "public">; | |||
| 33 | + | ||||
| 34 | + | /** | |||
| 35 | + | * Define a query that is only accessible from other Convex functions (but not from the client). | |||
| 36 | + | * | |||
| 37 | + | * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |||
| 38 | + | * | |||
| 39 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |||
| 40 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |||
| 41 | + | */ | |||
| 42 | + | export declare const internalQuery: QueryBuilder<DataModel, "internal">; | |||
| 43 | + | ||||
| 44 | + | /** | |||
| 45 | + | * Define a mutation in this Convex app's public API. | |||
| 46 | + | * | |||
| 47 | + | * This function will be allowed to modify your Convex database and will be accessible from the client. | |||
| 48 | + | * | |||
| 49 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |||
| 50 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |||
| 51 | + | */ | |||
| 52 | + | export declare const mutation: MutationBuilder<DataModel, "public">; | |||
| 53 | + | ||||
| 54 | + | /** | |||
| 55 | + | * Define a mutation that is only accessible from other Convex functions (but not from the client). | |||
| 56 | + | * | |||
| 57 | + | * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |||
| 58 | + | * | |||
| 59 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |||
| 60 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |||
| 61 | + | */ | |||
| 62 | + | export declare const internalMutation: MutationBuilder<DataModel, "internal">; | |||
| 63 | + | ||||
| 64 | + | /** | |||
| 65 | + | * Define an action in this Convex app's public API. | |||
| 66 | + | * | |||
| 67 | + | * An action is a function which can execute any JavaScript code, including non-deterministic | |||
| 68 | + | * code and code with side-effects, like calling third-party services. | |||
| 69 | + | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |||
| 70 | + | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |||
| 71 | + | * | |||
| 72 | + | * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |||
| 73 | + | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |||
| 74 | + | */ | |||
| 75 | + | export declare const action: ActionBuilder<DataModel, "public">; | |||
| 76 | + | ||||
| 77 | + | /** | |||
| 78 | + | * Define an action that is only accessible from other Convex functions (but not from the client). | |||
| 79 | + | * | |||
| 80 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |||
| 81 | + | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |||
| 82 | + | */ | |||
| 83 | + | export declare const internalAction: ActionBuilder<DataModel, "internal">; | |||
| 84 | + | ||||
| 85 | + | /** | |||
| 86 | + | * Define an HTTP action. | |||
| 87 | + | * | |||
| 88 | + | * The wrapped function will be used to respond to HTTP requests received | |||
| 89 | + | * by a Convex deployment if the requests matches the path and method where | |||
| 90 | + | * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |||
| 91 | + | * | |||
| 92 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument | |||
| 93 | + | * and a Fetch API `Request` object as its second. | |||
| 94 | + | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |||
| 95 | + | */ | |||
| 96 | + | export declare const httpAction: HttpActionBuilder; | |||
| 97 | + | ||||
| 98 | + | /** | |||
| 99 | + | * A set of services for use within Convex query functions. | |||
| 100 | + | * | |||
| 101 | + | * The query context is passed as the first argument to any Convex query | |||
| 102 | + | * function run on the server. | |||
| 103 | + | * | |||
| 104 | + | * This differs from the {@link MutationCtx} because all of the services are | |||
| 105 | + | * read-only. | |||
| 106 | + | */ | |||
| 107 | + | export type QueryCtx = GenericQueryCtx<DataModel>; | |||
| 108 | + | ||||
| 109 | + | /** | |||
| 110 | + | * A set of services for use within Convex mutation functions. | |||
| 111 | + | * | |||
| 112 | + | * The mutation context is passed as the first argument to any Convex mutation | |||
| 113 | + | * function run on the server. | |||
| 114 | + | */ | |||
| 115 | + | export type MutationCtx = GenericMutationCtx<DataModel>; | |||
| 116 | + | ||||
| 117 | + | /** | |||
| 118 | + | * A set of services for use within Convex action functions. | |||
| 119 | + | * | |||
| 120 | + | * The action context is passed as the first argument to any Convex action | |||
| 121 | + | * function run on the server. | |||
| 122 | + | */ | |||
| 123 | + | export type ActionCtx = GenericActionCtx<DataModel>; | |||
| 124 | + | ||||
| 125 | + | /** | |||
| 126 | + | * An interface to read from the database within Convex query functions. | |||
| 127 | + | * | |||
| 128 | + | * The two entry points are {@link DatabaseReader.get}, which fetches a single | |||
| 129 | + | * document by its {@link Id}, or {@link DatabaseReader.query}, which starts | |||
| 130 | + | * building a query. | |||
| 131 | + | */ | |||
| 132 | + | export type DatabaseReader = GenericDatabaseReader<DataModel>; | |||
| 133 | + | ||||
| 134 | + | /** | |||
| 135 | + | * An interface to read from and write to the database within Convex mutation | |||
| 136 | + | * functions. | |||
| 137 | + | * | |||
| 138 | + | * Convex guarantees that all writes within a single mutation are | |||
| 139 | + | * executed atomically, so you never have to worry about partial writes leaving | |||
| 140 | + | * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control) | |||
| 141 | + | * for the guarantees Convex provides your functions. | |||
| 142 | + | */ | |||
| 143 | + | export type DatabaseWriter = GenericDatabaseWriter<DataModel>; | |||
A src/convex/_generated/server.js +93 -0
| @@ -0,0 +1,93 @@ | |||
| 1 | + | /* eslint-disable */ | |
| 2 | + | /** | |
| 3 | + | * Generated utilities for implementing server-side Convex query and mutation functions. | |
| 4 | + | * | |
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |
| 6 | + | * | |
| 7 | + | * To regenerate, run `npx convex dev`. | |
| 8 | + | * @module | |
| 9 | + | */ | |
| 10 | + | ||
| 11 | + | import { | |
| 12 | + | actionGeneric, | |
| 13 | + | httpActionGeneric, | |
| 14 | + | queryGeneric, | |
| 15 | + | mutationGeneric, | |
| 16 | + | internalActionGeneric, | |
| 17 | + | internalMutationGeneric, | |
| 18 | + | internalQueryGeneric, | |
| 19 | + | } from "convex/server"; | |
| 20 | + | ||
| 21 | + | /** | |
| 22 | + | * Define a query in this Convex app's public API. | |
| 23 | + | * | |
| 24 | + | * This function will be allowed to read your Convex database and will be accessible from the client. | |
| 25 | + | * | |
| 26 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 27 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 28 | + | */ | |
| 29 | + | export const query = queryGeneric; | |
| 30 | + | ||
| 31 | + | /** | |
| 32 | + | * Define a query that is only accessible from other Convex functions (but not from the client). | |
| 33 | + | * | |
| 34 | + | * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |
| 35 | + | * | |
| 36 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |
| 37 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |
| 38 | + | */ | |
| 39 | + | export const internalQuery = internalQueryGeneric; | |
| 40 | + | ||
| 41 | + | /** | |
| 42 | + | * Define a mutation in this Convex app's public API. | |
| 43 | + | * | |
| 44 | + | * This function will be allowed to modify your Convex database and will be accessible from the client. | |
| 45 | + | * | |
| 46 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 47 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 48 | + | */ | |
| 49 | + | export const mutation = mutationGeneric; | |
| 50 | + | ||
| 51 | + | /** | |
| 52 | + | * Define a mutation that is only accessible from other Convex functions (but not from the client). | |
| 53 | + | * | |
| 54 | + | * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |
| 55 | + | * | |
| 56 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |
| 57 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |
| 58 | + | */ | |
| 59 | + | export const internalMutation = internalMutationGeneric; | |
| 60 | + | ||
| 61 | + | /** | |
| 62 | + | * Define an action in this Convex app's public API. | |
| 63 | + | * | |
| 64 | + | * An action is a function which can execute any JavaScript code, including non-deterministic | |
| 65 | + | * code and code with side-effects, like calling third-party services. | |
| 66 | + | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |
| 67 | + | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |
| 68 | + | * | |
| 69 | + | * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |
| 70 | + | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |
| 71 | + | */ | |
| 72 | + | export const action = actionGeneric; | |
| 73 | + | ||
| 74 | + | /** | |
| 75 | + | * Define an action that is only accessible from other Convex functions (but not from the client). | |
| 76 | + | * | |
| 77 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |
| 78 | + | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |
| 79 | + | */ | |
| 80 | + | export const internalAction = internalActionGeneric; | |
| 81 | + | ||
| 82 | + | /** | |
| 83 | + | * Define an HTTP action. | |
| 84 | + | * | |
| 85 | + | * The wrapped function will be used to respond to HTTP requests received | |
| 86 | + | * by a Convex deployment if the requests matches the path and method where | |
| 87 | + | * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |
| 88 | + | * | |
| 89 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument | |
| 90 | + | * and a Fetch API `Request` object as its second. | |
| 91 | + | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |
| 92 | + | */ | |
| 93 | + | export const httpAction = httpActionGeneric; | |
| @@ -0,0 +1,93 @@ | |||||
| 1 | + | /* eslint-disable */ | |||
| 2 | + | /** | |||
| 3 | + | * Generated utilities for implementing server-side Convex query and mutation functions. | |||
| 4 | + | * | |||
| 5 | + | * THIS CODE IS AUTOMATICALLY GENERATED. | |||
| 6 | + | * | |||
| 7 | + | * To regenerate, run `npx convex dev`. | |||
| 8 | + | * @module | |||
| 9 | + | */ | |||
| 10 | + | ||||
| 11 | + | import { | |||
| 12 | + | actionGeneric, | |||
| 13 | + | httpActionGeneric, | |||
| 14 | + | queryGeneric, | |||
| 15 | + | mutationGeneric, | |||
| 16 | + | internalActionGeneric, | |||
| 17 | + | internalMutationGeneric, | |||
| 18 | + | internalQueryGeneric, | |||
| 19 | + | } from "convex/server"; | |||
| 20 | + | ||||
| 21 | + | /** | |||
| 22 | + | * Define a query in this Convex app's public API. | |||
| 23 | + | * | |||
| 24 | + | * This function will be allowed to read your Convex database and will be accessible from the client. | |||
| 25 | + | * | |||
| 26 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |||
| 27 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |||
| 28 | + | */ | |||
| 29 | + | export const query = queryGeneric; | |||
| 30 | + | ||||
| 31 | + | /** | |||
| 32 | + | * Define a query that is only accessible from other Convex functions (but not from the client). | |||
| 33 | + | * | |||
| 34 | + | * This function will be allowed to read from your Convex database. It will not be accessible from the client. | |||
| 35 | + | * | |||
| 36 | + | * @param func - The query function. It receives a {@link QueryCtx} as its first argument. | |||
| 37 | + | * @returns The wrapped query. Include this as an `export` to name it and make it accessible. | |||
| 38 | + | */ | |||
| 39 | + | export const internalQuery = internalQueryGeneric; | |||
| 40 | + | ||||
| 41 | + | /** | |||
| 42 | + | * Define a mutation in this Convex app's public API. | |||
| 43 | + | * | |||
| 44 | + | * This function will be allowed to modify your Convex database and will be accessible from the client. | |||
| 45 | + | * | |||
| 46 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |||
| 47 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |||
| 48 | + | */ | |||
| 49 | + | export const mutation = mutationGeneric; | |||
| 50 | + | ||||
| 51 | + | /** | |||
| 52 | + | * Define a mutation that is only accessible from other Convex functions (but not from the client). | |||
| 53 | + | * | |||
| 54 | + | * This function will be allowed to modify your Convex database. It will not be accessible from the client. | |||
| 55 | + | * | |||
| 56 | + | * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument. | |||
| 57 | + | * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible. | |||
| 58 | + | */ | |||
| 59 | + | export const internalMutation = internalMutationGeneric; | |||
| 60 | + | ||||
| 61 | + | /** | |||
| 62 | + | * Define an action in this Convex app's public API. | |||
| 63 | + | * | |||
| 64 | + | * An action is a function which can execute any JavaScript code, including non-deterministic | |||
| 65 | + | * code and code with side-effects, like calling third-party services. | |||
| 66 | + | * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive. | |||
| 67 | + | * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}. | |||
| 68 | + | * | |||
| 69 | + | * @param func - The action. It receives an {@link ActionCtx} as its first argument. | |||
| 70 | + | * @returns The wrapped action. Include this as an `export` to name it and make it accessible. | |||
| 71 | + | */ | |||
| 72 | + | export const action = actionGeneric; | |||
| 73 | + | ||||
| 74 | + | /** | |||
| 75 | + | * Define an action that is only accessible from other Convex functions (but not from the client). | |||
| 76 | + | * | |||
| 77 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument. | |||
| 78 | + | * @returns The wrapped function. Include this as an `export` to name it and make it accessible. | |||
| 79 | + | */ | |||
| 80 | + | export const internalAction = internalActionGeneric; | |||
| 81 | + | ||||
| 82 | + | /** | |||
| 83 | + | * Define an HTTP action. | |||
| 84 | + | * | |||
| 85 | + | * The wrapped function will be used to respond to HTTP requests received | |||
| 86 | + | * by a Convex deployment if the requests matches the path and method where | |||
| 87 | + | * this action is routed. Be sure to route your httpAction in `convex/http.js`. | |||
| 88 | + | * | |||
| 89 | + | * @param func - The function. It receives an {@link ActionCtx} as its first argument | |||
| 90 | + | * and a Fetch API `Request` object as its second. | |||
| 91 | + | * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up. | |||
| 92 | + | */ | |||
| 93 | + | export const httpAction = httpActionGeneric; | |||
A src/convex/auth.ts +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | import { query, mutation } from './_generated/server'; | |
| 2 | + | import { customQuery, customMutation } from 'convex-helpers/server/customFunctions'; | |
| 3 | + | import { v } from 'convex/values'; | |
| 4 | + | import type { Doc } from './_generated/dataModel'; | |
| 5 | + | import type { QueryCtx, MutationCtx } from './_generated/server'; | |
| 6 | + | ||
| 7 | + | // Helper to validate session and get user | |
| 8 | + | async function validateSession( | |
| 9 | + | ctx: QueryCtx | MutationCtx, | |
| 10 | + | tokenHash: string | |
| 11 | + | ): Promise<{ session: Doc<'sessions'>; user: Doc<'users'> }> { | |
| 12 | + | const session = await ctx.db | |
| 13 | + | .query('sessions') | |
| 14 | + | .withIndex('by_tokenHash', (q) => q.eq('tokenHash', tokenHash)) | |
| 15 | + | .first(); | |
| 16 | + | ||
| 17 | + | if (!session) { | |
| 18 | + | throw new Error('Invalid session'); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | if (session.expiresAt < Date.now()) { | |
| 22 | + | throw new Error('Session expired'); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | const user = await ctx.db.get(session.userId); | |
| 26 | + | if (!user) { | |
| 27 | + | throw new Error('User not found'); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | return { session, user }; | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | // Authenticated query - requires tokenHash, provides user in context | |
| 34 | + | export const authQuery = customQuery(query, { | |
| 35 | + | args: { tokenHash: v.string() }, | |
| 36 | + | input: async (ctx, { tokenHash }) => { | |
| 37 | + | const { session, user } = await validateSession(ctx, tokenHash); | |
| 38 | + | return { ctx: { session, user }, args: {} }; | |
| 39 | + | } | |
| 40 | + | }); | |
| 41 | + | ||
| 42 | + | // Authenticated mutation - requires tokenHash, provides user in context | |
| 43 | + | export const authMutation = customMutation(mutation, { | |
| 44 | + | args: { tokenHash: v.string() }, | |
| 45 | + | input: async (ctx, { tokenHash }) => { | |
| 46 | + | const { session, user } = await validateSession(ctx, tokenHash); | |
| 47 | + | return { ctx: { session, user }, args: {} }; | |
| 48 | + | } | |
| 49 | + | }); | |
| 50 | + | ||
| 51 | + | // Type helpers for use in handlers | |
| 52 | + | export type AuthQueryCtx = QueryCtx & { session: Doc<'sessions'>; user: Doc<'users'> }; | |
| 53 | + | export type AuthMutationCtx = MutationCtx & { session: Doc<'sessions'>; user: Doc<'users'> }; | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | import { query, mutation } from './_generated/server'; | |||
| 2 | + | import { customQuery, customMutation } from 'convex-helpers/server/customFunctions'; | |||
| 3 | + | import { v } from 'convex/values'; | |||
| 4 | + | import type { Doc } from './_generated/dataModel'; | |||
| 5 | + | import type { QueryCtx, MutationCtx } from './_generated/server'; | |||
| 6 | + | ||||
| 7 | + | // Helper to validate session and get user | |||
| 8 | + | async function validateSession( | |||
| 9 | + | ctx: QueryCtx | MutationCtx, | |||
| 10 | + | tokenHash: string | |||
| 11 | + | ): Promise<{ session: Doc<'sessions'>; user: Doc<'users'> }> { | |||
| 12 | + | const session = await ctx.db | |||
| 13 | + | .query('sessions') | |||
| 14 | + | .withIndex('by_tokenHash', (q) => q.eq('tokenHash', tokenHash)) | |||
| 15 | + | .first(); | |||
| 16 | + | ||||
| 17 | + | if (!session) { | |||
| 18 | + | throw new Error('Invalid session'); | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | if (session.expiresAt < Date.now()) { | |||
| 22 | + | throw new Error('Session expired'); | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | const user = await ctx.db.get(session.userId); | |||
| 26 | + | if (!user) { | |||
| 27 | + | throw new Error('User not found'); | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | return { session, user }; | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | // Authenticated query - requires tokenHash, provides user in context | |||
| 34 | + | export const authQuery = customQuery(query, { | |||
| 35 | + | args: { tokenHash: v.string() }, | |||
| 36 | + | input: async (ctx, { tokenHash }) => { | |||
| 37 | + | const { session, user } = await validateSession(ctx, tokenHash); | |||
| 38 | + | return { ctx: { session, user }, args: {} }; | |||
| 39 | + | } | |||
| 40 | + | }); | |||
| 41 | + | ||||
| 42 | + | // Authenticated mutation - requires tokenHash, provides user in context | |||
| 43 | + | export const authMutation = customMutation(mutation, { | |||
| 44 | + | args: { tokenHash: v.string() }, | |||
| 45 | + | input: async (ctx, { tokenHash }) => { | |||
| 46 | + | const { session, user } = await validateSession(ctx, tokenHash); | |||
| 47 | + | return { ctx: { session, user }, args: {} }; | |||
| 48 | + | } | |||
| 49 | + | }); | |||
| 50 | + | ||||
| 51 | + | // Type helpers for use in handlers | |||
| 52 | + | export type AuthQueryCtx = QueryCtx & { session: Doc<'sessions'>; user: Doc<'users'> }; | |||
| 53 | + | export type AuthMutationCtx = MutationCtx & { session: Doc<'sessions'>; user: Doc<'users'> }; | |||
A src/convex/chatRoomsInternal.ts +58 -0
| @@ -0,0 +1,58 @@ | |||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | // Get chat room by ID (internal) | |
| 5 | + | export const getById = internalQuery({ | |
| 6 | + | args: { id: v.id('chatRooms') }, | |
| 7 | + | handler: async (ctx, args) => { | |
| 8 | + | return await ctx.db.get(args.id); | |
| 9 | + | } | |
| 10 | + | }); | |
| 11 | + | ||
| 12 | + | // List all chat rooms (internal) | |
| 13 | + | export const list = internalQuery({ | |
| 14 | + | args: {}, | |
| 15 | + | handler: async (ctx) => { | |
| 16 | + | return await ctx.db.query('chatRooms').collect(); | |
| 17 | + | } | |
| 18 | + | }); | |
| 19 | + | ||
| 20 | + | // Create a chat room (internal) | |
| 21 | + | export const create = internalMutation({ | |
| 22 | + | args: { | |
| 23 | + | name: v.optional(v.string()), | |
| 24 | + | type: v.string() | |
| 25 | + | }, | |
| 26 | + | handler: async (ctx, args) => { | |
| 27 | + | const roomId = await ctx.db.insert('chatRooms', { | |
| 28 | + | name: args.name, | |
| 29 | + | type: args.type, | |
| 30 | + | createdAt: Date.now() | |
| 31 | + | }); | |
| 32 | + | ||
| 33 | + | return await ctx.db.get(roomId); | |
| 34 | + | } | |
| 35 | + | }); | |
| 36 | + | ||
| 37 | + | // Get or create the default "General" room (internal) | |
| 38 | + | export const getOrCreateDefault = internalMutation({ | |
| 39 | + | args: {}, | |
| 40 | + | handler: async (ctx) => { | |
| 41 | + | // Look for existing General room | |
| 42 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 43 | + | const generalRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |
| 44 | + | ||
| 45 | + | if (generalRoom) { | |
| 46 | + | return generalRoom; | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // Create default room | |
| 50 | + | const roomId = await ctx.db.insert('chatRooms', { | |
| 51 | + | name: 'General', | |
| 52 | + | type: 'group', | |
| 53 | + | createdAt: Date.now() | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | return await ctx.db.get(roomId); | |
| 57 | + | } | |
| 58 | + | }); | |
| @@ -0,0 +1,58 @@ | |||||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |||
| 2 | + | import { v } from 'convex/values'; | |||
| 3 | + | ||||
| 4 | + | // Get chat room by ID (internal) | |||
| 5 | + | export const getById = internalQuery({ | |||
| 6 | + | args: { id: v.id('chatRooms') }, | |||
| 7 | + | handler: async (ctx, args) => { | |||
| 8 | + | return await ctx.db.get(args.id); | |||
| 9 | + | } | |||
| 10 | + | }); | |||
| 11 | + | ||||
| 12 | + | // List all chat rooms (internal) | |||
| 13 | + | export const list = internalQuery({ | |||
| 14 | + | args: {}, | |||
| 15 | + | handler: async (ctx) => { | |||
| 16 | + | return await ctx.db.query('chatRooms').collect(); | |||
| 17 | + | } | |||
| 18 | + | }); | |||
| 19 | + | ||||
| 20 | + | // Create a chat room (internal) | |||
| 21 | + | export const create = internalMutation({ | |||
| 22 | + | args: { | |||
| 23 | + | name: v.optional(v.string()), | |||
| 24 | + | type: v.string() | |||
| 25 | + | }, | |||
| 26 | + | handler: async (ctx, args) => { | |||
| 27 | + | const roomId = await ctx.db.insert('chatRooms', { | |||
| 28 | + | name: args.name, | |||
| 29 | + | type: args.type, | |||
| 30 | + | createdAt: Date.now() | |||
| 31 | + | }); | |||
| 32 | + | ||||
| 33 | + | return await ctx.db.get(roomId); | |||
| 34 | + | } | |||
| 35 | + | }); | |||
| 36 | + | ||||
| 37 | + | // Get or create the default "General" room (internal) | |||
| 38 | + | export const getOrCreateDefault = internalMutation({ | |||
| 39 | + | args: {}, | |||
| 40 | + | handler: async (ctx) => { | |||
| 41 | + | // Look for existing General room | |||
| 42 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |||
| 43 | + | const generalRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |||
| 44 | + | ||||
| 45 | + | if (generalRoom) { | |||
| 46 | + | return generalRoom; | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // Create default room | |||
| 50 | + | const roomId = await ctx.db.insert('chatRooms', { | |||
| 51 | + | name: 'General', | |||
| 52 | + | type: 'group', | |||
| 53 | + | createdAt: Date.now() | |||
| 54 | + | }); | |||
| 55 | + | ||||
| 56 | + | return await ctx.db.get(roomId); | |||
| 57 | + | } | |||
| 58 | + | }); | |||
A src/convex/http.ts +278 -0
| @@ -0,0 +1,278 @@ | |||
| 1 | + | import { httpRouter } from 'convex/server'; | |
| 2 | + | import { httpAction } from './_generated/server'; | |
| 3 | + | import { internal } from './_generated/api'; | |
| 4 | + | ||
| 5 | + | const http = httpRouter(); | |
| 6 | + | ||
| 7 | + | // Validate the secret header for server-to-server calls | |
| 8 | + | function validateSecret(request: Request): boolean { | |
| 9 | + | const secret = request.headers.get('X-Convex-Secret'); | |
| 10 | + | const expectedSecret = process.env.CONVEX_API_SECRET; | |
| 11 | + | ||
| 12 | + | if (!expectedSecret) { | |
| 13 | + | console.error('CONVEX_API_SECRET environment variable not set'); | |
| 14 | + | return false; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | return secret === expectedSecret; | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // Generic error response | |
| 21 | + | function unauthorizedResponse() { | |
| 22 | + | return new Response(JSON.stringify({ error: 'Unauthorized' }), { | |
| 23 | + | status: 401, | |
| 24 | + | headers: { 'Content-Type': 'application/json' } | |
| 25 | + | }); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | function errorResponse(message: string, status = 400) { | |
| 29 | + | return new Response(JSON.stringify({ error: message }), { | |
| 30 | + | status, | |
| 31 | + | headers: { 'Content-Type': 'application/json' } | |
| 32 | + | }); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | // ============ USERS (server-side only) ============ | |
| 36 | + | ||
| 37 | + | // Get user by nickname - for login | |
| 38 | + | http.route({ | |
| 39 | + | path: '/api/users/getByNickname', | |
| 40 | + | method: 'POST', | |
| 41 | + | handler: httpAction(async (ctx, request) => { | |
| 42 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 43 | + | ||
| 44 | + | const { nickname } = await request.json(); | |
| 45 | + | const user = await ctx.runQuery(internal.usersInternal.getByNickname, { nickname }); | |
| 46 | + | ||
| 47 | + | return new Response(JSON.stringify(user), { | |
| 48 | + | headers: { 'Content-Type': 'application/json' } | |
| 49 | + | }); | |
| 50 | + | }) | |
| 51 | + | }); | |
| 52 | + | ||
| 53 | + | // Create user - for registration | |
| 54 | + | http.route({ | |
| 55 | + | path: '/api/users/create', | |
| 56 | + | method: 'POST', | |
| 57 | + | handler: httpAction(async (ctx, request) => { | |
| 58 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 59 | + | ||
| 60 | + | const { nickname, password } = await request.json(); | |
| 61 | + | try { | |
| 62 | + | const userId = await ctx.runMutation(internal.usersInternal.create, { nickname, password }); | |
| 63 | + | return new Response(JSON.stringify({ userId }), { | |
| 64 | + | headers: { 'Content-Type': 'application/json' } | |
| 65 | + | }); | |
| 66 | + | } catch (e) { | |
| 67 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to create user'); | |
| 68 | + | } | |
| 69 | + | }) | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | // Get user by ID | |
| 73 | + | http.route({ | |
| 74 | + | path: '/api/users/getById', | |
| 75 | + | method: 'POST', | |
| 76 | + | handler: httpAction(async (ctx, request) => { | |
| 77 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 78 | + | ||
| 79 | + | const { id } = await request.json(); | |
| 80 | + | const user = await ctx.runQuery(internal.usersInternal.getById, { id }); | |
| 81 | + | ||
| 82 | + | return new Response(JSON.stringify(user), { | |
| 83 | + | headers: { 'Content-Type': 'application/json' } | |
| 84 | + | }); | |
| 85 | + | }) | |
| 86 | + | }); | |
| 87 | + | ||
| 88 | + | // List all users | |
| 89 | + | http.route({ | |
| 90 | + | path: '/api/users/list', | |
| 91 | + | method: 'POST', | |
| 92 | + | handler: httpAction(async (ctx, request) => { | |
| 93 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 94 | + | ||
| 95 | + | const users = await ctx.runQuery(internal.usersInternal.list); | |
| 96 | + | ||
| 97 | + | return new Response(JSON.stringify(users), { | |
| 98 | + | headers: { 'Content-Type': 'application/json' } | |
| 99 | + | }); | |
| 100 | + | }) | |
| 101 | + | }); | |
| 102 | + | ||
| 103 | + | // Update user status | |
| 104 | + | http.route({ | |
| 105 | + | path: '/api/users/updateStatus', | |
| 106 | + | method: 'POST', | |
| 107 | + | handler: httpAction(async (ctx, request) => { | |
| 108 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 109 | + | ||
| 110 | + | const { userId, status } = await request.json(); | |
| 111 | + | try { | |
| 112 | + | const user = await ctx.runMutation(internal.usersInternal.updateStatus, { | |
| 113 | + | id: userId, | |
| 114 | + | status | |
| 115 | + | }); | |
| 116 | + | return new Response(JSON.stringify(user), { | |
| 117 | + | headers: { 'Content-Type': 'application/json' } | |
| 118 | + | }); | |
| 119 | + | } catch (e) { | |
| 120 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to update status'); | |
| 121 | + | } | |
| 122 | + | }) | |
| 123 | + | }); | |
| 124 | + | ||
| 125 | + | // Set all users offline (server startup) | |
| 126 | + | http.route({ | |
| 127 | + | path: '/api/users/setAllOffline', | |
| 128 | + | method: 'POST', | |
| 129 | + | handler: httpAction(async (ctx, request) => { | |
| 130 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 131 | + | ||
| 132 | + | const result = await ctx.runMutation(internal.usersInternal.setAllOffline); | |
| 133 | + | ||
| 134 | + | return new Response(JSON.stringify(result), { | |
| 135 | + | headers: { 'Content-Type': 'application/json' } | |
| 136 | + | }); | |
| 137 | + | }) | |
| 138 | + | }); | |
| 139 | + | ||
| 140 | + | // ============ SESSIONS (server-side only) ============ | |
| 141 | + | ||
| 142 | + | // Create session - for login | |
| 143 | + | http.route({ | |
| 144 | + | path: '/api/sessions/create', | |
| 145 | + | method: 'POST', | |
| 146 | + | handler: httpAction(async (ctx, request) => { | |
| 147 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 148 | + | ||
| 149 | + | const { tokenHash, userId, expiresAt } = await request.json(); | |
| 150 | + | try { | |
| 151 | + | const result = await ctx.runMutation(internal.sessionsInternal.create, { | |
| 152 | + | tokenHash, | |
| 153 | + | userId, | |
| 154 | + | expiresAt | |
| 155 | + | }); | |
| 156 | + | return new Response(JSON.stringify(result), { | |
| 157 | + | headers: { 'Content-Type': 'application/json' } | |
| 158 | + | }); | |
| 159 | + | } catch (e) { | |
| 160 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to create session'); | |
| 161 | + | } | |
| 162 | + | }) | |
| 163 | + | }); | |
| 164 | + | ||
| 165 | + | // Validate session | |
| 166 | + | http.route({ | |
| 167 | + | path: '/api/sessions/validate', | |
| 168 | + | method: 'POST', | |
| 169 | + | handler: httpAction(async (ctx, request) => { | |
| 170 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 171 | + | ||
| 172 | + | const { tokenHash } = await request.json(); | |
| 173 | + | const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, { tokenHash }); | |
| 174 | + | ||
| 175 | + | return new Response(JSON.stringify(result), { | |
| 176 | + | headers: { 'Content-Type': 'application/json' } | |
| 177 | + | }); | |
| 178 | + | }) | |
| 179 | + | }); | |
| 180 | + | ||
| 181 | + | // Remove session - for logout | |
| 182 | + | http.route({ | |
| 183 | + | path: '/api/sessions/remove', | |
| 184 | + | method: 'POST', | |
| 185 | + | handler: httpAction(async (ctx, request) => { | |
| 186 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 187 | + | ||
| 188 | + | const { tokenHash } = await request.json(); | |
| 189 | + | await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash }); | |
| 190 | + | ||
| 191 | + | return new Response(JSON.stringify({ success: true }), { | |
| 192 | + | headers: { 'Content-Type': 'application/json' } | |
| 193 | + | }); | |
| 194 | + | }) | |
| 195 | + | }); | |
| 196 | + | ||
| 197 | + | // ============ CHAT ROOMS (server-side only) ============ | |
| 198 | + | ||
| 199 | + | // Get or create default room | |
| 200 | + | http.route({ | |
| 201 | + | path: '/api/chatRooms/getOrCreateDefault', | |
| 202 | + | method: 'POST', | |
| 203 | + | handler: httpAction(async (ctx, request) => { | |
| 204 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 205 | + | ||
| 206 | + | const room = await ctx.runMutation(internal.chatRoomsInternal.getOrCreateDefault); | |
| 207 | + | ||
| 208 | + | return new Response(JSON.stringify(room), { | |
| 209 | + | headers: { 'Content-Type': 'application/json' } | |
| 210 | + | }); | |
| 211 | + | }) | |
| 212 | + | }); | |
| 213 | + | ||
| 214 | + | // Get room by ID | |
| 215 | + | http.route({ | |
| 216 | + | path: '/api/chatRooms/getById', | |
| 217 | + | method: 'POST', | |
| 218 | + | handler: httpAction(async (ctx, request) => { | |
| 219 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 220 | + | ||
| 221 | + | const { id } = await request.json(); | |
| 222 | + | const room = await ctx.runQuery(internal.chatRoomsInternal.getById, { id }); | |
| 223 | + | ||
| 224 | + | return new Response(JSON.stringify(room), { | |
| 225 | + | headers: { 'Content-Type': 'application/json' } | |
| 226 | + | }); | |
| 227 | + | }) | |
| 228 | + | }); | |
| 229 | + | ||
| 230 | + | // ============ MESSAGES (server-side only) ============ | |
| 231 | + | ||
| 232 | + | // Get messages by room - for initial load | |
| 233 | + | http.route({ | |
| 234 | + | path: '/api/messages/getByRoom', | |
| 235 | + | method: 'POST', | |
| 236 | + | handler: httpAction(async (ctx, request) => { | |
| 237 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 238 | + | ||
| 239 | + | const { roomId, limit, beforeTimestamp } = await request.json(); | |
| 240 | + | const result = await ctx.runQuery(internal.messagesInternal.getByRoom, { | |
| 241 | + | roomId, | |
| 242 | + | limit, | |
| 243 | + | beforeTimestamp | |
| 244 | + | }); | |
| 245 | + | ||
| 246 | + | return new Response(JSON.stringify(result), { | |
| 247 | + | headers: { 'Content-Type': 'application/json' } | |
| 248 | + | }); | |
| 249 | + | }) | |
| 250 | + | }); | |
| 251 | + | ||
| 252 | + | // Send message | |
| 253 | + | http.route({ | |
| 254 | + | path: '/api/messages/send', | |
| 255 | + | method: 'POST', | |
| 256 | + | handler: httpAction(async (ctx, request) => { | |
| 257 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |
| 258 | + | ||
| 259 | + | const { chatRoomId, senderId, content, type, styleData, hasFormatting } = await request.json(); | |
| 260 | + | try { | |
| 261 | + | const message = await ctx.runMutation(internal.messagesInternal.send, { | |
| 262 | + | chatRoomId, | |
| 263 | + | senderId, | |
| 264 | + | content, | |
| 265 | + | type, | |
| 266 | + | styleData, | |
| 267 | + | hasFormatting | |
| 268 | + | }); | |
| 269 | + | return new Response(JSON.stringify(message), { | |
| 270 | + | headers: { 'Content-Type': 'application/json' } | |
| 271 | + | }); | |
| 272 | + | } catch (e) { | |
| 273 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to send message'); | |
| 274 | + | } | |
| 275 | + | }) | |
| 276 | + | }); | |
| 277 | + | ||
| 278 | + | export default http; | |
| @@ -0,0 +1,278 @@ | |||||
| 1 | + | import { httpRouter } from 'convex/server'; | |||
| 2 | + | import { httpAction } from './_generated/server'; | |||
| 3 | + | import { internal } from './_generated/api'; | |||
| 4 | + | ||||
| 5 | + | const http = httpRouter(); | |||
| 6 | + | ||||
| 7 | + | // Validate the secret header for server-to-server calls | |||
| 8 | + | function validateSecret(request: Request): boolean { | |||
| 9 | + | const secret = request.headers.get('X-Convex-Secret'); | |||
| 10 | + | const expectedSecret = process.env.CONVEX_API_SECRET; | |||
| 11 | + | ||||
| 12 | + | if (!expectedSecret) { | |||
| 13 | + | console.error('CONVEX_API_SECRET environment variable not set'); | |||
| 14 | + | return false; | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | return secret === expectedSecret; | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // Generic error response | |||
| 21 | + | function unauthorizedResponse() { | |||
| 22 | + | return new Response(JSON.stringify({ error: 'Unauthorized' }), { | |||
| 23 | + | status: 401, | |||
| 24 | + | headers: { 'Content-Type': 'application/json' } | |||
| 25 | + | }); | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | function errorResponse(message: string, status = 400) { | |||
| 29 | + | return new Response(JSON.stringify({ error: message }), { | |||
| 30 | + | status, | |||
| 31 | + | headers: { 'Content-Type': 'application/json' } | |||
| 32 | + | }); | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | // ============ USERS (server-side only) ============ | |||
| 36 | + | ||||
| 37 | + | // Get user by nickname - for login | |||
| 38 | + | http.route({ | |||
| 39 | + | path: '/api/users/getByNickname', | |||
| 40 | + | method: 'POST', | |||
| 41 | + | handler: httpAction(async (ctx, request) => { | |||
| 42 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 43 | + | ||||
| 44 | + | const { nickname } = await request.json(); | |||
| 45 | + | const user = await ctx.runQuery(internal.usersInternal.getByNickname, { nickname }); | |||
| 46 | + | ||||
| 47 | + | return new Response(JSON.stringify(user), { | |||
| 48 | + | headers: { 'Content-Type': 'application/json' } | |||
| 49 | + | }); | |||
| 50 | + | }) | |||
| 51 | + | }); | |||
| 52 | + | ||||
| 53 | + | // Create user - for registration | |||
| 54 | + | http.route({ | |||
| 55 | + | path: '/api/users/create', | |||
| 56 | + | method: 'POST', | |||
| 57 | + | handler: httpAction(async (ctx, request) => { | |||
| 58 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 59 | + | ||||
| 60 | + | const { nickname, password } = await request.json(); | |||
| 61 | + | try { | |||
| 62 | + | const userId = await ctx.runMutation(internal.usersInternal.create, { nickname, password }); | |||
| 63 | + | return new Response(JSON.stringify({ userId }), { | |||
| 64 | + | headers: { 'Content-Type': 'application/json' } | |||
| 65 | + | }); | |||
| 66 | + | } catch (e) { | |||
| 67 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to create user'); | |||
| 68 | + | } | |||
| 69 | + | }) | |||
| 70 | + | }); | |||
| 71 | + | ||||
| 72 | + | // Get user by ID | |||
| 73 | + | http.route({ | |||
| 74 | + | path: '/api/users/getById', | |||
| 75 | + | method: 'POST', | |||
| 76 | + | handler: httpAction(async (ctx, request) => { | |||
| 77 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 78 | + | ||||
| 79 | + | const { id } = await request.json(); | |||
| 80 | + | const user = await ctx.runQuery(internal.usersInternal.getById, { id }); | |||
| 81 | + | ||||
| 82 | + | return new Response(JSON.stringify(user), { | |||
| 83 | + | headers: { 'Content-Type': 'application/json' } | |||
| 84 | + | }); | |||
| 85 | + | }) | |||
| 86 | + | }); | |||
| 87 | + | ||||
| 88 | + | // List all users | |||
| 89 | + | http.route({ | |||
| 90 | + | path: '/api/users/list', | |||
| 91 | + | method: 'POST', | |||
| 92 | + | handler: httpAction(async (ctx, request) => { | |||
| 93 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 94 | + | ||||
| 95 | + | const users = await ctx.runQuery(internal.usersInternal.list); | |||
| 96 | + | ||||
| 97 | + | return new Response(JSON.stringify(users), { | |||
| 98 | + | headers: { 'Content-Type': 'application/json' } | |||
| 99 | + | }); | |||
| 100 | + | }) | |||
| 101 | + | }); | |||
| 102 | + | ||||
| 103 | + | // Update user status | |||
| 104 | + | http.route({ | |||
| 105 | + | path: '/api/users/updateStatus', | |||
| 106 | + | method: 'POST', | |||
| 107 | + | handler: httpAction(async (ctx, request) => { | |||
| 108 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 109 | + | ||||
| 110 | + | const { userId, status } = await request.json(); | |||
| 111 | + | try { | |||
| 112 | + | const user = await ctx.runMutation(internal.usersInternal.updateStatus, { | |||
| 113 | + | id: userId, | |||
| 114 | + | status | |||
| 115 | + | }); | |||
| 116 | + | return new Response(JSON.stringify(user), { | |||
| 117 | + | headers: { 'Content-Type': 'application/json' } | |||
| 118 | + | }); | |||
| 119 | + | } catch (e) { | |||
| 120 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to update status'); | |||
| 121 | + | } | |||
| 122 | + | }) | |||
| 123 | + | }); | |||
| 124 | + | ||||
| 125 | + | // Set all users offline (server startup) | |||
| 126 | + | http.route({ | |||
| 127 | + | path: '/api/users/setAllOffline', | |||
| 128 | + | method: 'POST', | |||
| 129 | + | handler: httpAction(async (ctx, request) => { | |||
| 130 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 131 | + | ||||
| 132 | + | const result = await ctx.runMutation(internal.usersInternal.setAllOffline); | |||
| 133 | + | ||||
| 134 | + | return new Response(JSON.stringify(result), { | |||
| 135 | + | headers: { 'Content-Type': 'application/json' } | |||
| 136 | + | }); | |||
| 137 | + | }) | |||
| 138 | + | }); | |||
| 139 | + | ||||
| 140 | + | // ============ SESSIONS (server-side only) ============ | |||
| 141 | + | ||||
| 142 | + | // Create session - for login | |||
| 143 | + | http.route({ | |||
| 144 | + | path: '/api/sessions/create', | |||
| 145 | + | method: 'POST', | |||
| 146 | + | handler: httpAction(async (ctx, request) => { | |||
| 147 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 148 | + | ||||
| 149 | + | const { tokenHash, userId, expiresAt } = await request.json(); | |||
| 150 | + | try { | |||
| 151 | + | const result = await ctx.runMutation(internal.sessionsInternal.create, { | |||
| 152 | + | tokenHash, | |||
| 153 | + | userId, | |||
| 154 | + | expiresAt | |||
| 155 | + | }); | |||
| 156 | + | return new Response(JSON.stringify(result), { | |||
| 157 | + | headers: { 'Content-Type': 'application/json' } | |||
| 158 | + | }); | |||
| 159 | + | } catch (e) { | |||
| 160 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to create session'); | |||
| 161 | + | } | |||
| 162 | + | }) | |||
| 163 | + | }); | |||
| 164 | + | ||||
| 165 | + | // Validate session | |||
| 166 | + | http.route({ | |||
| 167 | + | path: '/api/sessions/validate', | |||
| 168 | + | method: 'POST', | |||
| 169 | + | handler: httpAction(async (ctx, request) => { | |||
| 170 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 171 | + | ||||
| 172 | + | const { tokenHash } = await request.json(); | |||
| 173 | + | const result = await ctx.runQuery(internal.sessionsInternal.validateAndGetUser, { tokenHash }); | |||
| 174 | + | ||||
| 175 | + | return new Response(JSON.stringify(result), { | |||
| 176 | + | headers: { 'Content-Type': 'application/json' } | |||
| 177 | + | }); | |||
| 178 | + | }) | |||
| 179 | + | }); | |||
| 180 | + | ||||
| 181 | + | // Remove session - for logout | |||
| 182 | + | http.route({ | |||
| 183 | + | path: '/api/sessions/remove', | |||
| 184 | + | method: 'POST', | |||
| 185 | + | handler: httpAction(async (ctx, request) => { | |||
| 186 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 187 | + | ||||
| 188 | + | const { tokenHash } = await request.json(); | |||
| 189 | + | await ctx.runMutation(internal.sessionsInternal.remove, { tokenHash }); | |||
| 190 | + | ||||
| 191 | + | return new Response(JSON.stringify({ success: true }), { | |||
| 192 | + | headers: { 'Content-Type': 'application/json' } | |||
| 193 | + | }); | |||
| 194 | + | }) | |||
| 195 | + | }); | |||
| 196 | + | ||||
| 197 | + | // ============ CHAT ROOMS (server-side only) ============ | |||
| 198 | + | ||||
| 199 | + | // Get or create default room | |||
| 200 | + | http.route({ | |||
| 201 | + | path: '/api/chatRooms/getOrCreateDefault', | |||
| 202 | + | method: 'POST', | |||
| 203 | + | handler: httpAction(async (ctx, request) => { | |||
| 204 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 205 | + | ||||
| 206 | + | const room = await ctx.runMutation(internal.chatRoomsInternal.getOrCreateDefault); | |||
| 207 | + | ||||
| 208 | + | return new Response(JSON.stringify(room), { | |||
| 209 | + | headers: { 'Content-Type': 'application/json' } | |||
| 210 | + | }); | |||
| 211 | + | }) | |||
| 212 | + | }); | |||
| 213 | + | ||||
| 214 | + | // Get room by ID | |||
| 215 | + | http.route({ | |||
| 216 | + | path: '/api/chatRooms/getById', | |||
| 217 | + | method: 'POST', | |||
| 218 | + | handler: httpAction(async (ctx, request) => { | |||
| 219 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 220 | + | ||||
| 221 | + | const { id } = await request.json(); | |||
| 222 | + | const room = await ctx.runQuery(internal.chatRoomsInternal.getById, { id }); | |||
| 223 | + | ||||
| 224 | + | return new Response(JSON.stringify(room), { | |||
| 225 | + | headers: { 'Content-Type': 'application/json' } | |||
| 226 | + | }); | |||
| 227 | + | }) | |||
| 228 | + | }); | |||
| 229 | + | ||||
| 230 | + | // ============ MESSAGES (server-side only) ============ | |||
| 231 | + | ||||
| 232 | + | // Get messages by room - for initial load | |||
| 233 | + | http.route({ | |||
| 234 | + | path: '/api/messages/getByRoom', | |||
| 235 | + | method: 'POST', | |||
| 236 | + | handler: httpAction(async (ctx, request) => { | |||
| 237 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 238 | + | ||||
| 239 | + | const { roomId, limit, beforeTimestamp } = await request.json(); | |||
| 240 | + | const result = await ctx.runQuery(internal.messagesInternal.getByRoom, { | |||
| 241 | + | roomId, | |||
| 242 | + | limit, | |||
| 243 | + | beforeTimestamp | |||
| 244 | + | }); | |||
| 245 | + | ||||
| 246 | + | return new Response(JSON.stringify(result), { | |||
| 247 | + | headers: { 'Content-Type': 'application/json' } | |||
| 248 | + | }); | |||
| 249 | + | }) | |||
| 250 | + | }); | |||
| 251 | + | ||||
| 252 | + | // Send message | |||
| 253 | + | http.route({ | |||
| 254 | + | path: '/api/messages/send', | |||
| 255 | + | method: 'POST', | |||
| 256 | + | handler: httpAction(async (ctx, request) => { | |||
| 257 | + | if (!validateSecret(request)) return unauthorizedResponse(); | |||
| 258 | + | ||||
| 259 | + | const { chatRoomId, senderId, content, type, styleData, hasFormatting } = await request.json(); | |||
| 260 | + | try { | |||
| 261 | + | const message = await ctx.runMutation(internal.messagesInternal.send, { | |||
| 262 | + | chatRoomId, | |||
| 263 | + | senderId, | |||
| 264 | + | content, | |||
| 265 | + | type, | |||
| 266 | + | styleData, | |||
| 267 | + | hasFormatting | |||
| 268 | + | }); | |||
| 269 | + | return new Response(JSON.stringify(message), { | |||
| 270 | + | headers: { 'Content-Type': 'application/json' } | |||
| 271 | + | }); | |||
| 272 | + | } catch (e) { | |||
| 273 | + | return errorResponse(e instanceof Error ? e.message : 'Failed to send message'); | |||
| 274 | + | } | |||
| 275 | + | }) | |||
| 276 | + | }); | |||
| 277 | + | ||||
| 278 | + | export default http; | |||
A src/convex/messagesInternal.ts +90 -0
| @@ -0,0 +1,90 @@ | |||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | // Get messages by chat room ID (internal) | |
| 5 | + | export const getByRoom = internalQuery({ | |
| 6 | + | args: { | |
| 7 | + | roomId: v.id('chatRooms'), | |
| 8 | + | limit: v.optional(v.number()), | |
| 9 | + | beforeTimestamp: v.optional(v.number()) | |
| 10 | + | }, | |
| 11 | + | handler: async (ctx, args) => { | |
| 12 | + | const limit = args.limit ?? 100; | |
| 13 | + | ||
| 14 | + | const allMessages = await ctx.db | |
| 15 | + | .query('messages') | |
| 16 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 17 | + | .collect(); | |
| 18 | + | ||
| 19 | + | // Filter by timestamp if provided | |
| 20 | + | let filtered = allMessages; | |
| 21 | + | if (args.beforeTimestamp) { | |
| 22 | + | filtered = allMessages.filter((m) => m.timestamp < args.beforeTimestamp!); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | // Sort by timestamp descending and limit | |
| 26 | + | filtered.sort((a, b) => b.timestamp - a.timestamp); | |
| 27 | + | const messages = filtered.slice(0, limit); | |
| 28 | + | ||
| 29 | + | return { | |
| 30 | + | messages, | |
| 31 | + | hasMore: filtered.length > limit | |
| 32 | + | }; | |
| 33 | + | } | |
| 34 | + | }); | |
| 35 | + | ||
| 36 | + | // Send message (internal) | |
| 37 | + | export const send = internalMutation({ | |
| 38 | + | args: { | |
| 39 | + | chatRoomId: v.id('chatRooms'), | |
| 40 | + | senderId: v.id('users'), | |
| 41 | + | content: v.string(), | |
| 42 | + | type: v.optional(v.string()), | |
| 43 | + | styleData: v.optional(v.string()), | |
| 44 | + | hasFormatting: v.optional(v.boolean()) | |
| 45 | + | }, | |
| 46 | + | handler: async (ctx, args) => { | |
| 47 | + | // Verify room exists | |
| 48 | + | const room = await ctx.db.get(args.chatRoomId); | |
| 49 | + | if (!room) { | |
| 50 | + | throw new Error('Chat room not found'); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // Verify user exists | |
| 54 | + | const user = await ctx.db.get(args.senderId); | |
| 55 | + | if (!user) { | |
| 56 | + | throw new Error('User not found'); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | const messageId = await ctx.db.insert('messages', { | |
| 60 | + | chatRoomId: args.chatRoomId, | |
| 61 | + | senderId: args.senderId, | |
| 62 | + | content: args.content, | |
| 63 | + | type: args.type ?? 'chat', | |
| 64 | + | timestamp: Date.now(), | |
| 65 | + | styleData: args.styleData, | |
| 66 | + | hasFormatting: args.hasFormatting ?? false | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | return await ctx.db.get(messageId); | |
| 70 | + | } | |
| 71 | + | }); | |
| 72 | + | ||
| 73 | + | // Get recent messages across all rooms (internal) - for SSE polling | |
| 74 | + | export const getRecent = internalQuery({ | |
| 75 | + | args: { | |
| 76 | + | sinceTimestamp: v.number(), | |
| 77 | + | limit: v.optional(v.number()) | |
| 78 | + | }, | |
| 79 | + | handler: async (ctx, args) => { | |
| 80 | + | const limit = args.limit ?? 50; | |
| 81 | + | ||
| 82 | + | const messages = await ctx.db | |
| 83 | + | .query('messages') | |
| 84 | + | .withIndex('by_timestamp') | |
| 85 | + | .filter((q) => q.gt(q.field('timestamp'), args.sinceTimestamp)) | |
| 86 | + | .take(limit); | |
| 87 | + | ||
| 88 | + | return messages; | |
| 89 | + | } | |
| 90 | + | }); | |
| @@ -0,0 +1,90 @@ | |||||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |||
| 2 | + | import { v } from 'convex/values'; | |||
| 3 | + | ||||
| 4 | + | // Get messages by chat room ID (internal) | |||
| 5 | + | export const getByRoom = internalQuery({ | |||
| 6 | + | args: { | |||
| 7 | + | roomId: v.id('chatRooms'), | |||
| 8 | + | limit: v.optional(v.number()), | |||
| 9 | + | beforeTimestamp: v.optional(v.number()) | |||
| 10 | + | }, | |||
| 11 | + | handler: async (ctx, args) => { | |||
| 12 | + | const limit = args.limit ?? 100; | |||
| 13 | + | ||||
| 14 | + | const allMessages = await ctx.db | |||
| 15 | + | .query('messages') | |||
| 16 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |||
| 17 | + | .collect(); | |||
| 18 | + | ||||
| 19 | + | // Filter by timestamp if provided | |||
| 20 | + | let filtered = allMessages; | |||
| 21 | + | if (args.beforeTimestamp) { | |||
| 22 | + | filtered = allMessages.filter((m) => m.timestamp < args.beforeTimestamp!); | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | // Sort by timestamp descending and limit | |||
| 26 | + | filtered.sort((a, b) => b.timestamp - a.timestamp); | |||
| 27 | + | const messages = filtered.slice(0, limit); | |||
| 28 | + | ||||
| 29 | + | return { | |||
| 30 | + | messages, | |||
| 31 | + | hasMore: filtered.length > limit | |||
| 32 | + | }; | |||
| 33 | + | } | |||
| 34 | + | }); | |||
| 35 | + | ||||
| 36 | + | // Send message (internal) | |||
| 37 | + | export const send = internalMutation({ | |||
| 38 | + | args: { | |||
| 39 | + | chatRoomId: v.id('chatRooms'), | |||
| 40 | + | senderId: v.id('users'), | |||
| 41 | + | content: v.string(), | |||
| 42 | + | type: v.optional(v.string()), | |||
| 43 | + | styleData: v.optional(v.string()), | |||
| 44 | + | hasFormatting: v.optional(v.boolean()) | |||
| 45 | + | }, | |||
| 46 | + | handler: async (ctx, args) => { | |||
| 47 | + | // Verify room exists | |||
| 48 | + | const room = await ctx.db.get(args.chatRoomId); | |||
| 49 | + | if (!room) { | |||
| 50 | + | throw new Error('Chat room not found'); | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // Verify user exists | |||
| 54 | + | const user = await ctx.db.get(args.senderId); | |||
| 55 | + | if (!user) { | |||
| 56 | + | throw new Error('User not found'); | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | const messageId = await ctx.db.insert('messages', { | |||
| 60 | + | chatRoomId: args.chatRoomId, | |||
| 61 | + | senderId: args.senderId, | |||
| 62 | + | content: args.content, | |||
| 63 | + | type: args.type ?? 'chat', | |||
| 64 | + | timestamp: Date.now(), | |||
| 65 | + | styleData: args.styleData, | |||
| 66 | + | hasFormatting: args.hasFormatting ?? false | |||
| 67 | + | }); | |||
| 68 | + | ||||
| 69 | + | return await ctx.db.get(messageId); | |||
| 70 | + | } | |||
| 71 | + | }); | |||
| 72 | + | ||||
| 73 | + | // Get recent messages across all rooms (internal) - for SSE polling | |||
| 74 | + | export const getRecent = internalQuery({ | |||
| 75 | + | args: { | |||
| 76 | + | sinceTimestamp: v.number(), | |||
| 77 | + | limit: v.optional(v.number()) | |||
| 78 | + | }, | |||
| 79 | + | handler: async (ctx, args) => { | |||
| 80 | + | const limit = args.limit ?? 50; | |||
| 81 | + | ||||
| 82 | + | const messages = await ctx.db | |||
| 83 | + | .query('messages') | |||
| 84 | + | .withIndex('by_timestamp') | |||
| 85 | + | .filter((q) => q.gt(q.field('timestamp'), args.sinceTimestamp)) | |||
| 86 | + | .take(limit); | |||
| 87 | + | ||||
| 88 | + | return messages; | |||
| 89 | + | } | |||
| 90 | + | }); | |||
A src/convex/mutations.ts +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | import { mutation } from './_generated/server'; | |
| 2 | + | import { authMutation } from './auth'; | |
| 3 | + | import { v } from 'convex/values'; | |
| 4 | + | ||
| 5 | + | // Authenticated mutation: Send a message | |
| 6 | + | export const sendMessage = authMutation({ | |
| 7 | + | args: { | |
| 8 | + | chatRoomId: v.id('chatRooms'), | |
| 9 | + | content: v.string(), | |
| 10 | + | type: v.optional(v.string()), | |
| 11 | + | styleData: v.optional(v.string()), | |
| 12 | + | hasFormatting: v.optional(v.boolean()) | |
| 13 | + | }, | |
| 14 | + | handler: async (ctx, args) => { | |
| 15 | + | // ctx.user is provided by authMutation | |
| 16 | + | const user = ctx.user; | |
| 17 | + | ||
| 18 | + | // Verify room exists | |
| 19 | + | const room = await ctx.db.get(args.chatRoomId); | |
| 20 | + | if (!room) { | |
| 21 | + | throw new Error('Chat room not found'); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | const messageId = await ctx.db.insert('messages', { | |
| 25 | + | chatRoomId: args.chatRoomId, | |
| 26 | + | senderId: user._id, | |
| 27 | + | content: args.content, | |
| 28 | + | type: args.type ?? 'chat', | |
| 29 | + | timestamp: Date.now(), | |
| 30 | + | styleData: args.styleData, | |
| 31 | + | hasFormatting: args.hasFormatting ?? false | |
| 32 | + | }); | |
| 33 | + | ||
| 34 | + | const message = await ctx.db.get(messageId); | |
| 35 | + | ||
| 36 | + | // Return enriched message | |
| 37 | + | return { | |
| 38 | + | id: message!._id, | |
| 39 | + | chatRoomId: message!.chatRoomId, | |
| 40 | + | senderId: message!.senderId, | |
| 41 | + | content: message!.content, | |
| 42 | + | type: message!.type, | |
| 43 | + | timestamp: message!.timestamp, | |
| 44 | + | styleData: message!.styleData, | |
| 45 | + | hasFormatting: message!.hasFormatting, | |
| 46 | + | sender: { | |
| 47 | + | id: user._id, | |
| 48 | + | nickname: user.nickname, | |
| 49 | + | status: user.status, | |
| 50 | + | avatarUrl: user.avatarUrl | |
| 51 | + | } | |
| 52 | + | }; | |
| 53 | + | } | |
| 54 | + | }); | |
| 55 | + | ||
| 56 | + | // Authenticated mutation: Update current user's status | |
| 57 | + | export const updateMyStatus = authMutation({ | |
| 58 | + | args: { | |
| 59 | + | status: v.string() | |
| 60 | + | }, | |
| 61 | + | handler: async (ctx, args) => { | |
| 62 | + | const validStatuses = ['online', 'away', 'busy', 'offline', 'idle']; | |
| 63 | + | if (!validStatuses.includes(args.status)) { | |
| 64 | + | throw new Error('Invalid status'); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | await ctx.db.patch(ctx.user._id, { | |
| 68 | + | status: args.status, | |
| 69 | + | lastSeen: Date.now() | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | const updated = await ctx.db.get(ctx.user._id); | |
| 73 | + | return { | |
| 74 | + | id: updated!._id, | |
| 75 | + | nickname: updated!.nickname, | |
| 76 | + | status: updated!.status, | |
| 77 | + | avatarUrl: updated!.avatarUrl, | |
| 78 | + | lastSeen: updated!.lastSeen | |
| 79 | + | }; | |
| 80 | + | } | |
| 81 | + | }); | |
| 82 | + | ||
| 83 | + | // Public mutation: Create or get default room (can be called during init) | |
| 84 | + | export const getOrCreateDefaultRoom = mutation({ | |
| 85 | + | args: {}, | |
| 86 | + | handler: async (ctx) => { | |
| 87 | + | // Look for existing General room | |
| 88 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 89 | + | const generalRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |
| 90 | + | ||
| 91 | + | if (generalRoom) { | |
| 92 | + | return { | |
| 93 | + | id: generalRoom._id, | |
| 94 | + | name: generalRoom.name, | |
| 95 | + | type: generalRoom.type, | |
| 96 | + | createdAt: generalRoom.createdAt | |
| 97 | + | }; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Create default room | |
| 101 | + | const roomId = await ctx.db.insert('chatRooms', { | |
| 102 | + | name: 'General', | |
| 103 | + | type: 'group', | |
| 104 | + | createdAt: Date.now() | |
| 105 | + | }); | |
| 106 | + | ||
| 107 | + | const room = await ctx.db.get(roomId); | |
| 108 | + | return { | |
| 109 | + | id: room!._id, | |
| 110 | + | name: room!.name, | |
| 111 | + | type: room!.type, | |
| 112 | + | createdAt: room!.createdAt | |
| 113 | + | }; | |
| 114 | + | } | |
| 115 | + | }); | |
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | import { mutation } from './_generated/server'; | |||
| 2 | + | import { authMutation } from './auth'; | |||
| 3 | + | import { v } from 'convex/values'; | |||
| 4 | + | ||||
| 5 | + | // Authenticated mutation: Send a message | |||
| 6 | + | export const sendMessage = authMutation({ | |||
| 7 | + | args: { | |||
| 8 | + | chatRoomId: v.id('chatRooms'), | |||
| 9 | + | content: v.string(), | |||
| 10 | + | type: v.optional(v.string()), | |||
| 11 | + | styleData: v.optional(v.string()), | |||
| 12 | + | hasFormatting: v.optional(v.boolean()) | |||
| 13 | + | }, | |||
| 14 | + | handler: async (ctx, args) => { | |||
| 15 | + | // ctx.user is provided by authMutation | |||
| 16 | + | const user = ctx.user; | |||
| 17 | + | ||||
| 18 | + | // Verify room exists | |||
| 19 | + | const room = await ctx.db.get(args.chatRoomId); | |||
| 20 | + | if (!room) { | |||
| 21 | + | throw new Error('Chat room not found'); | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | const messageId = await ctx.db.insert('messages', { | |||
| 25 | + | chatRoomId: args.chatRoomId, | |||
| 26 | + | senderId: user._id, | |||
| 27 | + | content: args.content, | |||
| 28 | + | type: args.type ?? 'chat', | |||
| 29 | + | timestamp: Date.now(), | |||
| 30 | + | styleData: args.styleData, | |||
| 31 | + | hasFormatting: args.hasFormatting ?? false | |||
| 32 | + | }); | |||
| 33 | + | ||||
| 34 | + | const message = await ctx.db.get(messageId); | |||
| 35 | + | ||||
| 36 | + | // Return enriched message | |||
| 37 | + | return { | |||
| 38 | + | id: message!._id, | |||
| 39 | + | chatRoomId: message!.chatRoomId, | |||
| 40 | + | senderId: message!.senderId, | |||
| 41 | + | content: message!.content, | |||
| 42 | + | type: message!.type, | |||
| 43 | + | timestamp: message!.timestamp, | |||
| 44 | + | styleData: message!.styleData, | |||
| 45 | + | hasFormatting: message!.hasFormatting, | |||
| 46 | + | sender: { | |||
| 47 | + | id: user._id, | |||
| 48 | + | nickname: user.nickname, | |||
| 49 | + | status: user.status, | |||
| 50 | + | avatarUrl: user.avatarUrl | |||
| 51 | + | } | |||
| 52 | + | }; | |||
| 53 | + | } | |||
| 54 | + | }); | |||
| 55 | + | ||||
| 56 | + | // Authenticated mutation: Update current user's status | |||
| 57 | + | export const updateMyStatus = authMutation({ | |||
| 58 | + | args: { | |||
| 59 | + | status: v.string() | |||
| 60 | + | }, | |||
| 61 | + | handler: async (ctx, args) => { | |||
| 62 | + | const validStatuses = ['online', 'away', 'busy', 'offline', 'idle']; | |||
| 63 | + | if (!validStatuses.includes(args.status)) { | |||
| 64 | + | throw new Error('Invalid status'); | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | await ctx.db.patch(ctx.user._id, { | |||
| 68 | + | status: args.status, | |||
| 69 | + | lastSeen: Date.now() | |||
| 70 | + | }); | |||
| 71 | + | ||||
| 72 | + | const updated = await ctx.db.get(ctx.user._id); | |||
| 73 | + | return { | |||
| 74 | + | id: updated!._id, | |||
| 75 | + | nickname: updated!.nickname, | |||
| 76 | + | status: updated!.status, | |||
| 77 | + | avatarUrl: updated!.avatarUrl, | |||
| 78 | + | lastSeen: updated!.lastSeen | |||
| 79 | + | }; | |||
| 80 | + | } | |||
| 81 | + | }); | |||
| 82 | + | ||||
| 83 | + | // Public mutation: Create or get default room (can be called during init) | |||
| 84 | + | export const getOrCreateDefaultRoom = mutation({ | |||
| 85 | + | args: {}, | |||
| 86 | + | handler: async (ctx) => { | |||
| 87 | + | // Look for existing General room | |||
| 88 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |||
| 89 | + | const generalRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |||
| 90 | + | ||||
| 91 | + | if (generalRoom) { | |||
| 92 | + | return { | |||
| 93 | + | id: generalRoom._id, | |||
| 94 | + | name: generalRoom.name, | |||
| 95 | + | type: generalRoom.type, | |||
| 96 | + | createdAt: generalRoom.createdAt | |||
| 97 | + | }; | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Create default room | |||
| 101 | + | const roomId = await ctx.db.insert('chatRooms', { | |||
| 102 | + | name: 'General', | |||
| 103 | + | type: 'group', | |||
| 104 | + | createdAt: Date.now() | |||
| 105 | + | }); | |||
| 106 | + | ||||
| 107 | + | const room = await ctx.db.get(roomId); | |||
| 108 | + | return { | |||
| 109 | + | id: room!._id, | |||
| 110 | + | name: room!.name, | |||
| 111 | + | type: room!.type, | |||
| 112 | + | createdAt: room!.createdAt | |||
| 113 | + | }; | |||
| 114 | + | } | |||
| 115 | + | }); | |||
A src/convex/queries.ts +222 -0
| @@ -0,0 +1,222 @@ | |||
| 1 | + | import { query } from './_generated/server'; | |
| 2 | + | import { authQuery } from './auth'; | |
| 3 | + | import { v } from 'convex/values'; | |
| 4 | + | ||
| 5 | + | // ============ PUBLIC QUERIES (no auth required) ============ | |
| 6 | + | ||
| 7 | + | // Public query: Get messages for a chat room (real-time subscription) | |
| 8 | + | export const getMessagesPublic = query({ | |
| 9 | + | args: { | |
| 10 | + | roomId: v.id('chatRooms'), | |
| 11 | + | limit: v.optional(v.number()) | |
| 12 | + | }, | |
| 13 | + | handler: async (ctx, args) => { | |
| 14 | + | const limit = args.limit ?? 50; | |
| 15 | + | ||
| 16 | + | const messages = await ctx.db | |
| 17 | + | .query('messages') | |
| 18 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 19 | + | .order('desc') | |
| 20 | + | .take(limit); | |
| 21 | + | ||
| 22 | + | // Enrich messages with sender info | |
| 23 | + | const enrichedMessages = await Promise.all( | |
| 24 | + | messages.map(async (msg) => { | |
| 25 | + | const sender = await ctx.db.get(msg.senderId); | |
| 26 | + | return { | |
| 27 | + | id: msg._id, | |
| 28 | + | chatRoomId: msg.chatRoomId, | |
| 29 | + | senderId: msg.senderId, | |
| 30 | + | content: msg.content, | |
| 31 | + | type: msg.type, | |
| 32 | + | timestamp: msg.timestamp, | |
| 33 | + | styleData: msg.styleData, | |
| 34 | + | hasFormatting: msg.hasFormatting, | |
| 35 | + | sender: sender | |
| 36 | + | ? { | |
| 37 | + | id: sender._id, | |
| 38 | + | nickname: sender.nickname, | |
| 39 | + | status: sender.status, | |
| 40 | + | avatarUrl: sender.avatarUrl | |
| 41 | + | } | |
| 42 | + | : null | |
| 43 | + | }; | |
| 44 | + | }) | |
| 45 | + | ); | |
| 46 | + | ||
| 47 | + | // Return in chronological order (oldest first) | |
| 48 | + | return enrichedMessages.reverse(); | |
| 49 | + | } | |
| 50 | + | }); | |
| 51 | + | ||
| 52 | + | // Public query: Get all users (real-time subscription for buddy list) | |
| 53 | + | export const getUsersPublic = query({ | |
| 54 | + | args: {}, | |
| 55 | + | handler: async (ctx) => { | |
| 56 | + | const users = await ctx.db.query('users').collect(); | |
| 57 | + | ||
| 58 | + | // Return safe user data (no passwords) | |
| 59 | + | return users.map((user) => ({ | |
| 60 | + | id: user._id, | |
| 61 | + | nickname: user.nickname, | |
| 62 | + | status: user.status, | |
| 63 | + | avatarUrl: user.avatarUrl, | |
| 64 | + | lastSeen: user.lastSeen | |
| 65 | + | })); | |
| 66 | + | } | |
| 67 | + | }); | |
| 68 | + | ||
| 69 | + | // Public query: Get default room | |
| 70 | + | export const getDefaultRoomPublic = query({ | |
| 71 | + | args: {}, | |
| 72 | + | handler: async (ctx) => { | |
| 73 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 74 | + | const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |
| 75 | + | ||
| 76 | + | if (defaultRoom) { | |
| 77 | + | return { | |
| 78 | + | id: defaultRoom._id, | |
| 79 | + | name: defaultRoom.name, | |
| 80 | + | type: defaultRoom.type, | |
| 81 | + | createdAt: defaultRoom.createdAt | |
| 82 | + | }; | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | // Return first group room if General doesn't exist | |
| 86 | + | const firstGroup = rooms.find((r) => r.type === 'group'); | |
| 87 | + | if (firstGroup) { | |
| 88 | + | return { | |
| 89 | + | id: firstGroup._id, | |
| 90 | + | name: firstGroup.name, | |
| 91 | + | type: firstGroup.type, | |
| 92 | + | createdAt: firstGroup.createdAt | |
| 93 | + | }; | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | return null; | |
| 97 | + | } | |
| 98 | + | }); | |
| 99 | + | ||
| 100 | + | // ============ AUTHENTICATED QUERIES ============ | |
| 101 | + | ||
| 102 | + | // Authenticated query: Get messages for a chat room (real-time subscription) | |
| 103 | + | export const getMessages = authQuery({ | |
| 104 | + | args: { | |
| 105 | + | roomId: v.id('chatRooms'), | |
| 106 | + | limit: v.optional(v.number()) | |
| 107 | + | }, | |
| 108 | + | handler: async (ctx, args) => { | |
| 109 | + | const limit = args.limit ?? 100; | |
| 110 | + | ||
| 111 | + | const messages = await ctx.db | |
| 112 | + | .query('messages') | |
| 113 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |
| 114 | + | .order('desc') | |
| 115 | + | .take(limit); | |
| 116 | + | ||
| 117 | + | // Enrich messages with sender info | |
| 118 | + | const enrichedMessages = await Promise.all( | |
| 119 | + | messages.map(async (msg) => { | |
| 120 | + | const sender = await ctx.db.get(msg.senderId); | |
| 121 | + | return { | |
| 122 | + | id: msg._id, | |
| 123 | + | chatRoomId: msg.chatRoomId, | |
| 124 | + | senderId: msg.senderId, | |
| 125 | + | content: msg.content, | |
| 126 | + | type: msg.type, | |
| 127 | + | timestamp: msg.timestamp, | |
| 128 | + | styleData: msg.styleData, | |
| 129 | + | hasFormatting: msg.hasFormatting, | |
| 130 | + | sender: sender | |
| 131 | + | ? { | |
| 132 | + | id: sender._id, | |
| 133 | + | nickname: sender.nickname, | |
| 134 | + | status: sender.status, | |
| 135 | + | avatarUrl: sender.avatarUrl | |
| 136 | + | } | |
| 137 | + | : null | |
| 138 | + | }; | |
| 139 | + | }) | |
| 140 | + | ); | |
| 141 | + | ||
| 142 | + | // Return in chronological order (oldest first) | |
| 143 | + | return enrichedMessages.reverse(); | |
| 144 | + | } | |
| 145 | + | }); | |
| 146 | + | ||
| 147 | + | // Authenticated query: Get all users (real-time subscription for buddy list) | |
| 148 | + | export const getUsers = authQuery({ | |
| 149 | + | args: {}, | |
| 150 | + | handler: async (ctx) => { | |
| 151 | + | const users = await ctx.db.query('users').collect(); | |
| 152 | + | ||
| 153 | + | // Return safe user data (no passwords) | |
| 154 | + | return users.map((user) => ({ | |
| 155 | + | id: user._id, | |
| 156 | + | nickname: user.nickname, | |
| 157 | + | status: user.status, | |
| 158 | + | avatarUrl: user.avatarUrl, | |
| 159 | + | lastSeen: user.lastSeen | |
| 160 | + | })); | |
| 161 | + | } | |
| 162 | + | }); | |
| 163 | + | ||
| 164 | + | // Authenticated query: Get chat rooms | |
| 165 | + | export const getChatRooms = authQuery({ | |
| 166 | + | args: {}, | |
| 167 | + | handler: async (ctx) => { | |
| 168 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 169 | + | return rooms.map((room) => ({ | |
| 170 | + | id: room._id, | |
| 171 | + | name: room.name, | |
| 172 | + | type: room.type, | |
| 173 | + | createdAt: room.createdAt | |
| 174 | + | })); | |
| 175 | + | } | |
| 176 | + | }); | |
| 177 | + | ||
| 178 | + | // Authenticated query: Get default room (or first group room) | |
| 179 | + | export const getDefaultRoom = authQuery({ | |
| 180 | + | args: {}, | |
| 181 | + | handler: async (ctx) => { | |
| 182 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |
| 183 | + | const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |
| 184 | + | ||
| 185 | + | if (defaultRoom) { | |
| 186 | + | return { | |
| 187 | + | id: defaultRoom._id, | |
| 188 | + | name: defaultRoom.name, | |
| 189 | + | type: defaultRoom.type, | |
| 190 | + | createdAt: defaultRoom.createdAt | |
| 191 | + | }; | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | // Return first group room if General doesn't exist | |
| 195 | + | const firstGroup = rooms.find((r) => r.type === 'group'); | |
| 196 | + | if (firstGroup) { | |
| 197 | + | return { | |
| 198 | + | id: firstGroup._id, | |
| 199 | + | name: firstGroup.name, | |
| 200 | + | type: firstGroup.type, | |
| 201 | + | createdAt: firstGroup.createdAt | |
| 202 | + | }; | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | return null; | |
| 206 | + | } | |
| 207 | + | }); | |
| 208 | + | ||
| 209 | + | // Authenticated query: Get current user info | |
| 210 | + | export const getCurrentUser = authQuery({ | |
| 211 | + | args: {}, | |
| 212 | + | handler: async (ctx) => { | |
| 213 | + | // ctx.user is provided by authQuery | |
| 214 | + | return { | |
| 215 | + | id: ctx.user._id, | |
| 216 | + | nickname: ctx.user.nickname, | |
| 217 | + | status: ctx.user.status, | |
| 218 | + | avatarUrl: ctx.user.avatarUrl, | |
| 219 | + | lastSeen: ctx.user.lastSeen | |
| 220 | + | }; | |
| 221 | + | } | |
| 222 | + | }); | |
| @@ -0,0 +1,222 @@ | |||||
| 1 | + | import { query } from './_generated/server'; | |||
| 2 | + | import { authQuery } from './auth'; | |||
| 3 | + | import { v } from 'convex/values'; | |||
| 4 | + | ||||
| 5 | + | // ============ PUBLIC QUERIES (no auth required) ============ | |||
| 6 | + | ||||
| 7 | + | // Public query: Get messages for a chat room (real-time subscription) | |||
| 8 | + | export const getMessagesPublic = query({ | |||
| 9 | + | args: { | |||
| 10 | + | roomId: v.id('chatRooms'), | |||
| 11 | + | limit: v.optional(v.number()) | |||
| 12 | + | }, | |||
| 13 | + | handler: async (ctx, args) => { | |||
| 14 | + | const limit = args.limit ?? 50; | |||
| 15 | + | ||||
| 16 | + | const messages = await ctx.db | |||
| 17 | + | .query('messages') | |||
| 18 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |||
| 19 | + | .order('desc') | |||
| 20 | + | .take(limit); | |||
| 21 | + | ||||
| 22 | + | // Enrich messages with sender info | |||
| 23 | + | const enrichedMessages = await Promise.all( | |||
| 24 | + | messages.map(async (msg) => { | |||
| 25 | + | const sender = await ctx.db.get(msg.senderId); | |||
| 26 | + | return { | |||
| 27 | + | id: msg._id, | |||
| 28 | + | chatRoomId: msg.chatRoomId, | |||
| 29 | + | senderId: msg.senderId, | |||
| 30 | + | content: msg.content, | |||
| 31 | + | type: msg.type, | |||
| 32 | + | timestamp: msg.timestamp, | |||
| 33 | + | styleData: msg.styleData, | |||
| 34 | + | hasFormatting: msg.hasFormatting, | |||
| 35 | + | sender: sender | |||
| 36 | + | ? { | |||
| 37 | + | id: sender._id, | |||
| 38 | + | nickname: sender.nickname, | |||
| 39 | + | status: sender.status, | |||
| 40 | + | avatarUrl: sender.avatarUrl | |||
| 41 | + | } | |||
| 42 | + | : null | |||
| 43 | + | }; | |||
| 44 | + | }) | |||
| 45 | + | ); | |||
| 46 | + | ||||
| 47 | + | // Return in chronological order (oldest first) | |||
| 48 | + | return enrichedMessages.reverse(); | |||
| 49 | + | } | |||
| 50 | + | }); | |||
| 51 | + | ||||
| 52 | + | // Public query: Get all users (real-time subscription for buddy list) | |||
| 53 | + | export const getUsersPublic = query({ | |||
| 54 | + | args: {}, | |||
| 55 | + | handler: async (ctx) => { | |||
| 56 | + | const users = await ctx.db.query('users').collect(); | |||
| 57 | + | ||||
| 58 | + | // Return safe user data (no passwords) | |||
| 59 | + | return users.map((user) => ({ | |||
| 60 | + | id: user._id, | |||
| 61 | + | nickname: user.nickname, | |||
| 62 | + | status: user.status, | |||
| 63 | + | avatarUrl: user.avatarUrl, | |||
| 64 | + | lastSeen: user.lastSeen | |||
| 65 | + | })); | |||
| 66 | + | } | |||
| 67 | + | }); | |||
| 68 | + | ||||
| 69 | + | // Public query: Get default room | |||
| 70 | + | export const getDefaultRoomPublic = query({ | |||
| 71 | + | args: {}, | |||
| 72 | + | handler: async (ctx) => { | |||
| 73 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |||
| 74 | + | const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |||
| 75 | + | ||||
| 76 | + | if (defaultRoom) { | |||
| 77 | + | return { | |||
| 78 | + | id: defaultRoom._id, | |||
| 79 | + | name: defaultRoom.name, | |||
| 80 | + | type: defaultRoom.type, | |||
| 81 | + | createdAt: defaultRoom.createdAt | |||
| 82 | + | }; | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | // Return first group room if General doesn't exist | |||
| 86 | + | const firstGroup = rooms.find((r) => r.type === 'group'); | |||
| 87 | + | if (firstGroup) { | |||
| 88 | + | return { | |||
| 89 | + | id: firstGroup._id, | |||
| 90 | + | name: firstGroup.name, | |||
| 91 | + | type: firstGroup.type, | |||
| 92 | + | createdAt: firstGroup.createdAt | |||
| 93 | + | }; | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | return null; | |||
| 97 | + | } | |||
| 98 | + | }); | |||
| 99 | + | ||||
| 100 | + | // ============ AUTHENTICATED QUERIES ============ | |||
| 101 | + | ||||
| 102 | + | // Authenticated query: Get messages for a chat room (real-time subscription) | |||
| 103 | + | export const getMessages = authQuery({ | |||
| 104 | + | args: { | |||
| 105 | + | roomId: v.id('chatRooms'), | |||
| 106 | + | limit: v.optional(v.number()) | |||
| 107 | + | }, | |||
| 108 | + | handler: async (ctx, args) => { | |||
| 109 | + | const limit = args.limit ?? 100; | |||
| 110 | + | ||||
| 111 | + | const messages = await ctx.db | |||
| 112 | + | .query('messages') | |||
| 113 | + | .withIndex('by_chatRoom', (q) => q.eq('chatRoomId', args.roomId)) | |||
| 114 | + | .order('desc') | |||
| 115 | + | .take(limit); | |||
| 116 | + | ||||
| 117 | + | // Enrich messages with sender info | |||
| 118 | + | const enrichedMessages = await Promise.all( | |||
| 119 | + | messages.map(async (msg) => { | |||
| 120 | + | const sender = await ctx.db.get(msg.senderId); | |||
| 121 | + | return { | |||
| 122 | + | id: msg._id, | |||
| 123 | + | chatRoomId: msg.chatRoomId, | |||
| 124 | + | senderId: msg.senderId, | |||
| 125 | + | content: msg.content, | |||
| 126 | + | type: msg.type, | |||
| 127 | + | timestamp: msg.timestamp, | |||
| 128 | + | styleData: msg.styleData, | |||
| 129 | + | hasFormatting: msg.hasFormatting, | |||
| 130 | + | sender: sender | |||
| 131 | + | ? { | |||
| 132 | + | id: sender._id, | |||
| 133 | + | nickname: sender.nickname, | |||
| 134 | + | status: sender.status, | |||
| 135 | + | avatarUrl: sender.avatarUrl | |||
| 136 | + | } | |||
| 137 | + | : null | |||
| 138 | + | }; | |||
| 139 | + | }) | |||
| 140 | + | ); | |||
| 141 | + | ||||
| 142 | + | // Return in chronological order (oldest first) | |||
| 143 | + | return enrichedMessages.reverse(); | |||
| 144 | + | } | |||
| 145 | + | }); | |||
| 146 | + | ||||
| 147 | + | // Authenticated query: Get all users (real-time subscription for buddy list) | |||
| 148 | + | export const getUsers = authQuery({ | |||
| 149 | + | args: {}, | |||
| 150 | + | handler: async (ctx) => { | |||
| 151 | + | const users = await ctx.db.query('users').collect(); | |||
| 152 | + | ||||
| 153 | + | // Return safe user data (no passwords) | |||
| 154 | + | return users.map((user) => ({ | |||
| 155 | + | id: user._id, | |||
| 156 | + | nickname: user.nickname, | |||
| 157 | + | status: user.status, | |||
| 158 | + | avatarUrl: user.avatarUrl, | |||
| 159 | + | lastSeen: user.lastSeen | |||
| 160 | + | })); | |||
| 161 | + | } | |||
| 162 | + | }); | |||
| 163 | + | ||||
| 164 | + | // Authenticated query: Get chat rooms | |||
| 165 | + | export const getChatRooms = authQuery({ | |||
| 166 | + | args: {}, | |||
| 167 | + | handler: async (ctx) => { | |||
| 168 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |||
| 169 | + | return rooms.map((room) => ({ | |||
| 170 | + | id: room._id, | |||
| 171 | + | name: room.name, | |||
| 172 | + | type: room.type, | |||
| 173 | + | createdAt: room.createdAt | |||
| 174 | + | })); | |||
| 175 | + | } | |||
| 176 | + | }); | |||
| 177 | + | ||||
| 178 | + | // Authenticated query: Get default room (or first group room) | |||
| 179 | + | export const getDefaultRoom = authQuery({ | |||
| 180 | + | args: {}, | |||
| 181 | + | handler: async (ctx) => { | |||
| 182 | + | const rooms = await ctx.db.query('chatRooms').collect(); | |||
| 183 | + | const defaultRoom = rooms.find((r) => r.name === 'General' && r.type === 'group'); | |||
| 184 | + | ||||
| 185 | + | if (defaultRoom) { | |||
| 186 | + | return { | |||
| 187 | + | id: defaultRoom._id, | |||
| 188 | + | name: defaultRoom.name, | |||
| 189 | + | type: defaultRoom.type, | |||
| 190 | + | createdAt: defaultRoom.createdAt | |||
| 191 | + | }; | |||
| 192 | + | } | |||
| 193 | + | ||||
| 194 | + | // Return first group room if General doesn't exist | |||
| 195 | + | const firstGroup = rooms.find((r) => r.type === 'group'); | |||
| 196 | + | if (firstGroup) { | |||
| 197 | + | return { | |||
| 198 | + | id: firstGroup._id, | |||
| 199 | + | name: firstGroup.name, | |||
| 200 | + | type: firstGroup.type, | |||
| 201 | + | createdAt: firstGroup.createdAt | |||
| 202 | + | }; | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | return null; | |||
| 206 | + | } | |||
| 207 | + | }); | |||
| 208 | + | ||||
| 209 | + | // Authenticated query: Get current user info | |||
| 210 | + | export const getCurrentUser = authQuery({ | |||
| 211 | + | args: {}, | |||
| 212 | + | handler: async (ctx) => { | |||
| 213 | + | // ctx.user is provided by authQuery | |||
| 214 | + | return { | |||
| 215 | + | id: ctx.user._id, | |||
| 216 | + | nickname: ctx.user.nickname, | |||
| 217 | + | status: ctx.user.status, | |||
| 218 | + | avatarUrl: ctx.user.avatarUrl, | |||
| 219 | + | lastSeen: ctx.user.lastSeen | |||
| 220 | + | }; | |||
| 221 | + | } | |||
| 222 | + | }); | |||
A src/convex/schema.ts +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | import { defineSchema, defineTable } from 'convex/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | export default defineSchema({ | |
| 5 | + | users: defineTable({ | |
| 6 | + | password: v.string(), | |
| 7 | + | nickname: v.string(), | |
| 8 | + | status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | |
| 9 | + | avatarUrl: v.optional(v.string()), | |
| 10 | + | createdAt: v.number(), | |
| 11 | + | lastSeen: v.optional(v.number()) | |
| 12 | + | }).index('by_nickname', ['nickname']), | |
| 13 | + | ||
| 14 | + | sessions: defineTable({ | |
| 15 | + | tokenHash: v.string(), // SHA-256 hash of the session token | |
| 16 | + | userId: v.id('users'), | |
| 17 | + | expiresAt: v.number(), | |
| 18 | + | createdAt: v.number() | |
| 19 | + | }) | |
| 20 | + | .index('by_tokenHash', ['tokenHash']) | |
| 21 | + | .index('by_userId', ['userId']), | |
| 22 | + | ||
| 23 | + | chatRooms: defineTable({ | |
| 24 | + | name: v.optional(v.string()), | |
| 25 | + | type: v.string(), // 'direct' | 'group' | |
| 26 | + | createdAt: v.number() | |
| 27 | + | }), | |
| 28 | + | ||
| 29 | + | messages: defineTable({ | |
| 30 | + | chatRoomId: v.id('chatRooms'), | |
| 31 | + | senderId: v.id('users'), | |
| 32 | + | content: v.string(), | |
| 33 | + | type: v.string(), // 'chat' | 'emote' | 'system' | |
| 34 | + | timestamp: v.number(), | |
| 35 | + | styleData: v.optional(v.string()), | |
| 36 | + | hasFormatting: v.optional(v.boolean()) | |
| 37 | + | }) | |
| 38 | + | .index('by_chatRoom', ['chatRoomId']) | |
| 39 | + | .index('by_timestamp', ['timestamp']), | |
| 40 | + | ||
| 41 | + | userTextPreferences: defineTable({ | |
| 42 | + | userId: v.id('users'), | |
| 43 | + | defaultFontFamily: v.optional(v.string()), | |
| 44 | + | defaultFontSize: v.optional(v.number()), | |
| 45 | + | defaultColor: v.optional(v.string()), | |
| 46 | + | allowFormatting: v.optional(v.boolean()), | |
| 47 | + | maxMessageLength: v.optional(v.number()), | |
| 48 | + | stylePresets: v.optional(v.string()), | |
| 49 | + | createdAt: v.number(), | |
| 50 | + | updatedAt: v.number() | |
| 51 | + | }).index('by_userId', ['userId']) | |
| 52 | + | }); | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | import { defineSchema, defineTable } from 'convex/server'; | |||
| 2 | + | import { v } from 'convex/values'; | |||
| 3 | + | ||||
| 4 | + | export default defineSchema({ | |||
| 5 | + | users: defineTable({ | |||
| 6 | + | password: v.string(), | |||
| 7 | + | nickname: v.string(), | |||
| 8 | + | status: v.string(), // 'offline' | 'online' | 'away' | 'busy' | |||
| 9 | + | avatarUrl: v.optional(v.string()), | |||
| 10 | + | createdAt: v.number(), | |||
| 11 | + | lastSeen: v.optional(v.number()) | |||
| 12 | + | }).index('by_nickname', ['nickname']), | |||
| 13 | + | ||||
| 14 | + | sessions: defineTable({ | |||
| 15 | + | tokenHash: v.string(), // SHA-256 hash of the session token | |||
| 16 | + | userId: v.id('users'), | |||
| 17 | + | expiresAt: v.number(), | |||
| 18 | + | createdAt: v.number() | |||
| 19 | + | }) | |||
| 20 | + | .index('by_tokenHash', ['tokenHash']) | |||
| 21 | + | .index('by_userId', ['userId']), | |||
| 22 | + | ||||
| 23 | + | chatRooms: defineTable({ | |||
| 24 | + | name: v.optional(v.string()), | |||
| 25 | + | type: v.string(), // 'direct' | 'group' | |||
| 26 | + | createdAt: v.number() | |||
| 27 | + | }), | |||
| 28 | + | ||||
| 29 | + | messages: defineTable({ | |||
| 30 | + | chatRoomId: v.id('chatRooms'), | |||
| 31 | + | senderId: v.id('users'), | |||
| 32 | + | content: v.string(), | |||
| 33 | + | type: v.string(), // 'chat' | 'emote' | 'system' | |||
| 34 | + | timestamp: v.number(), | |||
| 35 | + | styleData: v.optional(v.string()), | |||
| 36 | + | hasFormatting: v.optional(v.boolean()) | |||
| 37 | + | }) | |||
| 38 | + | .index('by_chatRoom', ['chatRoomId']) | |||
| 39 | + | .index('by_timestamp', ['timestamp']), | |||
| 40 | + | ||||
| 41 | + | userTextPreferences: defineTable({ | |||
| 42 | + | userId: v.id('users'), | |||
| 43 | + | defaultFontFamily: v.optional(v.string()), | |||
| 44 | + | defaultFontSize: v.optional(v.number()), | |||
| 45 | + | defaultColor: v.optional(v.string()), | |||
| 46 | + | allowFormatting: v.optional(v.boolean()), | |||
| 47 | + | maxMessageLength: v.optional(v.number()), | |||
| 48 | + | stylePresets: v.optional(v.string()), | |||
| 49 | + | createdAt: v.number(), | |||
| 50 | + | updatedAt: v.number() | |||
| 51 | + | }).index('by_userId', ['userId']) | |||
| 52 | + | }); | |||
A src/convex/sessionsInternal.ts +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | // Create a new session (internal) | |
| 5 | + | export const create = internalMutation({ | |
| 6 | + | args: { | |
| 7 | + | tokenHash: v.string(), // SHA-256 hash of the token | |
| 8 | + | userId: v.id('users'), | |
| 9 | + | expiresAt: v.number() | |
| 10 | + | }, | |
| 11 | + | handler: async (ctx, args) => { | |
| 12 | + | const user = await ctx.db.get(args.userId); | |
| 13 | + | if (!user) { | |
| 14 | + | throw new Error('User not found'); | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | const id = await ctx.db.insert('sessions', { | |
| 18 | + | tokenHash: args.tokenHash, | |
| 19 | + | userId: args.userId, | |
| 20 | + | expiresAt: args.expiresAt, | |
| 21 | + | createdAt: Date.now() | |
| 22 | + | }); | |
| 23 | + | ||
| 24 | + | return { id, tokenHash: args.tokenHash }; | |
| 25 | + | } | |
| 26 | + | }); | |
| 27 | + | ||
| 28 | + | // Validate session and get user (internal) | |
| 29 | + | export const validateAndGetUser = internalQuery({ | |
| 30 | + | args: { tokenHash: v.string() }, | |
| 31 | + | handler: async (ctx, args) => { | |
| 32 | + | // Find session by token hash | |
| 33 | + | const session = await ctx.db | |
| 34 | + | .query('sessions') | |
| 35 | + | .withIndex('by_tokenHash', (q) => q.eq('tokenHash', args.tokenHash)) | |
| 36 | + | .first(); | |
| 37 | + | ||
| 38 | + | if (!session) { | |
| 39 | + | return { session: null, user: null }; | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // Check if expired | |
| 43 | + | if (session.expiresAt < Date.now()) { | |
| 44 | + | return { session: null, user: null }; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Get the user | |
| 48 | + | const user = await ctx.db.get(session.userId); | |
| 49 | + | if (!user) { | |
| 50 | + | return { session: null, user: null }; | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | return { session, user }; | |
| 54 | + | } | |
| 55 | + | }); | |
| 56 | + | ||
| 57 | + | // Delete a session by token hash (internal) | |
| 58 | + | export const remove = internalMutation({ | |
| 59 | + | args: { tokenHash: v.string() }, | |
| 60 | + | handler: async (ctx, args) => { | |
| 61 | + | const session = await ctx.db | |
| 62 | + | .query('sessions') | |
| 63 | + | .withIndex('by_tokenHash', (q) => q.eq('tokenHash', args.tokenHash)) | |
| 64 | + | .first(); | |
| 65 | + | ||
| 66 | + | if (session) { | |
| 67 | + | await ctx.db.delete(session._id); | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | }); | |
| 71 | + | ||
| 72 | + | // Delete all sessions for a user (internal) | |
| 73 | + | export const removeAllForUser = internalMutation({ | |
| 74 | + | args: { userId: v.id('users') }, | |
| 75 | + | handler: async (ctx, args) => { | |
| 76 | + | const sessions = await ctx.db | |
| 77 | + | .query('sessions') | |
| 78 | + | .withIndex('by_userId', (q) => q.eq('userId', args.userId)) | |
| 79 | + | .collect(); | |
| 80 | + | ||
| 81 | + | for (const session of sessions) { | |
| 82 | + | await ctx.db.delete(session._id); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | return { deleted: sessions.length }; | |
| 86 | + | } | |
| 87 | + | }); | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |||
| 2 | + | import { v } from 'convex/values'; | |||
| 3 | + | ||||
| 4 | + | // Create a new session (internal) | |||
| 5 | + | export const create = internalMutation({ | |||
| 6 | + | args: { | |||
| 7 | + | tokenHash: v.string(), // SHA-256 hash of the token | |||
| 8 | + | userId: v.id('users'), | |||
| 9 | + | expiresAt: v.number() | |||
| 10 | + | }, | |||
| 11 | + | handler: async (ctx, args) => { | |||
| 12 | + | const user = await ctx.db.get(args.userId); | |||
| 13 | + | if (!user) { | |||
| 14 | + | throw new Error('User not found'); | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | const id = await ctx.db.insert('sessions', { | |||
| 18 | + | tokenHash: args.tokenHash, | |||
| 19 | + | userId: args.userId, | |||
| 20 | + | expiresAt: args.expiresAt, | |||
| 21 | + | createdAt: Date.now() | |||
| 22 | + | }); | |||
| 23 | + | ||||
| 24 | + | return { id, tokenHash: args.tokenHash }; | |||
| 25 | + | } | |||
| 26 | + | }); | |||
| 27 | + | ||||
| 28 | + | // Validate session and get user (internal) | |||
| 29 | + | export const validateAndGetUser = internalQuery({ | |||
| 30 | + | args: { tokenHash: v.string() }, | |||
| 31 | + | handler: async (ctx, args) => { | |||
| 32 | + | // Find session by token hash | |||
| 33 | + | const session = await ctx.db | |||
| 34 | + | .query('sessions') | |||
| 35 | + | .withIndex('by_tokenHash', (q) => q.eq('tokenHash', args.tokenHash)) | |||
| 36 | + | .first(); | |||
| 37 | + | ||||
| 38 | + | if (!session) { | |||
| 39 | + | return { session: null, user: null }; | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // Check if expired | |||
| 43 | + | if (session.expiresAt < Date.now()) { | |||
| 44 | + | return { session: null, user: null }; | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // Get the user | |||
| 48 | + | const user = await ctx.db.get(session.userId); | |||
| 49 | + | if (!user) { | |||
| 50 | + | return { session: null, user: null }; | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | return { session, user }; | |||
| 54 | + | } | |||
| 55 | + | }); | |||
| 56 | + | ||||
| 57 | + | // Delete a session by token hash (internal) | |||
| 58 | + | export const remove = internalMutation({ | |||
| 59 | + | args: { tokenHash: v.string() }, | |||
| 60 | + | handler: async (ctx, args) => { | |||
| 61 | + | const session = await ctx.db | |||
| 62 | + | .query('sessions') | |||
| 63 | + | .withIndex('by_tokenHash', (q) => q.eq('tokenHash', args.tokenHash)) | |||
| 64 | + | .first(); | |||
| 65 | + | ||||
| 66 | + | if (session) { | |||
| 67 | + | await ctx.db.delete(session._id); | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | }); | |||
| 71 | + | ||||
| 72 | + | // Delete all sessions for a user (internal) | |||
| 73 | + | export const removeAllForUser = internalMutation({ | |||
| 74 | + | args: { userId: v.id('users') }, | |||
| 75 | + | handler: async (ctx, args) => { | |||
| 76 | + | const sessions = await ctx.db | |||
| 77 | + | .query('sessions') | |||
| 78 | + | .withIndex('by_userId', (q) => q.eq('userId', args.userId)) | |||
| 79 | + | .collect(); | |||
| 80 | + | ||||
| 81 | + | for (const session of sessions) { | |||
| 82 | + | await ctx.db.delete(session._id); | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | return { deleted: sessions.length }; | |||
| 86 | + | } | |||
| 87 | + | }); | |||
A src/convex/tsconfig.json +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | { | |
| 2 | + | /* This TypeScript project config describes the environment that | |
| 3 | + | * Convex functions run in and is used to typecheck them. | |
| 4 | + | * You can modify it, but some settings are required to use Convex. | |
| 5 | + | */ | |
| 6 | + | "compilerOptions": { | |
| 7 | + | /* These settings are not required by Convex and can be modified. */ | |
| 8 | + | "allowJs": true, | |
| 9 | + | "strict": true, | |
| 10 | + | "moduleResolution": "Bundler", | |
| 11 | + | "jsx": "react-jsx", | |
| 12 | + | "skipLibCheck": true, | |
| 13 | + | "allowSyntheticDefaultImports": true, | |
| 14 | + | ||
| 15 | + | /* These compiler options are required by Convex */ | |
| 16 | + | "target": "ESNext", | |
| 17 | + | "lib": ["ES2021", "dom"], | |
| 18 | + | "forceConsistentCasingInFileNames": true, | |
| 19 | + | "module": "ESNext", | |
| 20 | + | "isolatedModules": true, | |
| 21 | + | "noEmit": true | |
| 22 | + | }, | |
| 23 | + | "include": ["./**/*"], | |
| 24 | + | "exclude": ["./_generated"] | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | { | |||
| 2 | + | /* This TypeScript project config describes the environment that | |||
| 3 | + | * Convex functions run in and is used to typecheck them. | |||
| 4 | + | * You can modify it, but some settings are required to use Convex. | |||
| 5 | + | */ | |||
| 6 | + | "compilerOptions": { | |||
| 7 | + | /* These settings are not required by Convex and can be modified. */ | |||
| 8 | + | "allowJs": true, | |||
| 9 | + | "strict": true, | |||
| 10 | + | "moduleResolution": "Bundler", | |||
| 11 | + | "jsx": "react-jsx", | |||
| 12 | + | "skipLibCheck": true, | |||
| 13 | + | "allowSyntheticDefaultImports": true, | |||
| 14 | + | ||||
| 15 | + | /* These compiler options are required by Convex */ | |||
| 16 | + | "target": "ESNext", | |||
| 17 | + | "lib": ["ES2021", "dom"], | |||
| 18 | + | "forceConsistentCasingInFileNames": true, | |||
| 19 | + | "module": "ESNext", | |||
| 20 | + | "isolatedModules": true, | |||
| 21 | + | "noEmit": true | |||
| 22 | + | }, | |||
| 23 | + | "include": ["./**/*"], | |||
| 24 | + | "exclude": ["./_generated"] | |||
| 25 | + | } | |||
A src/convex/usersInternal.ts +95 -0
| @@ -0,0 +1,95 @@ | |||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |
| 2 | + | import { v } from 'convex/values'; | |
| 3 | + | ||
| 4 | + | // Get user by nickname (internal) | |
| 5 | + | export const getByNickname = internalQuery({ | |
| 6 | + | args: { nickname: v.string() }, | |
| 7 | + | handler: async (ctx, args) => { | |
| 8 | + | return await ctx.db | |
| 9 | + | .query('users') | |
| 10 | + | .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname)) | |
| 11 | + | .first(); | |
| 12 | + | } | |
| 13 | + | }); | |
| 14 | + | ||
| 15 | + | // Get user by ID (internal) | |
| 16 | + | export const getById = internalQuery({ | |
| 17 | + | args: { id: v.id('users') }, | |
| 18 | + | handler: async (ctx, args) => { | |
| 19 | + | return await ctx.db.get(args.id); | |
| 20 | + | } | |
| 21 | + | }); | |
| 22 | + | ||
| 23 | + | // Get all users (internal) | |
| 24 | + | export const list = internalQuery({ | |
| 25 | + | args: {}, | |
| 26 | + | handler: async (ctx) => { | |
| 27 | + | return await ctx.db.query('users').collect(); | |
| 28 | + | } | |
| 29 | + | }); | |
| 30 | + | ||
| 31 | + | // Create a new user (internal) | |
| 32 | + | export const create = internalMutation({ | |
| 33 | + | args: { | |
| 34 | + | nickname: v.string(), | |
| 35 | + | password: v.string() | |
| 36 | + | }, | |
| 37 | + | handler: async (ctx, args) => { | |
| 38 | + | // Check if nickname already exists | |
| 39 | + | const existing = await ctx.db | |
| 40 | + | .query('users') | |
| 41 | + | .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname)) | |
| 42 | + | .first(); | |
| 43 | + | ||
| 44 | + | if (existing) { | |
| 45 | + | throw new Error('Username already taken'); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | const userId = await ctx.db.insert('users', { | |
| 49 | + | nickname: args.nickname, | |
| 50 | + | password: args.password, | |
| 51 | + | status: 'offline', | |
| 52 | + | createdAt: Date.now() | |
| 53 | + | }); | |
| 54 | + | ||
| 55 | + | return userId; | |
| 56 | + | } | |
| 57 | + | }); | |
| 58 | + | ||
| 59 | + | // Update user status by ID (internal) | |
| 60 | + | export const updateStatus = internalMutation({ | |
| 61 | + | args: { | |
| 62 | + | id: v.id('users'), | |
| 63 | + | status: v.string() | |
| 64 | + | }, | |
| 65 | + | handler: async (ctx, args) => { | |
| 66 | + | const validStatuses = ['online', 'away', 'busy', 'offline', 'idle']; | |
| 67 | + | if (!validStatuses.includes(args.status)) { | |
| 68 | + | throw new Error('Invalid status'); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | const user = await ctx.db.get(args.id); | |
| 72 | + | if (!user) { | |
| 73 | + | throw new Error('User not found'); | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | await ctx.db.patch(args.id, { | |
| 77 | + | status: args.status, | |
| 78 | + | lastSeen: Date.now() | |
| 79 | + | }); | |
| 80 | + | ||
| 81 | + | return await ctx.db.get(args.id); | |
| 82 | + | } | |
| 83 | + | }); | |
| 84 | + | ||
| 85 | + | // Set all users offline (internal) - used on server startup | |
| 86 | + | export const setAllOffline = internalMutation({ | |
| 87 | + | args: {}, | |
| 88 | + | handler: async (ctx) => { | |
| 89 | + | const users = await ctx.db.query('users').collect(); | |
| 90 | + | for (const user of users) { | |
| 91 | + | await ctx.db.patch(user._id, { status: 'offline' }); | |
| 92 | + | } | |
| 93 | + | return { updated: users.length }; | |
| 94 | + | } | |
| 95 | + | }); | |
| @@ -0,0 +1,95 @@ | |||||
| 1 | + | import { internalQuery, internalMutation } from './_generated/server'; | |||
| 2 | + | import { v } from 'convex/values'; | |||
| 3 | + | ||||
| 4 | + | // Get user by nickname (internal) | |||
| 5 | + | export const getByNickname = internalQuery({ | |||
| 6 | + | args: { nickname: v.string() }, | |||
| 7 | + | handler: async (ctx, args) => { | |||
| 8 | + | return await ctx.db | |||
| 9 | + | .query('users') | |||
| 10 | + | .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname)) | |||
| 11 | + | .first(); | |||
| 12 | + | } | |||
| 13 | + | }); | |||
| 14 | + | ||||
| 15 | + | // Get user by ID (internal) | |||
| 16 | + | export const getById = internalQuery({ | |||
| 17 | + | args: { id: v.id('users') }, | |||
| 18 | + | handler: async (ctx, args) => { | |||
| 19 | + | return await ctx.db.get(args.id); | |||
| 20 | + | } | |||
| 21 | + | }); | |||
| 22 | + | ||||
| 23 | + | // Get all users (internal) | |||
| 24 | + | export const list = internalQuery({ | |||
| 25 | + | args: {}, | |||
| 26 | + | handler: async (ctx) => { | |||
| 27 | + | return await ctx.db.query('users').collect(); | |||
| 28 | + | } | |||
| 29 | + | }); | |||
| 30 | + | ||||
| 31 | + | // Create a new user (internal) | |||
| 32 | + | export const create = internalMutation({ | |||
| 33 | + | args: { | |||
| 34 | + | nickname: v.string(), | |||
| 35 | + | password: v.string() | |||
| 36 | + | }, | |||
| 37 | + | handler: async (ctx, args) => { | |||
| 38 | + | // Check if nickname already exists | |||
| 39 | + | const existing = await ctx.db | |||
| 40 | + | .query('users') | |||
| 41 | + | .withIndex('by_nickname', (q) => q.eq('nickname', args.nickname)) | |||
| 42 | + | .first(); | |||
| 43 | + | ||||
| 44 | + | if (existing) { | |||
| 45 | + | throw new Error('Username already taken'); | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | const userId = await ctx.db.insert('users', { | |||
| 49 | + | nickname: args.nickname, | |||
| 50 | + | password: args.password, | |||
| 51 | + | status: 'offline', | |||
| 52 | + | createdAt: Date.now() | |||
| 53 | + | }); | |||
| 54 | + | ||||
| 55 | + | return userId; | |||
| 56 | + | } | |||
| 57 | + | }); | |||
| 58 | + | ||||
| 59 | + | // Update user status by ID (internal) | |||
| 60 | + | export const updateStatus = internalMutation({ | |||
| 61 | + | args: { | |||
| 62 | + | id: v.id('users'), | |||
| 63 | + | status: v.string() | |||
| 64 | + | }, | |||
| 65 | + | handler: async (ctx, args) => { | |||
| 66 | + | const validStatuses = ['online', 'away', 'busy', 'offline', 'idle']; | |||
| 67 | + | if (!validStatuses.includes(args.status)) { | |||
| 68 | + | throw new Error('Invalid status'); | |||
| 69 | + | } | |||
| 70 | + | ||||
| 71 | + | const user = await ctx.db.get(args.id); | |||
| 72 | + | if (!user) { | |||
| 73 | + | throw new Error('User not found'); | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | await ctx.db.patch(args.id, { | |||
| 77 | + | status: args.status, | |||
| 78 | + | lastSeen: Date.now() | |||
| 79 | + | }); | |||
| 80 | + | ||||
| 81 | + | return await ctx.db.get(args.id); | |||
| 82 | + | } | |||
| 83 | + | }); | |||
| 84 | + | ||||
| 85 | + | // Set all users offline (internal) - used on server startup | |||
| 86 | + | export const setAllOffline = internalMutation({ | |||
| 87 | + | args: {}, | |||
| 88 | + | handler: async (ctx) => { | |||
| 89 | + | const users = await ctx.db.query('users').collect(); | |||
| 90 | + | for (const user of users) { | |||
| 91 | + | await ctx.db.patch(user._id, { status: 'offline' }); | |||
| 92 | + | } | |||
| 93 | + | return { updated: users.length }; | |||
| 94 | + | } | |||
| 95 | + | }); | |||