diff --git a/packages/db/drizzle/0003_parallel_supernaut.sql b/packages/db/drizzle/0003_parallel_supernaut.sql new file mode 100644 index 00000000..a703b1c3 --- /dev/null +++ b/packages/db/drizzle/0003_parallel_supernaut.sql @@ -0,0 +1 @@ +ALTER TABLE user ADD `role` text DEFAULT 'user'; \ No newline at end of file diff --git a/packages/db/drizzle/meta/0003_snapshot.json b/packages/db/drizzle/meta/0003_snapshot.json new file mode 100644 index 00000000..5b75ceb2 --- /dev/null +++ b/packages/db/drizzle/meta/0003_snapshot.json @@ -0,0 +1,792 @@ +{ + "version": "5", + "dialect": "sqlite", + "id": "28b3c23e-65eb-44b4-bf9c-8573a6ccde8a", + "prevId": "e0b3dd84-57d4-4adb-97da-98a6ff5d7d34", + "tables": { + "account": { + "name": "account", + "columns": { + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "providerAccountId": { + "name": "providerAccountId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_type": { + "name": "token_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "session_state": { + "name": "session_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "account_userId_user_id_fk": { + "name": "account_userId_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "account_provider_providerAccountId_pk": { + "columns": [ + "provider", + "providerAccountId" + ], + "name": "account_provider_providerAccountId_pk" + } + }, + "uniqueConstraints": {} + }, + "apiKey": { + "name": "apiKey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyId": { + "name": "keyId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "keyHash": { + "name": "keyHash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "apiKey_name_unique": { + "name": "apiKey_name_unique", + "columns": [ + "name" + ], + "isUnique": true + }, + "apiKey_keyId_unique": { + "name": "apiKey_keyId_unique", + "columns": [ + "keyId" + ], + "isUnique": true + }, + "apiKey_name_userId_unique": { + "name": "apiKey_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "apiKey_userId_user_id_fk": { + "name": "apiKey_userId_user_id_fk", + "tableFrom": "apiKey", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLinks": { + "name": "bookmarkLinks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "imageUrl": { + "name": "imageUrl", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "favicon": { + "name": "favicon", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "crawledAt": { + "name": "crawledAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkLinks_id_bookmarks_id_fk": { + "name": "bookmarkLinks_id_bookmarks_id_fk", + "tableFrom": "bookmarkLinks", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkLists": { + "name": "bookmarkLists", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "icon": { + "name": "icon", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkLists_name_userId_unique": { + "name": "bookmarkLists_name_userId_unique", + "columns": [ + "name", + "userId" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkLists_userId_user_id_fk": { + "name": "bookmarkLists_userId_user_id_fk", + "tableFrom": "bookmarkLists", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTags": { + "name": "bookmarkTags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "bookmarkTags_userId_name_unique": { + "name": "bookmarkTags_userId_name_unique", + "columns": [ + "userId", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "bookmarkTags_userId_user_id_fk": { + "name": "bookmarkTags_userId_user_id_fk", + "tableFrom": "bookmarkTags", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarkTexts": { + "name": "bookmarkTexts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarkTexts_id_bookmarks_id_fk": { + "name": "bookmarkTexts_id_bookmarks_id_fk", + "tableFrom": "bookmarkTexts", + "tableTo": "bookmarks", + "columnsFrom": [ + "id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarks": { + "name": "bookmarks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "createdAt": { + "name": "createdAt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived": { + "name": "archived", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "favourited": { + "name": "favourited", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarks_userId_user_id_fk": { + "name": "bookmarks_userId_user_id_fk", + "tableFrom": "bookmarks", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "bookmarksInLists": { + "name": "bookmarksInLists", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "listId": { + "name": "listId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "addedAt": { + "name": "addedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "bookmarksInLists_bookmarkId_bookmarks_id_fk": { + "name": "bookmarksInLists_bookmarkId_bookmarks_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bookmarksInLists_listId_bookmarkLists_id_fk": { + "name": "bookmarksInLists_listId_bookmarkLists_id_fk", + "tableFrom": "bookmarksInLists", + "tableTo": "bookmarkLists", + "columnsFrom": [ + "listId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "bookmarksInLists_bookmarkId_listId_pk": { + "columns": [ + "bookmarkId", + "listId" + ], + "name": "bookmarksInLists_bookmarkId_listId_pk" + } + }, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "sessionToken": { + "name": "sessionToken", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "userId": { + "name": "userId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "session_userId_user_id_fk": { + "name": "session_userId_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "userId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "tagsOnBookmarks": { + "name": "tagsOnBookmarks", + "columns": { + "bookmarkId": { + "name": "bookmarkId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tagId": { + "name": "tagId", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachedAt": { + "name": "attachedAt", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attachedBy": { + "name": "attachedBy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tagsOnBookmarks_bookmarkId_bookmarks_id_fk": { + "name": "tagsOnBookmarks_bookmarkId_bookmarks_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarks", + "columnsFrom": [ + "bookmarkId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tagsOnBookmarks_tagId_bookmarkTags_id_fk": { + "name": "tagsOnBookmarks_tagId_bookmarkTags_id_fk", + "tableFrom": "tagsOnBookmarks", + "tableTo": "bookmarkTags", + "columnsFrom": [ + "tagId" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tagsOnBookmarks_bookmarkId_tagId_pk": { + "columns": [ + "bookmarkId", + "tagId" + ], + "name": "tagsOnBookmarks_bookmarkId_tagId_pk" + } + }, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "emailVerified": { + "name": "emailVerified", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'user'" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "verificationToken": { + "name": "verificationToken", + "columns": { + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires": { + "name": "expires", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "verificationToken_identifier_token_pk": { + "columns": [ + "identifier", + "token" + ], + "name": "verificationToken_identifier_token_pk" + } + }, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 28c22844..a8b97da4 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1709293861959, "tag": "0002_worried_beyonder", "breakpoints": true + }, + { + "idx": 3, + "version": "5", + "when": 1709331420929, + "tag": "0003_parallel_supernaut", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 3c464d6c..d1e3fa0d 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -25,6 +25,7 @@ export const users = sqliteTable("user", { emailVerified: integer("emailVerified", { mode: "timestamp_ms" }), image: text("image"), password: text("password"), + role: text("role", { enum: ["admin", "user"] }).default("user"), }); export const accounts = sqliteTable( diff --git a/packages/web/app/dashboard/admin/page.tsx b/packages/web/app/dashboard/admin/page.tsx new file mode 100644 index 00000000..26b6447b --- /dev/null +++ b/packages/web/app/dashboard/admin/page.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { ActionButton } from "@/components/ui/action-button"; +import LoadingSpinner from "@/components/ui/spinner"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { toast } from "@/components/ui/use-toast"; +import { api } from "@/lib/trpc"; +import { keepPreviousData } from "@tanstack/react-query"; +import { useSession } from "next-auth/react"; +import { useRouter } from "next/navigation"; + +export default function AdminPage() { + const router = useRouter(); + const { data: session, status } = useSession(); + + if (status == "loading") { + return ; + } + + if (!session || session.user.role != "admin") { + router.push("/"); + return; + } + + const { data } = api.admin.stats.useQuery(undefined, { + refetchInterval: 1000, + placeholderData: keepPreviousData, + }); + + const { mutate: recrawlLinks, isPending: isRecrawlPending } = + api.admin.recrawlAllLinks.useMutation({ + onSuccess: () => { + toast({ + description: "Recrawl enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + const { mutate: reindexBookmarks, isPending: isReindexPending } = + api.admin.reindexAllBookmarks.useMutation({ + onSuccess: () => { + toast({ + description: "Reindex enqueued", + }); + }, + onError: (e) => { + toast({ + variant: "destructive", + description: e.message, + }); + }, + }); + + return ( +
+

Admin

+
+ {data ? ( + + + + Stats + Value + + + + + Num Users + {data.numUsers} + + + Num Bookmarks + {data.numBookmarks} + + + Pending Crawling Jobs + {data.pendingCrawls} + + + Pending Indexing Jobs + {data.pendingIndexing} + + + Pending OpenAI Jobs + {data.pendingOpenai} + + +
+ ) : ( + + )} +
+

Actions

+ recrawlLinks()} + > + Recrawl All Links + + reindexBookmarks()} + > + Reindex All Bookmarks + +
+ ); +} diff --git a/packages/web/app/dashboard/components/Sidebar.tsx b/packages/web/app/dashboard/components/Sidebar.tsx index 010ee103..29e5baed 100644 --- a/packages/web/app/dashboard/components/Sidebar.tsx +++ b/packages/web/app/dashboard/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { PackageOpen, Settings, Search, + Shield, } from "lucide-react"; import { redirect } from "next/navigation"; import SidebarItem from "./SidebarItem"; @@ -61,6 +62,13 @@ export default async function Sidebar() { name="Settings" path="/dashboard/settings" /> + {session.user.role == "admin" && ( + } + name="Admin" + path="/dashboard/admin" + /> + )} diff --git a/packages/web/app/layout.tsx b/packages/web/app/layout.tsx index d597063b..a8423031 100644 --- a/packages/web/app/layout.tsx +++ b/packages/web/app/layout.tsx @@ -5,6 +5,7 @@ import React from "react"; import { Toaster } from "@/components/ui/toaster"; import Providers from "@/lib/providers"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; +import { getServerAuthSession } from "@/server/auth"; const inter = Inter({ subsets: ["latin"] }); @@ -13,15 +14,16 @@ export const metadata: Metadata = { description: "Your AI powered second brain", }; -export default function RootLayout({ +export default async function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const session = await getServerAuthSession(); return ( - + {children} diff --git a/packages/web/lib/providers.tsx b/packages/web/lib/providers.tsx index d14d4d96..0c721c1e 100644 --- a/packages/web/lib/providers.tsx +++ b/packages/web/lib/providers.tsx @@ -6,8 +6,16 @@ import { api } from "./trpc"; import { loggerLink } from "@trpc/client"; import { httpBatchLink } from "@trpc/client"; import superjson from "superjson"; +import { SessionProvider } from "next-auth/react"; +import { Session } from "next-auth"; -export default function Providers({ children }: { children: React.ReactNode }) { +export default function Providers({ + children, + session, +}: { + children: React.ReactNode; + session: Session | null; +}) { const [queryClient] = React.useState(() => new QueryClient()); const [trpcClient] = useState(() => @@ -28,8 +36,12 @@ export default function Providers({ children }: { children: React.ReactNode }) { ); return ( - - {children} - + + + + {children} + + + ); } diff --git a/packages/web/lib/testUtils.ts b/packages/web/lib/testUtils.ts index ca9a6474..142ad844 100644 --- a/packages/web/lib/testUtils.ts +++ b/packages/web/lib/testUtils.ts @@ -2,7 +2,6 @@ import { users } from "@hoarder/db/schema"; import { getInMemoryDB } from "@hoarder/db/drizzle"; import { appRouter } from "@/server/api/routers/_app"; import { createCallerFactory } from "@/server/api/trpc"; -import { beforeEach } from "vitest"; export function getTestDB() { return getInMemoryDB(true); @@ -31,6 +30,7 @@ export function getApiCaller(db: TestDB, userId: string) { return createCaller({ user: { id: userId, + role: "user", }, db, }); diff --git a/packages/web/server/api/routers/_app.ts b/packages/web/server/api/routers/_app.ts index 6a1b05e9..43ab6f5d 100644 --- a/packages/web/server/api/routers/_app.ts +++ b/packages/web/server/api/routers/_app.ts @@ -1,4 +1,5 @@ import { router } from "../trpc"; +import { adminAppRouter } from "./admin"; import { apiKeysAppRouter } from "./apiKeys"; import { bookmarksAppRouter } from "./bookmarks"; import { listsAppRouter } from "./lists"; @@ -8,6 +9,7 @@ export const appRouter = router({ apiKeys: apiKeysAppRouter, users: usersAppRouter, lists: listsAppRouter, + admin: adminAppRouter, }); // export type definition of API export type AppRouter = typeof appRouter; diff --git a/packages/web/server/api/routers/admin.ts b/packages/web/server/api/routers/admin.ts new file mode 100644 index 00000000..92769151 --- /dev/null +++ b/packages/web/server/api/routers/admin.ts @@ -0,0 +1,86 @@ +import { authedProcedure, router } from "../trpc"; +import { z } from "zod"; +import { TRPCError } from "@trpc/server"; +import { count } from "drizzle-orm"; +import { bookmarks, users } from "@hoarder/db/schema"; +import { + LinkCrawlerQueue, + OpenAIQueue, + SearchIndexingQueue, +} from "@hoarder/shared/queues"; + +const adminProcedure = authedProcedure.use(function isAdmin(opts) { + const user = opts.ctx.user; + if (user.role != "admin") { + throw new TRPCError({ code: "FORBIDDEN" }); + } + return opts.next(opts); +}); + +export const adminAppRouter = router({ + stats: adminProcedure + .output( + z.object({ + numUsers: z.number(), + numBookmarks: z.number(), + pendingCrawls: z.number(), + pendingIndexing: z.number(), + pendingOpenai: z.number(), + }), + ) + .query(async ({ ctx }) => { + const [ + [{ value: numUsers }], + [{ value: numBookmarks }], + pendingCrawls, + pendingIndexing, + pendingOpenai, + ] = await Promise.all([ + ctx.db.select({ value: count() }).from(users), + ctx.db.select({ value: count() }).from(bookmarks), + LinkCrawlerQueue.getWaitingCount(), + SearchIndexingQueue.getWaitingCount(), + OpenAIQueue.getWaitingCount(), + ]); + + return { + numUsers, + numBookmarks, + pendingCrawls, + pendingIndexing, + pendingOpenai, + }; + }), + recrawlAllLinks: adminProcedure.mutation(async ({ ctx }) => { + const bookmarkIds = await ctx.db.query.bookmarkLinks.findMany({ + columns: { + id: true, + }, + }); + + await Promise.all( + bookmarkIds.map((b) => + LinkCrawlerQueue.add("crawl", { + bookmarkId: b.id, + }), + ), + ); + }), + + reindexAllBookmarks: adminProcedure.mutation(async ({ ctx }) => { + const bookmarkIds = await ctx.db.query.bookmarks.findMany({ + columns: { + id: true, + }, + }); + + await Promise.all( + bookmarkIds.map((b) => + SearchIndexingQueue.add("search_indexing", { + bookmarkId: b.id, + type: "index", + }), + ), + ); + }), +}); diff --git a/packages/web/server/auth.ts b/packages/web/server/auth.ts index f2c78190..1810c87d 100644 --- a/packages/web/server/auth.ts +++ b/packages/web/server/auth.ts @@ -16,6 +16,7 @@ declare module "next-auth/jwt" { export interface JWT { user: { id: string; + role: "admin" | "user"; } & DefaultSession["user"]; } } @@ -27,8 +28,13 @@ declare module "next-auth" { export interface Session { user: { id: string; + role: "admin" | "user"; } & DefaultSession["user"]; } + + export interface DefaultUser { + role: "admin" | "user" | null; + } } const providers: Provider[] = [ @@ -75,6 +81,7 @@ export const authOptions: NextAuthOptions = { name: user.name, email: user.email, image: user.image, + role: user.role || "user", }; } return token;