detached d01ad39

refactor(convex): add Convex backend schema and functions

commit
d01ad39b89be7a90c8f489ebde036703e4b456bf
author
bnema · · 2026-01-18 18:34 UTC
parent
ececade
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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+});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 3

Tags 8

↑↓ moveenter openesc close