diff options
| author | Mohamed Bassem <me@mbassem.com> | 2025-03-11 20:09:32 +0000 |
|---|---|---|
| committer | Mohamed Bassem <me@mbassem.com> | 2025-03-11 20:09:32 +0000 |
| commit | b8c587e3c3e717263da84522d59c7904715ae22a (patch) | |
| tree | a26a67162118b4a629d33a833dd25be67d344855 | |
| parent | 59c444a503c0124988608c190342acc53c797107 (diff) | |
| download | karakeep-b8c587e3c3e717263da84522d59c7904715ae22a.tar.zst | |
feat: Add endpoints for whoami and user stats. Fixes #1113
| -rw-r--r-- | apps/web/app/api/v1/users/me/route.ts | 14 | ||||
| -rw-r--r-- | apps/web/app/api/v1/users/me/stats/route.ts | 14 | ||||
| -rw-r--r-- | packages/e2e_tests/tests/api/users.test.ts | 102 | ||||
| -rw-r--r-- | packages/open-api/hoarder-openapi-spec.json | 96 | ||||
| -rw-r--r-- | packages/open-api/index.ts | 2 | ||||
| -rw-r--r-- | packages/open-api/lib/users.ts | 55 | ||||
| -rw-r--r-- | packages/sdk/src/hoarder-api.d.ts | 89 | ||||
| -rw-r--r-- | packages/shared/types/users.ts | 15 | ||||
| -rw-r--r-- | packages/trpc/routers/users.ts | 77 |
9 files changed, 455 insertions, 9 deletions
diff --git a/apps/web/app/api/v1/users/me/route.ts b/apps/web/app/api/v1/users/me/route.ts new file mode 100644 index 00000000..bf0a3ba2 --- /dev/null +++ b/apps/web/app/api/v1/users/me/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { buildHandler } from "../../utils/handler"; + +export const dynamic = "force-dynamic"; + +export const GET = (req: NextRequest) => + buildHandler({ + req, + handler: async ({ api }) => { + const user = await api.users.whoami(); + return { status: 200, resp: user }; + }, + }); diff --git a/apps/web/app/api/v1/users/me/stats/route.ts b/apps/web/app/api/v1/users/me/stats/route.ts new file mode 100644 index 00000000..359c3156 --- /dev/null +++ b/apps/web/app/api/v1/users/me/stats/route.ts @@ -0,0 +1,14 @@ +import { NextRequest } from "next/server"; + +import { buildHandler } from "../../../utils/handler"; + +export const dynamic = "force-dynamic"; + +export const GET = (req: NextRequest) => + buildHandler({ + req, + handler: async ({ api }) => { + const stats = await api.users.stats(); + return { status: 200, resp: stats }; + }, + }); 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, + }; + }), }); |
