From 791dbb78a35fc2c2a543d43fdf9b45b9686d1291 Mon Sep 17 00:00:00 2001 From: Daniel Tostes Date: Tue, 22 Apr 2025 19:42:28 -0300 Subject: [PATCH 1/6] chore(deps): update dependencies for @tanstack/react-query and add @trpc packages - Bumps @tanstack/react-query from 5.66.9 to 5.74.4. - Adds @trpc/client, @trpc/next, @trpc/react-query, and @trpc/server at version 11.1.0. - Introduces client-only package --- bun.lock | 19 ++++++++++++++++--- package.json | 7 ++++++- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/bun.lock b/bun.lock index b5ad4ccf..99e7fcb0 100644 --- a/bun.lock +++ b/bun.lock @@ -27,11 +27,16 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.3", - "@tanstack/react-query": "^5.66.9", + "@tanstack/react-query": "^5.74.4", + "@trpc/client": "^11.1.0", + "@trpc/next": "^11.1.0", + "@trpc/react-query": "^11.1.0", + "@trpc/server": "^11.1.0", "@types/mdx": "^2.0.13", "@upstash/ratelimit": "^2.0.1", "@upstash/redis": "^1.34.8", "class-variance-authority": "^0.7.1", + "client-only": "^0.0.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.0.0", @@ -767,14 +772,22 @@ "@tanstack/eslint-plugin-query": ["@tanstack/eslint-plugin-query@5.73.3", "", { "dependencies": { "@typescript-eslint/utils": "^8.18.1" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0" } }, "sha512-GmUtnOkRzDuNOq96g3eW5ADKC1nWfrM9RI0kRyQVr87rOl6y+PUgkuVaPxh3R2C0EVODxCS07b9aaWphidl/OA=="], - "@tanstack/query-core": ["@tanstack/query-core@5.66.4", "", {}, "sha512-skM/gzNX4shPkqmdTCSoHtJAPMTtmIJNS0hE+xwTTUVYwezArCT34NMermABmBVUg5Ls5aiUXEDXfqwR1oVkcA=="], + "@tanstack/query-core": ["@tanstack/query-core@5.74.4", "", {}, "sha512-YuG0A0+3i9b2Gfo9fkmNnkUWh5+5cFhWBN0pJAHkHilTx6A0nv8kepkk4T4GRt4e5ahbtFj2eTtkiPcVU1xO4A=="], "@tanstack/query-devtools": ["@tanstack/query-devtools@5.73.3", "", {}, "sha512-hBQyYwsOuO7QOprK75NzfrWs/EQYjgFA0yykmcvsV62q0t6Ua97CU3sYgjHx0ZvxkXSOMkY24VRJ5uv9f5Ik4w=="], - "@tanstack/react-query": ["@tanstack/react-query@5.66.9", "", { "dependencies": { "@tanstack/query-core": "5.66.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-NRI02PHJsP5y2gAuWKP+awamTIBFBSKMnO6UVzi03GTclmHHHInH5UzVgzi5tpu4+FmGfsdT7Umqegobtsp23A=="], + "@tanstack/react-query": ["@tanstack/react-query@5.74.4", "", { "dependencies": { "@tanstack/query-core": "5.74.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-mAbxw60d4ffQ4qmRYfkO1xzRBPUEf/72Dgo3qqea0J66nIKuDTLEqQt0ku++SDFlMGMnB6uKDnEG1xD/TDse4Q=="], "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.74.4", "", { "dependencies": { "@tanstack/query-devtools": "5.73.3" }, "peerDependencies": { "@tanstack/react-query": "^5.74.4", "react": "^18 || ^19" } }, "sha512-PGCAcytQMmeagoeGG45ccBhrC1x0/5OlNjsM1FAb9OfsQZIhPzjwjhGcwmMu6TbT4RIHgvjxLwC5NHgkUwJQzw=="], + "@trpc/client": ["@trpc/client@11.1.0", "", { "peerDependencies": { "@trpc/server": "11.1.0", "typescript": ">=5.7.2" } }, "sha512-Q3pL4p7AddxI/ZJTEFo1utKSdasDFjZPECIPsKDkthEt52k530JkYVltTdLkYFKrNWXKKBo8MN7NwchelczoRw=="], + + "@trpc/next": ["@trpc/next@11.1.0", "", { "peerDependencies": { "@tanstack/react-query": "^5.59.15", "@trpc/client": "11.1.0", "@trpc/react-query": "11.1.0", "@trpc/server": "11.1.0", "next": "*", "react": ">=16.8.0", "react-dom": ">=16.8.0", "typescript": ">=5.7.2" }, "optionalPeers": ["@tanstack/react-query", "@trpc/react-query"] }, "sha512-P8/qpfvRs7IIDdFBrcyMfxXumgf5p7K+dig6NpxpNYs4bqVJfBnAbATYEplmLhw/Dcksqo5ZoI0+0A19wLm8Ug=="], + + "@trpc/react-query": ["@trpc/react-query@11.1.0", "", { "peerDependencies": { "@tanstack/react-query": "^5.67.1", "@trpc/client": "11.1.0", "@trpc/server": "11.1.0", "react": ">=18.2.0", "react-dom": ">=18.2.0", "typescript": ">=5.7.2" } }, "sha512-qdqKdFM8hVy/YSBCg1/3VO+IgB6Nbul3Fk1SA3lefGf0bkYZdWVVyKab8HBAfOWlMsuRufhVLPdKYmnjzBrK9g=="], + + "@trpc/server": ["@trpc/server@11.1.0", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-uAJ7ikejeujVkf53XFJ/0W8nr7bDjul+Szk5Rsepq97Hb/WS1RkRXdyX4KqAyCE9b1vDFCJVJwSxiIZdRtbTZQ=="], + "@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="], "@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="], diff --git a/package.json b/package.json index ff024cbb..36afc5f0 100644 --- a/package.json +++ b/package.json @@ -56,11 +56,16 @@ "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-tooltip": "^1.2.3", - "@tanstack/react-query": "^5.66.9", + "@tanstack/react-query": "^5.74.4", + "@trpc/client": "^11.1.0", + "@trpc/next": "^11.1.0", + "@trpc/react-query": "^11.1.0", + "@trpc/server": "^11.1.0", "@types/mdx": "^2.0.13", "@upstash/ratelimit": "^2.0.1", "@upstash/redis": "^1.34.8", "class-variance-authority": "^0.7.1", + "client-only": "^0.0.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "date-fns": "^4.0.0", From ba23106c7760952e404f98c0bc4ffe763fa1fb07 Mon Sep 17 00:00:00 2001 From: Daniel Tostes Date: Tue, 22 Apr 2025 20:12:16 -0300 Subject: [PATCH 2/6] chore(deps): add superjson dependency --- bun.lock | 7 +++++++ package.json | 1 + 2 files changed, 8 insertions(+) diff --git a/bun.lock b/bun.lock index 99e7fcb0..42c6382a 100644 --- a/bun.lock +++ b/bun.lock @@ -56,6 +56,7 @@ "react-intersection-observer": "^9.16.0", "server-only": "^0.0.1", "sonner": "^2.0.3", + "superjson": "^2.2.2", "svix": "^1.64.1", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", @@ -990,6 +991,8 @@ "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + "copy-anything": ["copy-anything@3.0.5", "", { "dependencies": { "is-what": "^4.1.8" } }, "sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w=="], + "cosmiconfig": ["cosmiconfig@7.1.0", "", { "dependencies": { "@types/parse-json": "^4.0.0", "import-fresh": "^3.2.1", "parse-json": "^5.0.0", "path-type": "^4.0.0", "yaml": "^1.10.0" } }, "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1358,6 +1361,8 @@ "is-weakset": ["is-weakset@2.0.4", "", { "dependencies": { "call-bound": "^1.0.3", "get-intrinsic": "^1.2.6" } }, "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ=="], + "is-what": ["is-what@4.1.16", "", {}, "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A=="], + "isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], "isexe": ["isexe@3.1.1", "", {}, "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ=="], @@ -1860,6 +1865,8 @@ "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], + "superjson": ["superjson@2.2.2", "", { "dependencies": { "copy-anything": "^3.0.2" } }, "sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q=="], + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], diff --git a/package.json b/package.json index 36afc5f0..60265316 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "react-intersection-observer": "^9.16.0", "server-only": "^0.0.1", "sonner": "^2.0.3", + "superjson": "^2.2.2", "svix": "^1.64.1", "tailwind-merge": "^3.2.0", "tailwindcss-animate": "^1.0.7", From 49480d799fe9f7754c39310d7a8af8430543151a Mon Sep 17 00:00:00 2001 From: Daniel Tostes Date: Tue, 22 Apr 2025 20:18:43 -0300 Subject: [PATCH 3/6] chore(deps): add @trpc/tanstack-react-query dependency - Forgot this one. Oops! --- bun.lock | 3 +++ package.json | 1 + 2 files changed, 4 insertions(+) diff --git a/bun.lock b/bun.lock index 42c6382a..214f8554 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "@trpc/next": "^11.1.0", "@trpc/react-query": "^11.1.0", "@trpc/server": "^11.1.0", + "@trpc/tanstack-react-query": "^11.1.0", "@types/mdx": "^2.0.13", "@upstash/ratelimit": "^2.0.1", "@upstash/redis": "^1.34.8", @@ -789,6 +790,8 @@ "@trpc/server": ["@trpc/server@11.1.0", "", { "peerDependencies": { "typescript": ">=5.7.2" } }, "sha512-uAJ7ikejeujVkf53XFJ/0W8nr7bDjul+Szk5Rsepq97Hb/WS1RkRXdyX4KqAyCE9b1vDFCJVJwSxiIZdRtbTZQ=="], + "@trpc/tanstack-react-query": ["@trpc/tanstack-react-query@11.1.0", "", { "peerDependencies": { "@tanstack/react-query": "^5.67.1", "@trpc/client": "11.1.0", "@trpc/server": "11.1.0", "react": ">=18.2.0", "react-dom": ">=18.2.0", "typescript": ">=5.7.2" } }, "sha512-BRK5QWKAVHqvDyqjnGYp+53sgb5xE3MB1+fxrBKylVR1lFEeFQvUHwwY3Dtr/Pcq0mmxALytCsBVEGTiQCHnEw=="], + "@tsconfig/node18": ["@tsconfig/node18@1.0.3", "", {}, "sha512-RbwvSJQsuN9TB04AQbGULYfOGE/RnSFk/FLQ5b0NmDf5Kx2q/lABZbHQPKCO1vZ6Fiwkplu+yb9pGdLy1iGseQ=="], "@types/acorn": ["@types/acorn@4.0.6", "", { "dependencies": { "@types/estree": "*" } }, "sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ=="], diff --git a/package.json b/package.json index 60265316..295b7384 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "@trpc/next": "^11.1.0", "@trpc/react-query": "^11.1.0", "@trpc/server": "^11.1.0", + "@trpc/tanstack-react-query": "^11.1.0", "@types/mdx": "^2.0.13", "@upstash/ratelimit": "^2.0.1", "@upstash/redis": "^1.34.8", From a110c94314adeac76121692e1ac32153f120d1c7 Mon Sep 17 00:00:00 2001 From: Daniel Tostes Date: Thu, 24 Apr 2025 15:43:23 -0300 Subject: [PATCH 4/6] feat(api): implement tRPC boilerplate - Add an initial bookmarks router to confirm it works. --- src/app/api/trpc/[trpc]/route.ts | 28 ++++++++ src/server/api/root.ts | 10 +++ src/server/api/routers/bookmarks.ts | 66 +++++++++++++++++++ src/server/api/trpc.ts | 99 +++++++++++++++++++++++++++++ src/trpc/client.tsx | 86 +++++++++++++++++++++++++ src/trpc/query-client.ts | 24 +++++++ src/trpc/server.ts | 26 ++++++++ 7 files changed, 339 insertions(+) create mode 100644 src/app/api/trpc/[trpc]/route.ts create mode 100644 src/server/api/root.ts create mode 100644 src/server/api/routers/bookmarks.ts create mode 100644 src/server/api/trpc.ts create mode 100644 src/trpc/client.tsx create mode 100644 src/trpc/query-client.ts create mode 100644 src/trpc/server.ts diff --git a/src/app/api/trpc/[trpc]/route.ts b/src/app/api/trpc/[trpc]/route.ts new file mode 100644 index 00000000..d47f0799 --- /dev/null +++ b/src/app/api/trpc/[trpc]/route.ts @@ -0,0 +1,28 @@ +import { type NextRequest } from "next/server"; +import { appRouter } from "@/server/api/root"; +import { createTRPCContext } from "@/server/api/trpc"; +import { fetchRequestHandler } from "@trpc/server/adapters/fetch"; + +const createContext = async (req: NextRequest) => { + return createTRPCContext({ + headers: req.headers, + }); +}; + +const handler = (req: NextRequest) => + fetchRequestHandler({ + endpoint: "/api/trpc", + req, + router: appRouter, + createContext: () => createContext(req), + onError: + process.env.NODE_ENV === "development" + ? ({ path, error }) => { + console.error( + `❌ tRPC failed on ${path ?? ""}: ${error.message}`, + ); + } + : undefined, + }); + +export { handler as GET, handler as POST }; diff --git a/src/server/api/root.ts b/src/server/api/root.ts new file mode 100644 index 00000000..0fe156a7 --- /dev/null +++ b/src/server/api/root.ts @@ -0,0 +1,10 @@ +import { bookmarksRouter } from "@/server/api/routers/bookmarks"; +import { createCallerFactory, createTRPCRouter } from "@/server/api/trpc"; + +export const appRouter = createTRPCRouter({ + bookmarks: bookmarksRouter, +}); + +export type AppRouter = typeof appRouter; + +export const createCaller = createCallerFactory(appRouter); diff --git a/src/server/api/routers/bookmarks.ts b/src/server/api/routers/bookmarks.ts new file mode 100644 index 00000000..d30a35b6 --- /dev/null +++ b/src/server/api/routers/bookmarks.ts @@ -0,0 +1,66 @@ +import { authenticatedProcedure, createTRPCRouter } from "@/server/api/trpc"; +import { and, eq } from "drizzle-orm"; +import { z } from "zod"; + +import { bookmarks } from "@/db/schema"; + +export const bookmarksRouter = createTRPCRouter({ + bookmark: authenticatedProcedure + .input( + z.object({ + filterId: z.number(), + }), + ) + .mutation(async ({ ctx, input }) => { + const existingBookmark = await ctx.db.query.bookmarks.findFirst({ + where: and( + eq(bookmarks.filterId, input.filterId), + eq(bookmarks.authorId, ctx.userId), + ), + }); + if (existingBookmark) { + await ctx.db + .delete(bookmarks) + .where( + and( + eq(bookmarks.filterId, existingBookmark.filterId), + eq(bookmarks.authorId, ctx.userId), + ), + ); + return { bookmarked: false }; + } else { + await ctx.db.insert(bookmarks).values({ + filterId: input.filterId, + authorId: ctx.userId, + }); + return { bookmarked: true }; + } + }), + + getBookmarkedStatus: authenticatedProcedure + .input( + z.object({ + filterId: z.number(), + }), + ) + .query(async ({ ctx, input }) => { + const bookmarked = await ctx.db.query.bookmarks.findFirst({ + where: and( + eq(bookmarks.filterId, input.filterId), + eq(bookmarks.authorId, ctx.userId), + ), + }); + return { bookmarked: bookmarked ? true : false }; + }), + + getBookmarkedFilters: authenticatedProcedure.query(async ({ ctx }) => { + return await ctx.db.query.bookmarks.findMany({ + where: eq(bookmarks.authorId, ctx.userId), + with: { + filter: { + with: { filterItems: { with: { item: true, category: true } } }, + }, + }, + }); + }), +}); diff --git a/src/server/api/trpc.ts b/src/server/api/trpc.ts new file mode 100644 index 00000000..15b5fd86 --- /dev/null +++ b/src/server/api/trpc.ts @@ -0,0 +1,99 @@ +import { db } from "@/db"; +import { auth } from "@clerk/nextjs/server"; +import { initTRPC, TRPCError } from "@trpc/server"; +import SuperJSON from "superjson"; +import { z, ZodError } from "zod"; + +import { checkUserOwnsFilter } from "@/lib/filters"; + +export const createTRPCContext = async (opts: { headers: Headers }) => { + return { + auth: await auth(), + db, + ...opts, + }; +}; + +type Context = Awaited>; + +const t = initTRPC.context().create({ + transformer: SuperJSON, + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, +}); + +export const createCallerFactory = t.createCallerFactory; +export const createTRPCRouter = t.router; + +/** + * Middleware for timing procedure execution and adding an artificial delay in development. + * + * It can help catch unwanted waterfalls by simulating + * network latency that would occur in production but not in local development. + */ +const timingMiddleware = t.middleware(async ({ next, path }) => { + const start = Date.now(); + + if (t._config.isDev) { + const waitMs = Math.floor(Math.random() * 400) + 100; + await new Promise((resolve) => setTimeout(resolve, waitMs)); + } + + const result = await next(); + + const end = Date.now(); + console.log(`[TRPC] ${path} took ${end - start}ms to execute`); + + return result; +}); + +const authMiddleware = t.middleware(({ next, ctx }) => { + const { userId } = ctx.auth; + + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Unauthorized", + }); + } + + return next({ + ctx: { + ...ctx, + userId, + }, + }); +}); + +export const publicProcedure = t.procedure.use(timingMiddleware); +export const authenticatedProcedure = t.procedure.use(authMiddleware); + +export const ownsFilterProcedure = (filterId: z.ZodNumber) => { + return authenticatedProcedure + .input(z.object({ filterId })) + .use(async ({ next, ctx, input }) => { + const ownsFilter = await checkUserOwnsFilter(input.filterId, ctx.userId); + + if (!ownsFilter) { + throw new TRPCError({ + code: "FORBIDDEN", + message: "You don't have access to this filter", + }); + } + + return next({ + ctx: { + ...ctx, + filter: ownsFilter, + }, + }); + }); +}; diff --git a/src/trpc/client.tsx b/src/trpc/client.tsx new file mode 100644 index 00000000..9adbddc7 --- /dev/null +++ b/src/trpc/client.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { useState } from "react"; +import type { AppRouter } from "@/server/api/root"; +import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; +import { createTRPCClient, httpBatchLink, loggerLink } from "@trpc/client"; +import { createTRPCReact } from "@trpc/react-query"; +import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server"; +import SuperJSON from "superjson"; + +import { makeQueryClient } from "./query-client"; + +let browserQueryClientSingleton: QueryClient; +function getQueryClient() { + if (typeof window === "undefined") { + // Server: always make a new query client + return makeQueryClient(); + } + // Browser: use singleton pattern to keep the same query client + browserQueryClientSingleton ??= makeQueryClient(); + + return browserQueryClientSingleton; +} + +export const api = createTRPCReact(); + +/** + * Inference helper for inputs. + * + * @example type HelloInput = RouterInputs['example']['hello'] + */ +export type RouterInputs = inferRouterInputs; + +/** + * Inference helper for outputs. + * + * @example type HelloOutput = RouterOutputs['example']['hello'] + */ +export type RouterOutputs = inferRouterOutputs; + +export function TRPCReactProvider( + props: Readonly<{ + children: React.ReactNode; + }>, +) { + const queryClient = getQueryClient(); + + const [trpcClient] = useState(() => + createTRPCClient({ + links: [ + loggerLink({ + enabled: (op) => + process.env.NODE_ENV === "development" || + (op.direction === "down" && op.result instanceof Error), + }), + httpBatchLink({ + transformer: SuperJSON, + url: getUrl(), + headers: () => { + const headers = new Headers(); + headers.set("x-trpc-source", "nextjs-react"); + return headers; + }, + }), + ], + }), + ); + + return ( + + + {props.children} + + + ); +} + +function getUrl() { + const base = (() => { + if (typeof window !== "undefined") return window.location.origin; + if (process.env.CLOUDFLARE_URL) + return `https://${process.env.CLOUDFLARE_URL}`; + return "http://localhost:3000"; + })(); + return `${base}/api/trpc`; +} diff --git a/src/trpc/query-client.ts b/src/trpc/query-client.ts new file mode 100644 index 00000000..598e8c72 --- /dev/null +++ b/src/trpc/query-client.ts @@ -0,0 +1,24 @@ +import { + defaultShouldDehydrateQuery, + QueryClient, +} from "@tanstack/react-query"; +import superjson from "superjson"; + +export function makeQueryClient() { + return new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30 * 1000, + }, + dehydrate: { + serializeData: superjson.serialize, + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === "pending", + }, + hydrate: { + deserializeData: superjson.deserialize, + }, + }, + }); +} diff --git a/src/trpc/server.ts b/src/trpc/server.ts new file mode 100644 index 00000000..afc22d78 --- /dev/null +++ b/src/trpc/server.ts @@ -0,0 +1,26 @@ +import "server-only"; + +import { cache } from "react"; +import { headers } from "next/headers"; +import { createCaller, type AppRouter } from "@/server/api/root"; +import { createTRPCContext } from "@/server/api/trpc"; +import { createHydrationHelpers } from "@trpc/react-query/rsc"; + +import { makeQueryClient } from "./query-client"; + +const createContext = cache(async () => { + const heads = new Headers(await headers()); + heads.set("x-trpc-source", "rsc"); + + return createTRPCContext({ + headers: heads, + }); +}); + +export const getQueryClient = cache(makeQueryClient); +const caller = createCaller(createContext); + +export const { trpc: api, HydrateClient } = createHydrationHelpers( + caller, + getQueryClient, +); From c3cade28d0d577d54f318f8e1303a6d3c43acf13 Mon Sep 17 00:00:00 2001 From: Daniel Tostes Date: Thu, 24 Apr 2025 15:44:36 -0300 Subject: [PATCH 5/6] refactor(bookmarks): migrate bookmark actions to tRPC and remove legacy code - Deleted the `bookmark-filter.ts` actions in favor of tRPC implementations. - Updated `MyFiltersPage` to use the new tRPC client for fetching bookmarked filters. - Refactored `SavedFilters` and `BookmarkToggle` components to utilize tRPC queries and mutations for bookmark management. - Removed the `use-get-bookmarked-filters` hook as it is no longer needed. --- src/actions/bookmark-filter.ts | 71 ------------------- src/app/(app)/my-filters/page.tsx | 18 ++--- .../filters/filter-card/bookmark-toggle.tsx | 32 ++++----- src/components/filters/saved-filters.tsx | 5 +- src/hooks/use-get-bookmarked-filters.tsx | 9 --- 5 files changed, 19 insertions(+), 116 deletions(-) delete mode 100644 src/actions/bookmark-filter.ts delete mode 100644 src/hooks/use-get-bookmarked-filters.tsx diff --git a/src/actions/bookmark-filter.ts b/src/actions/bookmark-filter.ts deleted file mode 100644 index c2fce862..00000000 --- a/src/actions/bookmark-filter.ts +++ /dev/null @@ -1,71 +0,0 @@ -"use server"; - -import { db } from "@/db"; -import { and, eq } from "drizzle-orm"; -import { z } from "zod"; - -import { authenticatedProcedure } from "@/lib/safe-action"; -import { bookmarks } from "@/db/schema"; - -export const bookmarkFilter = authenticatedProcedure - .createServerAction() - .input( - z.object({ - filterId: z.number(), - }), - ) - .handler(async ({ ctx, input }) => { - const existingBookmark = await db.query.bookmarks.findFirst({ - where: and( - eq(bookmarks.filterId, input.filterId), - eq(bookmarks.authorId, ctx.userId), - ), - }); - if (existingBookmark) { - await db - .delete(bookmarks) - .where( - and( - eq(bookmarks.filterId, existingBookmark.filterId), - eq(bookmarks.authorId, ctx.userId), - ), - ); - return { bookmarked: false }; - } else { - await db.insert(bookmarks).values({ - filterId: input.filterId, - authorId: ctx.userId, - }); - return { bookmarked: true }; - } - }); - -export const getBookmarkedStatus = authenticatedProcedure - .createServerAction() - .input( - z.object({ - filterId: z.number(), - }), - ) - .handler(async ({ ctx, input }) => { - const bookmarked = await db.query.bookmarks.findFirst({ - where: and( - eq(bookmarks.filterId, input.filterId), - eq(bookmarks.authorId, ctx.userId), - ), - }); - return { bookmarked: bookmarked ? true : false }; - }); - -export const getBookmarkedFilters = authenticatedProcedure - .createServerAction() - .handler(async ({ ctx }) => { - return await db.query.bookmarks.findMany({ - where: eq(bookmarks.authorId, ctx.userId), - with: { - filter: { - with: { filterItems: { with: { item: true, category: true } } }, - }, - }, - }); - }); diff --git a/src/app/(app)/my-filters/page.tsx b/src/app/(app)/my-filters/page.tsx index e5607843..839d5d0c 100644 --- a/src/app/(app)/my-filters/page.tsx +++ b/src/app/(app)/my-filters/page.tsx @@ -1,11 +1,7 @@ import type { Metadata } from "next"; -import { - dehydrate, - HydrationBoundary, - QueryClient, -} from "@tanstack/react-query"; +import { api, getQueryClient } from "@/trpc/server"; +import { dehydrate, HydrationBoundary } from "@tanstack/react-query"; -import { getBookmarkedFilters } from "@/actions/bookmark-filter"; import { getUserCategories, getUserCategoryHierarchy, @@ -26,16 +22,10 @@ export const metadata: Metadata = { }; export default async function MyFiltersPage() { - const queryClient = new QueryClient(); + const queryClient = getQueryClient(); + void api.bookmarks.getBookmarkedFilters.prefetch(); await Promise.all([ - queryClient.prefetchQuery({ - queryKey: ["bookmarked-filters"], - queryFn: async () => { - const [data] = await getBookmarkedFilters(); - return data; - }, - }), queryClient.prefetchQuery({ queryKey: ["user-filters-by-category", null], queryFn: async () => { diff --git a/src/components/filters/filter-card/bookmark-toggle.tsx b/src/components/filters/filter-card/bookmark-toggle.tsx index 8ce47861..4601af27 100644 --- a/src/components/filters/filter-card/bookmark-toggle.tsx +++ b/src/components/filters/filter-card/bookmark-toggle.tsx @@ -1,17 +1,12 @@ "use client"; import { useEffect, useState } from "react"; +import { api } from "@/trpc/client"; import { useUser } from "@clerk/nextjs"; import type { ToggleProps } from "@radix-ui/react-toggle"; -import { useQueryClient } from "@tanstack/react-query"; import { BookmarkIcon, Loader2Icon } from "lucide-react"; import { toast } from "sonner"; -import { bookmarkFilter, getBookmarkedStatus } from "@/actions/bookmark-filter"; -import { - useServerActionMutation, - useServerActionQuery, -} from "@/hooks/server-action-hooks"; import { Toggle } from "@/components/ui/toggle"; interface BookmarkToggleProps extends ToggleProps { @@ -26,34 +21,31 @@ export function BookmarkToggle({ }: BookmarkToggleProps) { const { isLoaded, isSignedIn } = useUser(); const [isBookmarked, setIsBookmarked] = useState(initialBookmarked); - const queryClient = useQueryClient(); - const { data: bookmarkedData, isLoading } = useServerActionQuery( - getBookmarkedStatus, - { - input: { filterId }, - queryKey: ["bookmarked", filterId], - enabled: isLoaded && isSignedIn && !initialBookmarked, - }, - ); + const utils = api.useUtils(); + const { data: bookmarkedData, isLoading } = + api.bookmarks.getBookmarkedStatus.useQuery( + { filterId }, + { enabled: isLoaded && isSignedIn && !initialBookmarked }, + ); - const mutation = useServerActionMutation(bookmarkFilter, { + const mutation = api.bookmarks.bookmark.useMutation({ onSuccess: (data) => { setIsBookmarked(data.bookmarked); toast.success( data.bookmarked ? "Filter bookmarked" : "Filter unbookmarked", ); }, - onError: (err) => { - if (err.code === "NOT_AUTHORIZED") { + onError: (error) => { + if (error.data?.code === "UNAUTHORIZED") { toast.error("You must be signed in to bookmark a filter"); } else { toast.error("An error occurred while bookmarking the filter"); } }, onSettled: () => { - queryClient.invalidateQueries({ queryKey: ["bookmarked", filterId] }); - queryClient.invalidateQueries({ queryKey: ["bookmarked-filters"] }); + utils.bookmarks.getBookmarkedStatus.invalidate(); + utils.bookmarks.getBookmarkedFilters.invalidate(); }, }); diff --git a/src/components/filters/saved-filters.tsx b/src/components/filters/saved-filters.tsx index 5add3277..cbfbd882 100644 --- a/src/components/filters/saved-filters.tsx +++ b/src/components/filters/saved-filters.tsx @@ -1,14 +1,15 @@ "use client"; +import { api } from "@/trpc/client"; import { BookmarkPlusIcon, GlobeIcon } from "lucide-react"; -import { useGetBookmarkedFilters } from "@/hooks/use-get-bookmarked-filters"; import { EmptyState } from "@/components/empty-state"; import { BookmarkedFilterCard } from "../my-filters/bookmarked-filter-card"; export function SavedFilters() { - const { data: bookmarkedFilters } = useGetBookmarkedFilters(); + const { data: bookmarkedFilters } = + api.bookmarks.getBookmarkedFilters.useQuery(); if (!bookmarkedFilters?.length) { return ( diff --git a/src/hooks/use-get-bookmarked-filters.tsx b/src/hooks/use-get-bookmarked-filters.tsx deleted file mode 100644 index c4db54aa..00000000 --- a/src/hooks/use-get-bookmarked-filters.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { getBookmarkedFilters } from "@/actions/bookmark-filter"; -import { useServerActionQuery } from "@/hooks/server-action-hooks"; - -export function useGetBookmarkedFilters() { - return useServerActionQuery(getBookmarkedFilters, { - input: undefined, - queryKey: ["bookmarked-filters"], - }); -} From 566fbdcaffa76c5292b26e5f914a113d349ab296 Mon Sep 17 00:00:00 2001 From: Daniel Tostes Date: Thu, 24 Apr 2025 15:50:26 -0300 Subject: [PATCH 6/6] refactor(layout): replace QueryProvider with TRPCReactProvider - Updated the RootLayout component to utilize TRPCReactProvider for improved data fetching and state management. - Removed the legacy QueryProvider to streamline the integration with tRPC. --- src/app/layout.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6b6eab86..a5e2757b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -7,10 +7,11 @@ import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { cn } from "@/lib/utils"; import { Toaster } from "@/components/ui/sonner"; import { ThemeProvider } from "@/components/theme-provider"; -import { QueryProvider } from "@/providers/QueryProvider"; import "./globals.css"; +import { TRPCReactProvider } from "@/trpc/client"; + import { siteConfig } from "@/config/site"; import { OutboundLinkTracker } from "@/components/analytics/outbound-link-tracker"; @@ -95,7 +96,7 @@ export default function RootLayout({ inter.variable, )} > - + - +