Admin interface MVP
This commit is contained in:
parent
ea2a9da1e0
commit
50a89d4104
@ -1,27 +0,0 @@
|
|||||||
import {
|
|
||||||
printError,
|
|
||||||
printErrorMessageWithReason,
|
|
||||||
printObject,
|
|
||||||
} from "@/lib/output";
|
|
||||||
import { getAPIClient } from "@/lib/trpc";
|
|
||||||
import { Command } from "@commander-js/extra-typings";
|
|
||||||
|
|
||||||
export const helloWorldCmd = new Command()
|
|
||||||
.name("hello-world")
|
|
||||||
.description("returns info about the server and stats");
|
|
||||||
|
|
||||||
helloWorldCmd
|
|
||||||
.command("test")
|
|
||||||
.description("does something specific I guess")
|
|
||||||
.action(async () => {
|
|
||||||
const api = getAPIClient();
|
|
||||||
const whoami = await api.users.whoami.query();
|
|
||||||
try {
|
|
||||||
console.log("Hello " + whoami.name);
|
|
||||||
} catch (error) {
|
|
||||||
printErrorMessageWithReason(
|
|
||||||
"Something went horribly wrong",
|
|
||||||
error as object,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
21
apps/cli/src/commands/whoami.ts
Normal file
21
apps/cli/src/commands/whoami.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import {
|
||||||
|
printError,
|
||||||
|
printErrorMessageWithReason,
|
||||||
|
printObject,
|
||||||
|
} from "@/lib/output";
|
||||||
|
import { getAPIClient } from "@/lib/trpc";
|
||||||
|
import { Command } from "@commander-js/extra-typings";
|
||||||
|
|
||||||
|
export const whoamiCmd = new Command()
|
||||||
|
.name("whoami")
|
||||||
|
.description("returns info about the owner of this API key")
|
||||||
|
.action(async () => {
|
||||||
|
await getAPIClient()
|
||||||
|
.users.whoami.query()
|
||||||
|
.then(printObject)
|
||||||
|
.catch(
|
||||||
|
printError(
|
||||||
|
`Unable to fetch information about the owner of this API key`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { helloWorldCmd } from "@/commands/hello-world"
|
import { whoamiCmd } from "@/commands/whoami";
|
||||||
import { setGlobalOptions } from "@/lib/globals";
|
import { setGlobalOptions } from "@/lib/globals";
|
||||||
import { Command, Option } from "@commander-js/extra-typings";
|
import { Command, Option } from "@commander-js/extra-typings";
|
||||||
|
|
||||||
@ -25,7 +25,10 @@ const program = new Command()
|
|||||||
: "0.0.0",
|
: "0.0.0",
|
||||||
);
|
);
|
||||||
|
|
||||||
program.addCommand(helloWorldCmd);
|
program.addCommand(whoamiCmd, {
|
||||||
|
isDefault: true
|
||||||
|
});
|
||||||
|
|
||||||
setGlobalOptions(program.opts());
|
setGlobalOptions(program.opts());
|
||||||
|
|
||||||
program.parse();
|
program.parse();
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
export interface GlobalOptions {
|
export interface GlobalOptions {
|
||||||
apiKey: string;
|
apiKey: string;
|
||||||
serverAddr: string;
|
serverAddr: string;
|
||||||
json?: true;
|
json?: false;
|
||||||
}
|
}
|
||||||
|
|
||||||
export let globalOpts: GlobalOptions | undefined = undefined;
|
export let globalOpts: GlobalOptions | undefined = undefined;
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export default function LifetrackerLogo({
|
|||||||
return (
|
return (
|
||||||
<span style={{ gap }} className="flex items-center">
|
<span style={{ gap }} className="flex items-center">
|
||||||
<span
|
<span
|
||||||
style={{ fontSize: "50px" }}
|
style={{ fontSize: "35px" }}
|
||||||
className={`fill-foreground`}
|
className={`fill-foreground`}
|
||||||
>Lifetracker</span>
|
>Lifetracker</span>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@ -33,7 +33,7 @@ import { TRPCClientError } from "@trpc/client";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { zAdminCreateUserSchema } from "@hoarder/shared/types/admin";
|
import { zAdminCreateUserSchema } from "@lifetracker/shared/types/admin";
|
||||||
|
|
||||||
type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>;
|
type AdminCreateUserSchema = z.infer<typeof zAdminCreateUserSchema>;
|
||||||
|
|
||||||
|
|||||||
@ -5,67 +5,6 @@ import { toast } from "@/components/ui/use-toast";
|
|||||||
import { api } from "@/lib/trpc";
|
import { api } from "@/lib/trpc";
|
||||||
|
|
||||||
export default function AdminActions() {
|
export default function AdminActions() {
|
||||||
const { mutate: recrawlLinks, isPending: isRecrawlPending } =
|
|
||||||
api.admin.recrawlLinks.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,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
mutate: reRunInferenceOnAllBookmarks,
|
|
||||||
isPending: isInferencePending,
|
|
||||||
} = api.admin.reRunInferenceOnAllBookmarks.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
description: "Inference jobs enqueued",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
description: e.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { mutateAsync: tidyAssets, isPending: isTidyAssetsPending } =
|
|
||||||
api.admin.tidyAssets.useMutation({
|
|
||||||
onSuccess: () => {
|
|
||||||
toast({
|
|
||||||
description: "Tidy assets request has been enqueued!",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (e) => {
|
|
||||||
toast({
|
|
||||||
variant: "destructive",
|
|
||||||
description: e.message,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -73,60 +12,10 @@ export default function AdminActions() {
|
|||||||
<div className="flex flex-col gap-2 sm:w-1/2">
|
<div className="flex flex-col gap-2 sm:w-1/2">
|
||||||
<ActionButton
|
<ActionButton
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
loading={isRecrawlPending}
|
loading={false}
|
||||||
onClick={() =>
|
onClick={() => alert("This does nothing yet")}
|
||||||
recrawlLinks({ crawlStatus: "failure", runInference: true })
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
Recrawl Failed Links Only
|
TODO: Add Stuff Here
|
||||||
</ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
variant="destructive"
|
|
||||||
loading={isRecrawlPending}
|
|
||||||
onClick={() =>
|
|
||||||
recrawlLinks({ crawlStatus: "all", runInference: true })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Recrawl All Links
|
|
||||||
</ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
variant="destructive"
|
|
||||||
loading={isRecrawlPending}
|
|
||||||
onClick={() =>
|
|
||||||
recrawlLinks({ crawlStatus: "all", runInference: false })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Recrawl All Links (Without Inference)
|
|
||||||
</ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
variant="destructive"
|
|
||||||
loading={isInferencePending}
|
|
||||||
onClick={() =>
|
|
||||||
reRunInferenceOnAllBookmarks({ taggingStatus: "failure" })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
Regenerate AI Tags for Failed Bookmarks Only
|
|
||||||
</ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
variant="destructive"
|
|
||||||
loading={isInferencePending}
|
|
||||||
onClick={() => reRunInferenceOnAllBookmarks({ taggingStatus: "all" })}
|
|
||||||
>
|
|
||||||
Regenerate AI Tags for All Bookmarks
|
|
||||||
</ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
variant="destructive"
|
|
||||||
loading={isReindexPending}
|
|
||||||
onClick={() => reindexBookmarks()}
|
|
||||||
>
|
|
||||||
Reindex All Bookmarks
|
|
||||||
</ActionButton>
|
|
||||||
<ActionButton
|
|
||||||
variant="destructive"
|
|
||||||
loading={isTidyAssetsPending}
|
|
||||||
onClick={() => tidyAssets()}
|
|
||||||
>
|
|
||||||
Compact Assets
|
|
||||||
</ActionButton>
|
</ActionButton>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -32,7 +32,7 @@ import { TRPCClientError } from "@trpc/client";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { changeRoleSchema } from "@hoarder/shared/types/admin";
|
import { changeRoleSchema } from "@lifetracker/shared/types/admin";
|
||||||
|
|
||||||
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
|
type ChangeRoleSchema = z.infer<typeof changeRoleSchema>;
|
||||||
|
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import { TRPCClientError } from "@trpc/client";
|
|||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
import { resetPasswordSchema } from "@hoarder/shared/types/admin";
|
import { resetPasswordSchema } from "@lifetracker/shared/types/admin";
|
||||||
|
|
||||||
interface ResetPasswordDialogProps {
|
interface ResetPasswordDialogProps {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|||||||
@ -14,8 +14,8 @@ import { api } from "@/lib/trpc";
|
|||||||
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
const REPO_LATEST_RELEASE_API =
|
const REPO_LATEST_RELEASE_API =
|
||||||
"https://api.github.com/repos/hoarder-app/hoarder/releases/latest";
|
"https://git.ryanpandya.com/repos/ryan/lifetracker/releases/latest";
|
||||||
const REPO_RELEASE_PAGE = "https://github.com/hoarder-app/hoarder/releases";
|
const REPO_RELEASE_PAGE = "https://git.ryanpandya.com/ryan/lifetracker/releases";
|
||||||
|
|
||||||
function useLatestRelease() {
|
function useLatestRelease() {
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
@ -55,7 +55,7 @@ function ReleaseInfo() {
|
|||||||
return (
|
return (
|
||||||
<div className="text-nowrap">
|
<div className="text-nowrap">
|
||||||
<span className="text-3xl font-semibold">{currentRelease}</span>
|
<span className="text-3xl font-semibold">{currentRelease}</span>
|
||||||
<span className="ml-1 text-sm">{newRelease}</span>
|
{/* <span className="ml-1 text-sm">{newRelease}</span> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -83,7 +83,7 @@ export default function ServerStats() {
|
|||||||
Total Bookmarks
|
Total Bookmarks
|
||||||
</div>
|
</div>
|
||||||
<div className="text-3xl font-semibold">
|
<div className="text-3xl font-semibold">
|
||||||
{serverStats.numBookmarks}
|
{serverStats.numEntries}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border bg-background p-4 sm:w-1/4">
|
<div className="rounded-md border bg-background p-4 sm:w-1/4">
|
||||||
@ -105,28 +105,7 @@ export default function ServerStats() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell className="lg:w-2/3">Crawling Jobs</TableCell>
|
<TableCell className="lg:w-2/3">No workers yet...</TableCell>
|
||||||
<TableCell>{serverStats.crawlStats.queued}</TableCell>
|
|
||||||
<TableCell>{serverStats.crawlStats.pending}</TableCell>
|
|
||||||
<TableCell>{serverStats.crawlStats.failed}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Indexing Jobs</TableCell>
|
|
||||||
<TableCell>{serverStats.indexingStats.queued}</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Inference Jobs</TableCell>
|
|
||||||
<TableCell>{serverStats.inferenceStats.queued}</TableCell>
|
|
||||||
<TableCell>{serverStats.inferenceStats.pending}</TableCell>
|
|
||||||
<TableCell>{serverStats.inferenceStats.failed}</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
<TableRow>
|
|
||||||
<TableCell>Tidy Assets Jobs</TableCell>
|
|
||||||
<TableCell>{serverStats.tidyAssetsStats.queued}</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
<TableCell>-</TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
@ -67,8 +67,8 @@ export default function UsersSection() {
|
|||||||
<TableHeader className="bg-gray-200">
|
<TableHeader className="bg-gray-200">
|
||||||
<TableHead>Name</TableHead>
|
<TableHead>Name</TableHead>
|
||||||
<TableHead>Email</TableHead>
|
<TableHead>Email</TableHead>
|
||||||
<TableHead>Num Bookmarks</TableHead>
|
<TableHead>Days on Record</TableHead>
|
||||||
<TableHead>Asset Sizes</TableHead>
|
<TableHead>Entries</TableHead>
|
||||||
<TableHead>Role</TableHead>
|
<TableHead>Role</TableHead>
|
||||||
<TableHead>Local User</TableHead>
|
<TableHead>Local User</TableHead>
|
||||||
<TableHead>Actions</TableHead>
|
<TableHead>Actions</TableHead>
|
||||||
@ -79,10 +79,10 @@ export default function UsersSection() {
|
|||||||
<TableCell className="py-1">{u.name}</TableCell>
|
<TableCell className="py-1">{u.name}</TableCell>
|
||||||
<TableCell className="py-1">{u.email}</TableCell>
|
<TableCell className="py-1">{u.email}</TableCell>
|
||||||
<TableCell className="py-1">
|
<TableCell className="py-1">
|
||||||
{userStats[u.id].numBookmarks}
|
{userStats[u.id].numDays}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-1">
|
<TableCell className="py-1">
|
||||||
{toHumanReadableSize(userStats[u.id].assetSizes)}
|
{userStats[u.id].numEntries}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="py-1 capitalize">{u.role}</TableCell>
|
<TableCell className="py-1 capitalize">{u.role}</TableCell>
|
||||||
<TableCell className="py-1 capitalize">
|
<TableCell className="py-1 capitalize">
|
||||||
|
|||||||
@ -26,7 +26,7 @@ import { cn } from "@/lib/utils";
|
|||||||
import { distance } from "fastest-levenshtein";
|
import { distance } from "fastest-levenshtein";
|
||||||
import { Check, Combine, X } from "lucide-react";
|
import { Check, Combine, X } from "lucide-react";
|
||||||
|
|
||||||
import { useMergeTag } from "@hoarder/shared-react/hooks/tags";
|
import { useMergeTag } from "@lifetracker/shared-react/hooks/tags";
|
||||||
|
|
||||||
interface Suggestion {
|
interface Suggestion {
|
||||||
mergeIntoId: string;
|
mergeIntoId: string;
|
||||||
|
|||||||
@ -17,11 +17,6 @@ export async function createContextFromRequest(req: Request) {
|
|||||||
if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) {
|
if (authorizationHeader && authorizationHeader.startsWith("Bearer ")) {
|
||||||
const token = authorizationHeader.split(" ")[1];
|
const token = authorizationHeader.split(" ")[1];
|
||||||
try {
|
try {
|
||||||
|
|
||||||
console.log("\n\nEeeeeeentering hell\n\n");
|
|
||||||
console.log(await authenticateApiKey(token));
|
|
||||||
console.log("\n\n\n\n");
|
|
||||||
|
|
||||||
const user = await authenticateApiKey(token);
|
const user = await authenticateApiKey(token);
|
||||||
return {
|
return {
|
||||||
user,
|
user,
|
||||||
@ -44,7 +39,7 @@ export const createContext = async (
|
|||||||
): Promise<Context> => {
|
): Promise<Context> => {
|
||||||
const session = await getServerAuthSession();
|
const session = await getServerAuthSession();
|
||||||
if (ip === undefined) {
|
if (ip === undefined) {
|
||||||
const hdrs = headers();
|
const hdrs = await headers();
|
||||||
ip = requestIp.getClientIp({
|
ip = requestIp.getClientIp({
|
||||||
headers: Object.fromEntries(hdrs.entries()),
|
headers: Object.fromEntries(hdrs.entries()),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,8 +2,6 @@ import dotenv from "dotenv";
|
|||||||
import type { Config } from "drizzle-kit";
|
import type { Config } from "drizzle-kit";
|
||||||
import serverConfig from "@lifetracker/shared/config";
|
import serverConfig from "@lifetracker/shared/config";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const databaseURL = serverConfig.dataDir
|
const databaseURL = serverConfig.dataDir
|
||||||
? `${serverConfig.dataDir}/lifetracker.db`
|
? `${serverConfig.dataDir}/lifetracker.db`
|
||||||
: "./lifetracker.db";
|
: "./lifetracker.db";
|
||||||
|
|||||||
@ -8,7 +8,10 @@ import path from "path";
|
|||||||
import dbConfig from "./drizzle.config";
|
import dbConfig from "./drizzle.config";
|
||||||
|
|
||||||
const sqlite = new Database(dbConfig.dbCredentials.url);
|
const sqlite = new Database(dbConfig.dbCredentials.url);
|
||||||
export const db = drizzle(sqlite, { schema, logger: true });
|
export const db = drizzle(sqlite, {
|
||||||
|
schema,
|
||||||
|
// logger: true
|
||||||
|
});
|
||||||
|
|
||||||
export function getInMemoryDB(runMigrations: boolean) {
|
export function getInMemoryDB(runMigrations: boolean) {
|
||||||
const mem = new Database(":memory:");
|
const mem = new Database(":memory:");
|
||||||
|
|||||||
@ -1,9 +1,8 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import dotenv from "dotenv";
|
import dotenv from "dotenv";
|
||||||
console.log(dotenv.config({
|
dotenv.config({
|
||||||
path: ".env.local",
|
path: ".env.local",
|
||||||
debug: true
|
});
|
||||||
}));
|
|
||||||
const stringBool = (defaultValue: string) =>
|
const stringBool = (defaultValue: string) =>
|
||||||
z
|
z
|
||||||
.string()
|
.string()
|
||||||
|
|||||||
25
packages/shared/types/admin.ts
Normal file
25
packages/shared/types/admin.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { PASSWORD_MAX_LENGTH, zSignUpSchema } from "./users";
|
||||||
|
|
||||||
|
export const zRoleSchema = z.object({
|
||||||
|
role: z.enum(["user", "admin"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const zAdminCreateUserSchema = zSignUpSchema.and(zRoleSchema);
|
||||||
|
|
||||||
|
export const changeRoleSchema = z.object({
|
||||||
|
userId: z.string(),
|
||||||
|
role: z.enum(["user", "admin"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const resetPasswordSchema = z
|
||||||
|
.object({
|
||||||
|
userId: z.string(),
|
||||||
|
newPassword: z.string().min(8).max(PASSWORD_MAX_LENGTH),
|
||||||
|
newPasswordConfirm: z.string(),
|
||||||
|
})
|
||||||
|
.refine((data) => data.newPassword === data.newPasswordConfirm, {
|
||||||
|
message: "Passwords don't match",
|
||||||
|
path: ["newPasswordConfirm"],
|
||||||
|
});
|
||||||
@ -2,10 +2,12 @@ import { apiKeys } from "@lifetracker/db/schema";
|
|||||||
import { router } from "../index";
|
import { router } from "../index";
|
||||||
import { usersAppRouter } from "./users";
|
import { usersAppRouter } from "./users";
|
||||||
import { apiKeysAppRouter } from "./apiKeys";
|
import { apiKeysAppRouter } from "./apiKeys";
|
||||||
|
import { adminAppRouter } from "./admin";
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
users: usersAppRouter,
|
users: usersAppRouter,
|
||||||
apiKeys: apiKeysAppRouter,
|
apiKeys: apiKeysAppRouter,
|
||||||
|
admin: adminAppRouter,
|
||||||
});
|
});
|
||||||
// export type definition of API
|
// export type definition of API
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
118
packages/trpc/routers/admin.ts
Normal file
118
packages/trpc/routers/admin.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { TRPCError } from "@trpc/server";
|
||||||
|
import { count, eq, sum } from "drizzle-orm";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
import { users } from "@lifetracker/db/schema";
|
||||||
|
import {
|
||||||
|
changeRoleSchema,
|
||||||
|
resetPasswordSchema,
|
||||||
|
zAdminCreateUserSchema,
|
||||||
|
} from "@lifetracker/shared/types/admin";
|
||||||
|
|
||||||
|
import { hashPassword } from "../auth";
|
||||||
|
import { adminProcedure, router } from "../index";
|
||||||
|
import { createUser } from "./users";
|
||||||
|
|
||||||
|
export const adminAppRouter = router({
|
||||||
|
stats: adminProcedure
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
numUsers: z.number(),
|
||||||
|
numEntries: z.number(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
const [
|
||||||
|
[{ value: numUsers }],
|
||||||
|
] = await Promise.all([
|
||||||
|
ctx.db.select({ value: count() }).from(users)]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
numUsers,
|
||||||
|
numEntries: 420.69,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
userStats: adminProcedure
|
||||||
|
.output(
|
||||||
|
z.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
numDays: z.number(),
|
||||||
|
numEntries: z.number(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.query(async ({ ctx }) => {
|
||||||
|
const [userIds] = await Promise.all([
|
||||||
|
ctx.db.select({ id: users.id }).from(users)
|
||||||
|
]);
|
||||||
|
|
||||||
|
const results: Record<
|
||||||
|
string,
|
||||||
|
{ numDays: number; numEntries: number }
|
||||||
|
> = {};
|
||||||
|
for (const user of userIds) {
|
||||||
|
results[user.id] = {
|
||||||
|
numDays: 0,
|
||||||
|
numEntries: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}),
|
||||||
|
createUser: adminProcedure
|
||||||
|
.input(zAdminCreateUserSchema)
|
||||||
|
.output(
|
||||||
|
z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string(),
|
||||||
|
role: z.enum(["user", "admin"]).nullable(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
return createUser(input, ctx, input.role);
|
||||||
|
}),
|
||||||
|
changeRole: adminProcedure
|
||||||
|
.input(changeRoleSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (ctx.user.id == input.userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Cannot change own role",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const result = await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({ role: input.role })
|
||||||
|
.where(eq(users.id, input.userId));
|
||||||
|
|
||||||
|
if (!result.changes) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
resetPassword: adminProcedure
|
||||||
|
.input(resetPasswordSchema)
|
||||||
|
.mutation(async ({ input, ctx }) => {
|
||||||
|
if (ctx.user.id == input.userId) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Cannot reset own password",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const hashedPassword = await hashPassword(input.newPassword);
|
||||||
|
const result = await ctx.db
|
||||||
|
.update(users)
|
||||||
|
.set({ password: hashedPassword })
|
||||||
|
.where(eq(users.id, input.userId));
|
||||||
|
|
||||||
|
if (result.changes == 0) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
message: "User not found",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user