aboutsummaryrefslogtreecommitdiffstats
path: root/packages
diff options
context:
space:
mode:
authorMohamed Bassem <me@mbassem.com>2025-03-11 20:09:32 +0000
committerMohamed Bassem <me@mbassem.com>2025-03-11 20:09:32 +0000
commitb8c587e3c3e717263da84522d59c7904715ae22a (patch)
treea26a67162118b4a629d33a833dd25be67d344855 /packages
parent59c444a503c0124988608c190342acc53c797107 (diff)
downloadkarakeep-b8c587e3c3e717263da84522d59c7904715ae22a.tar.zst
feat: Add endpoints for whoami and user stats. Fixes #1113
Diffstat (limited to 'packages')
-rw-r--r--packages/e2e_tests/tests/api/users.test.ts102
-rw-r--r--packages/open-api/hoarder-openapi-spec.json96
-rw-r--r--packages/open-api/index.ts2
-rw-r--r--packages/open-api/lib/users.ts55
-rw-r--r--packages/sdk/src/hoarder-api.d.ts89
-rw-r--r--packages/shared/types/users.ts15
-rw-r--r--packages/trpc/routers/users.ts77
7 files changed, 427 insertions, 9 deletions
diff --git a/packages/e2e_tests/tests/api/users.test.ts b/packages/e2e_tests/tests/api/users.test.ts
new file mode 100644
index 00000000..36c0868d
--- /dev/null
+++ b/packages/e2e_tests/tests/api/users.test.ts
@@ -0,0 +1,102 @@
+import { createHoarderClient } from "@hoarderapp/sdk";
+import { beforeEach, describe, expect, inject, it } from "vitest";
+
+import { createTestUser } from "../../utils/api";
+
+describe("Users API", () => {
+ const port = inject("hoarderPort");
+
+ if (!port) {
+ throw new Error("Missing required environment variables");
+ }
+
+ let client: ReturnType<typeof createHoarderClient>;
+ let apiKey: string;
+
+ beforeEach(async () => {
+ apiKey = await createTestUser();
+ client = createHoarderClient({
+ baseUrl: `http://localhost:${port}/api/v1/`,
+ headers: {
+ "Content-Type": "application/json",
+ authorization: `Bearer ${apiKey}`,
+ },
+ });
+ });
+
+ it("should response with user info", async () => {
+ // Get the user info
+ const { data: userInfo } = await client.GET("/users/me");
+ expect(userInfo).toBeDefined();
+ expect(userInfo?.name).toEqual("Test User");
+ });
+
+ it("should response with user stats", async () => {
+ ////////////////////////////////////////////////////////////////////////////////////
+ // Prepare some data
+ ////////////////////////////////////////////////////////////////////////////////////
+ const { data: createdBookmark1 } = await client.POST("/bookmarks", {
+ body: {
+ type: "text",
+ text: "This is a test bookmark",
+ favourited: true,
+ },
+ });
+ await client.POST("/bookmarks", {
+ body: {
+ type: "text",
+ text: "This is a test bookmark",
+ archived: true,
+ },
+ });
+ // Create a highlight
+ await client.POST("/highlights", {
+ body: {
+ bookmarkId: createdBookmark1!.id,
+ startOffset: 0,
+ endOffset: 5,
+ text: "This is a test highlight",
+ note: "Test note",
+ color: "yellow",
+ },
+ });
+ // attach a tag
+ await client.POST("/bookmarks/{bookmarkId}/tags", {
+ params: {
+ path: {
+ bookmarkId: createdBookmark1!.id,
+ },
+ },
+ body: {
+ tags: [{ tagName: "test-tag" }],
+ },
+ });
+ // create two list
+ await client.POST("/lists", {
+ body: {
+ name: "Test List",
+ icon: "s",
+ },
+ });
+ await client.POST("/lists", {
+ body: {
+ name: "Test List 2",
+ icon: "s",
+ },
+ });
+
+ ////////////////////////////////////////////////////////////////////////////////////
+ // The actual test
+ ////////////////////////////////////////////////////////////////////////////////////
+
+ const { data: userStats } = await client.GET("/users/me/stats");
+
+ expect(userStats).toBeDefined();
+ expect(userStats?.numBookmarks).toBe(2);
+ expect(userStats?.numFavorites).toBe(1);
+ expect(userStats?.numArchived).toBe(1);
+ expect(userStats?.numTags).toBe(1);
+ expect(userStats?.numLists).toBe(2);
+ expect(userStats?.numHighlights).toBe(1);
+ });
+});
diff --git a/packages/open-api/hoarder-openapi-spec.json b/packages/open-api/hoarder-openapi-spec.json
index 7e1911cb..56dad7a7 100644
--- a/packages/open-api/hoarder-openapi-spec.json
+++ b/packages/open-api/hoarder-openapi-spec.json
@@ -2040,6 +2040,102 @@
}
}
}
+ },
+ "/users/me": {
+ "get": {
+ "description": "Returns info about the current user",
+ "summary": "Get current user info",
+ "tags": [
+ "Users"
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Object with user data.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "string"
+ },
+ "name": {
+ "type": "string",
+ "nullable": true
+ },
+ "email": {
+ "type": "string",
+ "nullable": true
+ }
+ },
+ "required": [
+ "id"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
+ },
+ "/users/me/stats": {
+ "get": {
+ "description": "Returns stats about the current user",
+ "summary": "Get current user stats",
+ "tags": [
+ "Users"
+ ],
+ "security": [
+ {
+ "bearerAuth": []
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "Object with user stats.",
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": {
+ "numBookmarks": {
+ "type": "number"
+ },
+ "numFavorites": {
+ "type": "number"
+ },
+ "numArchived": {
+ "type": "number"
+ },
+ "numTags": {
+ "type": "number"
+ },
+ "numLists": {
+ "type": "number"
+ },
+ "numHighlights": {
+ "type": "number"
+ }
+ },
+ "required": [
+ "numBookmarks",
+ "numFavorites",
+ "numArchived",
+ "numTags",
+ "numLists",
+ "numHighlights"
+ ]
+ }
+ }
+ }
+ }
+ }
+ }
}
}
} \ No newline at end of file
diff --git a/packages/open-api/index.ts b/packages/open-api/index.ts
index 75850e5a..d96cb5ca 100644
--- a/packages/open-api/index.ts
+++ b/packages/open-api/index.ts
@@ -9,6 +9,7 @@ import { registry as commonRegistry } from "./lib/common";
import { registry as highlightsRegistry } from "./lib/highlights";
import { registry as listsRegistry } from "./lib/lists";
import { registry as tagsRegistry } from "./lib/tags";
+import { registry as userRegistry } from "./lib/users";
function getOpenApiDocumentation() {
const registry = new OpenAPIRegistry([
@@ -17,6 +18,7 @@ function getOpenApiDocumentation() {
listsRegistry,
tagsRegistry,
highlightsRegistry,
+ userRegistry,
]);
const generator = new OpenApiGeneratorV3(registry.definitions);
diff --git a/packages/open-api/lib/users.ts b/packages/open-api/lib/users.ts
new file mode 100644
index 00000000..657fcdc8
--- /dev/null
+++ b/packages/open-api/lib/users.ts
@@ -0,0 +1,55 @@
+import {
+ extendZodWithOpenApi,
+ OpenAPIRegistry,
+} from "@asteasolutions/zod-to-openapi";
+import { z } from "zod";
+
+import {
+ zUserStatsResponseSchema,
+ zWhoAmIResponseSchema,
+} from "@hoarder/shared/types/users";
+
+import { BearerAuth } from "./common";
+
+export const registry = new OpenAPIRegistry();
+extendZodWithOpenApi(z);
+
+registry.registerPath({
+ method: "get",
+ path: "/users/me",
+ description: "Returns info about the current user",
+ summary: "Get current user info",
+ tags: ["Users"],
+ security: [{ [BearerAuth.name]: [] }],
+ request: {},
+ responses: {
+ 200: {
+ description: "Object with user data.",
+ content: {
+ "application/json": {
+ schema: zWhoAmIResponseSchema,
+ },
+ },
+ },
+ },
+});
+
+registry.registerPath({
+ method: "get",
+ path: "/users/me/stats",
+ description: "Returns stats about the current user",
+ summary: "Get current user stats",
+ tags: ["Users"],
+ security: [{ [BearerAuth.name]: [] }],
+ request: {},
+ responses: {
+ 200: {
+ description: "Object with user stats.",
+ content: {
+ "application/json": {
+ schema: zUserStatsResponseSchema,
+ },
+ },
+ },
+ },
+});
diff --git a/packages/sdk/src/hoarder-api.d.ts b/packages/sdk/src/hoarder-api.d.ts
index bc785995..60892c05 100644
--- a/packages/sdk/src/hoarder-api.d.ts
+++ b/packages/sdk/src/hoarder-api.d.ts
@@ -1234,6 +1234,95 @@ export interface paths {
};
trace?: never;
};
+ "/users/me": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get current user info
+ * @description Returns info about the current user
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Object with user data. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ id: string;
+ name?: string | null;
+ email?: string | null;
+ };
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
+ "/users/me/stats": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ /**
+ * Get current user stats
+ * @description Returns stats about the current user
+ */
+ get: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ requestBody?: never;
+ responses: {
+ /** @description Object with user stats. */
+ 200: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": {
+ numBookmarks: number;
+ numFavorites: number;
+ numArchived: number;
+ numTags: number;
+ numLists: number;
+ numHighlights: number;
+ };
+ };
+ };
+ };
+ };
+ put?: never;
+ post?: never;
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
}
export type webhooks = Record<string, never>;
export interface components {
diff --git a/packages/shared/types/users.ts b/packages/shared/types/users.ts
index 7d97a6d9..86c5a9ee 100644
--- a/packages/shared/types/users.ts
+++ b/packages/shared/types/users.ts
@@ -24,3 +24,18 @@ export const zChangePasswordSchema = z
message: "Passwords don't match",
path: ["newPasswordConfirm"],
});
+
+export const zWhoAmIResponseSchema = z.object({
+ id: z.string(),
+ name: z.string().nullish(),
+ email: z.string().nullish(),
+});
+
+export const zUserStatsResponseSchema = z.object({
+ numBookmarks: z.number(),
+ numFavorites: z.number(),
+ numArchived: z.number(),
+ numTags: z.number(),
+ numLists: z.number(),
+ numHighlights: z.number(),
+});
diff --git a/packages/trpc/routers/users.ts b/packages/trpc/routers/users.ts
index ca46d9f7..a78ec9b4 100644
--- a/packages/trpc/routers/users.ts
+++ b/packages/trpc/routers/users.ts
@@ -4,10 +4,20 @@ import invariant from "tiny-invariant";
import { z } from "zod";
import { SqliteError } from "@hoarder/db";
-import { users } from "@hoarder/db/schema";
+import {
+ bookmarkLists,
+ bookmarks,
+ bookmarkTags,
+ highlights,
+ users,
+} from "@hoarder/db/schema";
import { deleteUserAssets } from "@hoarder/shared/assetdb";
import serverConfig from "@hoarder/shared/config";
-import { zSignUpSchema } from "@hoarder/shared/types/users";
+import {
+ zSignUpSchema,
+ zUserStatsResponseSchema,
+ zWhoAmIResponseSchema,
+} from "@hoarder/shared/types/users";
import { hashPassword, validatePassword } from "../auth";
import {
@@ -160,13 +170,7 @@ export const usersAppRouter = router({
await deleteUserAssets({ userId: input.userId });
}),
whoami: authedProcedure
- .output(
- z.object({
- id: z.string(),
- name: z.string().nullish(),
- email: z.string().nullish(),
- }),
- )
+ .output(zWhoAmIResponseSchema)
.query(async ({ ctx }) => {
if (!ctx.user.email) {
throw new TRPCError({ code: "UNAUTHORIZED" });
@@ -179,4 +183,59 @@ export const usersAppRouter = router({
}
return { id: ctx.user.id, name: ctx.user.name, email: ctx.user.email };
}),
+ stats: authedProcedure
+ .output(zUserStatsResponseSchema)
+ .query(async ({ ctx }) => {
+ const [
+ [{ numBookmarks }],
+ [{ numFavorites }],
+ [{ numArchived }],
+ [{ numTags }],
+ [{ numLists }],
+ [{ numHighlights }],
+ ] = await Promise.all([
+ ctx.db
+ .select({ numBookmarks: count() })
+ .from(bookmarks)
+ .where(eq(bookmarks.userId, ctx.user.id)),
+ ctx.db
+ .select({ numFavorites: count() })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, ctx.user.id),
+ eq(bookmarks.favourited, true),
+ ),
+ ),
+ ctx.db
+ .select({ numArchived: count() })
+ .from(bookmarks)
+ .where(
+ and(
+ eq(bookmarks.userId, ctx.user.id),
+ eq(bookmarks.archived, true),
+ ),
+ ),
+ ctx.db
+ .select({ numTags: count() })
+ .from(bookmarkTags)
+ .where(eq(bookmarkTags.userId, ctx.user.id)),
+ ctx.db
+ .select({ numLists: count() })
+ .from(bookmarkLists)
+ .where(eq(bookmarkLists.userId, ctx.user.id)),
+ ctx.db
+ .select({ numHighlights: count() })
+ .from(highlights)
+ .where(eq(highlights.userId, ctx.user.id)),
+ ]);
+ return {
+ numBookmarks,
+ numFavorites,
+ numArchived,
+ numTags,
+ numLists,
+ numHighlights,
+ };
+ }),
});