♻️ Migrate from prisma to drizzle (#1434)
* ♻️ Migrate from prisma to drizzle * 🐛 Build issue with CalendarTile * 🚧 Temporary solution for docker container * 🐛 Drizzle not using DATABASE_URL * ♻️ Address pull request feedback * 🐛 Remove console log of env variables * 🐛 Some unit tests not working * 🐋 Revert docker tool changes * 🐛 Issue with board slug page for logged in users --------- Co-authored-by: Thomas Camlong <thomascamlong@gmail.com>
This commit is contained in:
@@ -1,24 +1,24 @@
|
||||
import { Box, Text } from "@mantine/core";
|
||||
import { IconCheck, IconX } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { minPasswordLength } from "~/validations/user";
|
||||
import { Box, Text } from '@mantine/core';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
import { minPasswordLength } from '~/validations/user';
|
||||
|
||||
export const PasswordRequirement = ({ meets, label }: { meets: boolean; label: string }) => {
|
||||
const { t } = useTranslation('password-requirements');
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={meets ? 'teal' : 'red'}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
mt={7}
|
||||
size="sm"
|
||||
>
|
||||
{meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />}{' '}
|
||||
<Box ml={10}>
|
||||
{t(`${label}`, {
|
||||
count: minPasswordLength,
|
||||
})}
|
||||
</Box>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
const { t } = useTranslation('password-requirements');
|
||||
|
||||
return (
|
||||
<Text
|
||||
color={meets ? 'teal' : 'red'}
|
||||
sx={{ display: 'flex', alignItems: 'center' }}
|
||||
mt={7}
|
||||
size="sm"
|
||||
>
|
||||
{meets ? <IconCheck size="0.9rem" /> : <IconX size="0.9rem" />}
|
||||
<Box ml={10}>
|
||||
{t(`${label}`, {
|
||||
count: minPasswordLength,
|
||||
})}
|
||||
</Box>
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
11
src/env.js
11
src/env.js
@@ -1,7 +1,11 @@
|
||||
const { z } = require('zod');
|
||||
const { createEnv } = require('@t3-oss/env-nextjs');
|
||||
|
||||
const portSchema = z.string().regex(/\d*/).transform((value) => value === undefined ? undefined : Number(value)).optional();
|
||||
const portSchema = z
|
||||
.string()
|
||||
.regex(/\d*/)
|
||||
.transform((value) => (value === undefined ? undefined : Number(value)))
|
||||
.optional();
|
||||
const envSchema = z.enum(['development', 'test', 'production']);
|
||||
|
||||
const env = createEnv({
|
||||
@@ -22,7 +26,7 @@ const env = createEnv({
|
||||
),
|
||||
DOCKER_HOST: z.string().optional(),
|
||||
DOCKER_PORT: portSchema,
|
||||
HOSTNAME: z.string().optional()
|
||||
HOSTNAME: z.string().optional(),
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -57,8 +61,9 @@ const env = createEnv({
|
||||
NEXT_PUBLIC_DEFAULT_COLOR_SCHEME: process.env.DEFAULT_COLOR_SCHEME,
|
||||
NEXT_PUBLIC_PORT: process.env.PORT,
|
||||
NEXT_PUBLIC_NODE_ENV: process.env.NODE_ENV,
|
||||
HOSTNAME: process.env.HOSTNAME
|
||||
HOSTNAME: process.env.HOSTNAME,
|
||||
},
|
||||
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
|
||||
18
src/migrate.ts
Normal file
18
src/migrate.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// This file is used to migrate the database to the current version
|
||||
// It is run when the docker container starts
|
||||
import Database from 'better-sqlite3';
|
||||
import dotenv from 'dotenv';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { migrate } from 'drizzle-orm/better-sqlite3/migrator';
|
||||
|
||||
dotenv.config({ path: __dirname + '/../.env' });
|
||||
|
||||
const sqlite = new Database(process.env.DATABASE_URL!.replace('file:', ''));
|
||||
|
||||
const db = drizzle(sqlite);
|
||||
|
||||
const migrateDatabase = async () => {
|
||||
await migrate(db, { migrationsFolder: './drizzle' });
|
||||
};
|
||||
|
||||
migrateDatabase();
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { useForm } from '@mantine/form';
|
||||
import { showNotification, updateNotification } from '@mantine/notifications';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import { GetServerSideProps } from 'next';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useTranslation } from 'next-i18next';
|
||||
@@ -20,10 +21,11 @@ import { useRouter } from 'next/router';
|
||||
import { useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { PasswordRequirements } from '~/components/Password/password-requirements';
|
||||
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
|
||||
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
|
||||
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { prisma } from '~/server/db';
|
||||
import { db } from '~/server/db';
|
||||
import { invites } from '~/server/db/schema';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { api } from '~/utils/api';
|
||||
import { useI18nZodResolver } from '~/utils/i18n-zod-resolver';
|
||||
@@ -194,14 +196,14 @@ export const getServerSideProps: GetServerSideProps = async ({
|
||||
};
|
||||
}
|
||||
|
||||
const token = await prisma.invite.findUnique({
|
||||
where: {
|
||||
id: routeParams.data.inviteId,
|
||||
token: queryParams.data.token,
|
||||
},
|
||||
const dbInvite = await db.query.invites.findFirst({
|
||||
where: and(
|
||||
eq(invites.id, routeParams.data.inviteId),
|
||||
eq(invites.token, queryParams.data.token)
|
||||
),
|
||||
});
|
||||
|
||||
if (!token || token.expires < new Date()) {
|
||||
if (!dbInvite || dbInvite.expires < new Date()) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
|
||||
@@ -64,7 +64,7 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
|
||||
const result = checkForSessionOrAskForLogin(
|
||||
ctx,
|
||||
session,
|
||||
() => config.settings.access.allowGuests || !session?.user
|
||||
() => config.settings.access.allowGuests || !!session?.user
|
||||
);
|
||||
if (result) {
|
||||
return result;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { GetServerSideProps, InferGetServerSidePropsType } from 'next';
|
||||
import { SSRConfig } from 'next-i18next';
|
||||
import { Dashboard } from '~/components/Dashboard/Dashboard';
|
||||
@@ -5,7 +6,9 @@ import { BoardLayout } from '~/components/layout/Templates/BoardLayout';
|
||||
import { useInitConfig } from '~/config/init';
|
||||
import { env } from '~/env';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { prisma } from '~/server/db';
|
||||
import { db } from '~/server/db';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import { userSettings } from '~/server/db/schema';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { boardNamespaces } from '~/tools/server/translation-namespaces';
|
||||
@@ -32,11 +35,7 @@ type BoardGetServerSideProps = {
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = async (ctx) => {
|
||||
const session = await getServerAuthSession(ctx);
|
||||
const currentUserSettings = await prisma.userSettings.findFirst({
|
||||
where: {
|
||||
userId: session?.user?.id,
|
||||
},
|
||||
});
|
||||
const boardName = await getDefaultBoardAsync(session?.user?.id, 'default');
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
boardNamespaces,
|
||||
@@ -44,7 +43,6 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
|
||||
ctx.req,
|
||||
ctx.res
|
||||
);
|
||||
const boardName = currentUserSettings?.defaultBoard ?? 'default';
|
||||
const config = await getFrontendConfig(boardName);
|
||||
|
||||
if (!config.settings.access.allowGuests && !session?.user) {
|
||||
@@ -54,7 +52,7 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = a
|
||||
primaryColor: config.settings.customization.colors.primary,
|
||||
secondaryColor: config.settings.customization.colors.secondary,
|
||||
primaryShade: config.settings.customization.colors.shade,
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import ContainerTable from '~/components/Manage/Tools/Docker/ContainerTable';
|
||||
import { ManageLayout } from '~/components/layout/Templates/ManageLayout';
|
||||
import { dockerRouter } from '~/server/api/routers/docker/router';
|
||||
import { getServerAuthSession } from '~/server/auth';
|
||||
import { prisma } from '~/server/db';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
import { boardNamespaces } from '~/tools/server/translation-namespaces';
|
||||
import { api } from '~/utils/api';
|
||||
@@ -66,7 +65,6 @@ export const getServerSideProps: GetServerSideProps = async ({ locale, req, res
|
||||
const caller = dockerRouter.createCaller({
|
||||
session: session,
|
||||
cookies: req.cookies,
|
||||
prisma: prisma,
|
||||
});
|
||||
|
||||
const translations = await getServerSideTranslations(
|
||||
|
||||
@@ -148,7 +148,7 @@ const ManageUsersPage = () => {
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{debouncedSearch && debouncedSearch.length > 0 && (
|
||||
{debouncedSearch && debouncedSearch.length > 0 && data.countPages === 0 && (
|
||||
<tr>
|
||||
<td colSpan={1}>
|
||||
<Box p={15}>
|
||||
|
||||
@@ -7,7 +7,8 @@ import Head from 'next/head';
|
||||
import { OnboardingSteps } from '~/components/Onboarding/onboarding-steps';
|
||||
import { ThemeSchemeToggle } from '~/components/ThemeSchemeToggle/ThemeSchemeToggle';
|
||||
import { FloatingBackground } from '~/components/layout/Background/FloatingBackground';
|
||||
import { prisma } from '~/server/db';
|
||||
import { db } from '~/server/db';
|
||||
import { getTotalUserCountAsync } from '~/server/db/queries/user';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { getServerSideTranslations } from '~/tools/server/getServerSideTranslations';
|
||||
|
||||
@@ -32,11 +33,7 @@ export default function OnboardPage({
|
||||
<ThemeSchemeToggle pos="absolute" top={20} right={20} variant="default" />
|
||||
|
||||
<Stack h="100dvh" bg={background} spacing={0}>
|
||||
<Center
|
||||
bg={fn.linearGradient(145, colors.red[7], colors.red[5])}
|
||||
mih={150}
|
||||
h={150}
|
||||
>
|
||||
<Center bg={fn.linearGradient(145, colors.red[7], colors.red[5])} mih={150} h={150}>
|
||||
<Center bg={background} w={100} h={100} style={{ borderRadius: 64 }}>
|
||||
<Image width={70} src="/imgs/logo/logo-color.svg" alt="Homarr Logo" />
|
||||
</Center>
|
||||
@@ -72,7 +69,7 @@ export default function OnboardPage({
|
||||
}
|
||||
|
||||
export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const userCount = await prisma.user.count();
|
||||
const userCount = await getTotalUserCountAsync();
|
||||
if (userCount >= 1) {
|
||||
return {
|
||||
notFound: true,
|
||||
@@ -83,7 +80,12 @@ export const getServerSideProps: GetServerSideProps = async (ctx) => {
|
||||
const configs = files.map((file) => getConfig(file));
|
||||
const configSchemaVersions = configs.map((config) => config.schemaVersion);
|
||||
|
||||
const translations = await getServerSideTranslations(['password-requirements'], ctx.locale, ctx.req, ctx.res);
|
||||
const translations = await getServerSideTranslations(
|
||||
['password-requirements'],
|
||||
ctx.locale,
|
||||
ctx.req,
|
||||
ctx.res
|
||||
);
|
||||
|
||||
return {
|
||||
props: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import fs from 'fs';
|
||||
import { z } from 'zod';
|
||||
import { getDefaultBoardAsync } from '~/server/db/queries/userSettings';
|
||||
import { configExists } from '~/tools/config/configExists';
|
||||
import { getConfig } from '~/tools/config/getConfig';
|
||||
import { getFrontendConfig } from '~/tools/config/getFrontendConfig';
|
||||
@@ -13,11 +14,7 @@ export const boardRouter = createTRPCRouter({
|
||||
all: protectedProcedure.query(async ({ ctx }) => {
|
||||
const files = fs.readdirSync('./data/configs').filter((file) => file.endsWith('.json'));
|
||||
|
||||
const userSettings = await ctx.prisma.userSettings.findUniqueOrThrow({
|
||||
where: {
|
||||
userId: ctx.session?.user.id,
|
||||
},
|
||||
});
|
||||
const defaultBoard = await getDefaultBoardAsync(ctx.session.user.id, 'default');
|
||||
|
||||
return await Promise.all(
|
||||
files.map(async (file) => {
|
||||
@@ -31,7 +28,7 @@ export const boardRouter = createTRPCRouter({
|
||||
countApps: countApps,
|
||||
countWidgets: config.widgets.length,
|
||||
countCategories: config.categories.length,
|
||||
isDefaultForUser: name === userSettings.defaultBoard,
|
||||
isDefaultForUser: name === defaultBoard,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import { randomBytes, randomUUID } from 'crypto';
|
||||
import dayjs from 'dayjs';
|
||||
import { eq, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '~/server/db';
|
||||
import { invites } from '~/server/db/schema';
|
||||
|
||||
import { adminProcedure, createTRPCRouter, publicProcedure } from '../trpc';
|
||||
|
||||
@@ -14,22 +17,25 @@ export const inviteRouter = createTRPCRouter({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const limit = input.limit ?? 50;
|
||||
const invites = await ctx.prisma.invite.findMany({
|
||||
take: limit,
|
||||
skip: limit * input.page,
|
||||
include: {
|
||||
const dbInvites = await db.query.invites.findMany({
|
||||
limit: limit,
|
||||
offset: limit * input.page,
|
||||
with: {
|
||||
createdBy: {
|
||||
select: {
|
||||
columns: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const inviteCount = await ctx.prisma.invite.count();
|
||||
const inviteCount = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(invites)
|
||||
.then((rows) => rows[0].count);
|
||||
|
||||
return {
|
||||
invites: invites.map((token) => ({
|
||||
invites: dbInvites.map((token) => ({
|
||||
id: token.id,
|
||||
expires: token.expires,
|
||||
creator: token.createdBy.name,
|
||||
@@ -47,27 +53,21 @@ export const inviteRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const token = await ctx.prisma.invite.create({
|
||||
data: {
|
||||
expires: input.expiration,
|
||||
createdById: ctx.session.user.id,
|
||||
token: randomBytes(20).toString('hex'),
|
||||
},
|
||||
});
|
||||
const inviteToInsert = {
|
||||
id: randomUUID(),
|
||||
expires: input.expiration,
|
||||
createdById: ctx.session.user.id,
|
||||
token: randomBytes(20).toString('hex'),
|
||||
};
|
||||
await db.insert(invites).values(inviteToInsert);
|
||||
|
||||
return {
|
||||
id: token.id,
|
||||
token: token.token,
|
||||
expires: token.expires,
|
||||
id: inviteToInsert.id,
|
||||
token: inviteToInsert.token,
|
||||
expires: inviteToInsert.expires,
|
||||
};
|
||||
}),
|
||||
delete: adminProcedure
|
||||
.input(z.object({ tokenId: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.invite.delete({
|
||||
where: {
|
||||
id: input.tokenId,
|
||||
},
|
||||
});
|
||||
}),
|
||||
delete: adminProcedure.input(z.object({ tokenId: z.string() })).mutation(async ({ input }) => {
|
||||
await db.delete(invites).where(eq(invites.id, input.tokenId));
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { UserSettings } from '@prisma/client';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { eq, like, sql } from 'drizzle-orm';
|
||||
import { z } from 'zod';
|
||||
import { db } from '~/server/db';
|
||||
import { getTotalUserCountAsync } from '~/server/db/queries/user';
|
||||
import { UserSettings, invites, userSettings, users } from '~/server/db/schema';
|
||||
import { hashPassword } from '~/utils/security';
|
||||
import {
|
||||
colorSchemeParser,
|
||||
@@ -11,24 +15,18 @@ import {
|
||||
} from '~/validations/user';
|
||||
|
||||
import { COOKIE_COLOR_SCHEME_KEY, COOKIE_LOCALE_KEY } from '../../../../data/constants';
|
||||
import {
|
||||
TRPCContext,
|
||||
adminProcedure,
|
||||
createTRPCRouter,
|
||||
protectedProcedure,
|
||||
publicProcedure,
|
||||
} from '../trpc';
|
||||
import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc';
|
||||
|
||||
export const userRouter = createTRPCRouter({
|
||||
createOwnerAccount: publicProcedure.input(signUpFormSchema).mutation(async ({ ctx, input }) => {
|
||||
const userCount = await ctx.prisma.user.count();
|
||||
const userCount = await getTotalUserCountAsync();
|
||||
if (userCount > 0) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
});
|
||||
}
|
||||
|
||||
await createUserIfNotPresent(ctx, input, {
|
||||
await createUserIfNotPresent(input, {
|
||||
defaultSettings: {
|
||||
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
|
||||
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
|
||||
@@ -36,9 +34,8 @@ export const userRouter = createTRPCRouter({
|
||||
isOwner: true,
|
||||
});
|
||||
}),
|
||||
count: publicProcedure.query(async ({ ctx }) => {
|
||||
const count = await ctx.prisma.user.count();
|
||||
return count;
|
||||
count: publicProcedure.query(async () => {
|
||||
return await getTotalUserCountAsync();
|
||||
}),
|
||||
createFromInvite: publicProcedure
|
||||
.input(
|
||||
@@ -49,51 +46,29 @@ export const userRouter = createTRPCRouter({
|
||||
)
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const token = await ctx.prisma.invite.findUnique({
|
||||
where: {
|
||||
token: input.inviteToken,
|
||||
},
|
||||
const invite = await db.query.invites.findFirst({
|
||||
where: eq(invites.token, input.inviteToken),
|
||||
});
|
||||
|
||||
if (!token || token.expires < new Date()) {
|
||||
if (!invite || invite.expires < new Date()) {
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Invalid invite token',
|
||||
});
|
||||
}
|
||||
|
||||
await createUserIfNotPresent(ctx, input, {
|
||||
const userId = await createUserIfNotPresent(input, {
|
||||
defaultSettings: {
|
||||
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
|
||||
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
|
||||
},
|
||||
});
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hashedPassword = hashPassword(input.password, salt);
|
||||
|
||||
const user = await ctx.prisma.user.create({
|
||||
data: {
|
||||
name: input.username,
|
||||
password: hashedPassword,
|
||||
salt: salt,
|
||||
settings: {
|
||||
create: {
|
||||
colorScheme: colorSchemeParser.parse(ctx.cookies[COOKIE_COLOR_SCHEME_KEY]),
|
||||
language: ctx.cookies[COOKIE_LOCALE_KEY] ?? 'en',
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await ctx.prisma.invite.delete({
|
||||
where: {
|
||||
id: token.id,
|
||||
},
|
||||
});
|
||||
await db.delete(invites).where(eq(invites.id, invite.id));
|
||||
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
id: userId,
|
||||
name: input.username,
|
||||
};
|
||||
}),
|
||||
changeColorScheme: protectedProcedure
|
||||
@@ -103,18 +78,12 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.user.update({
|
||||
where: {
|
||||
id: ctx.session?.user?.id,
|
||||
},
|
||||
data: {
|
||||
settings: {
|
||||
update: {
|
||||
colorScheme: input.colorScheme,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db
|
||||
.update(userSettings)
|
||||
.set({
|
||||
colorScheme: input.colorScheme,
|
||||
})
|
||||
.where(eq(userSettings.userId, ctx.session?.user?.id));
|
||||
}),
|
||||
changeRole: adminProcedure
|
||||
.input(z.object({ id: z.string(), type: z.enum(['promote', 'demote']) }))
|
||||
@@ -126,10 +95,8 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -146,14 +113,10 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.prisma.user.update({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
data: {
|
||||
isAdmin: input.type === 'promote',
|
||||
},
|
||||
});
|
||||
await db
|
||||
.update(users)
|
||||
.set({ isAdmin: input.type === 'promote' })
|
||||
.where(eq(users.id, input.id));
|
||||
}),
|
||||
changeLanguage: protectedProcedure
|
||||
.input(
|
||||
@@ -162,25 +125,15 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.user.update({
|
||||
where: {
|
||||
id: ctx.session?.user?.id,
|
||||
},
|
||||
data: {
|
||||
settings: {
|
||||
update: {
|
||||
language: input.language,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db
|
||||
.update(userSettings)
|
||||
.set({ language: input.language })
|
||||
.where(eq(userSettings.userId, ctx.session?.user?.id));
|
||||
}),
|
||||
withSettings: protectedProcedure.query(async ({ ctx, input }) => {
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: {
|
||||
id: ctx.session?.user?.id,
|
||||
},
|
||||
include: {
|
||||
withSettings: protectedProcedure.query(async ({ ctx }) => {
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, ctx.session?.user?.id),
|
||||
with: {
|
||||
settings: true,
|
||||
},
|
||||
});
|
||||
@@ -195,50 +148,26 @@ export const userRouter = createTRPCRouter({
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
settings: {
|
||||
...user.settings,
|
||||
firstDayOfWeek: z
|
||||
.enum(['monday', 'saturday', 'sunday'])
|
||||
.parse(user.settings.firstDayOfWeek),
|
||||
},
|
||||
settings: user.settings,
|
||||
};
|
||||
}),
|
||||
|
||||
updateSettings: protectedProcedure
|
||||
.input(updateSettingsValidationSchema)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.user.update({
|
||||
where: {
|
||||
id: ctx.session.user.id,
|
||||
},
|
||||
data: {
|
||||
settings: {
|
||||
update: {
|
||||
disablePingPulse: input.disablePingPulse,
|
||||
replacePingWithIcons: input.replaceDotsWithIcons,
|
||||
defaultBoard: input.defaultBoard,
|
||||
language: input.language,
|
||||
firstDayOfWeek: input.firstDayOfWeek,
|
||||
searchTemplate: input.searchTemplate,
|
||||
openSearchInNewTab: input.openSearchInNewTab,
|
||||
autoFocusSearch: input.autoFocusSearch,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await db
|
||||
.update(userSettings)
|
||||
.set(input)
|
||||
.where(eq(userSettings.userId, ctx.session?.user?.id));
|
||||
}),
|
||||
|
||||
makeDefaultDashboard: protectedProcedure
|
||||
.input(z.object({ board: z.string() }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await ctx.prisma.userSettings.update({
|
||||
where: {
|
||||
userId: ctx.session?.user.id,
|
||||
},
|
||||
data: {
|
||||
defaultBoard: input.board,
|
||||
},
|
||||
});
|
||||
await db
|
||||
.update(userSettings)
|
||||
.set({ defaultBoard: input.board })
|
||||
.where(eq(userSettings.userId, ctx.session?.user?.id));
|
||||
}),
|
||||
|
||||
all: adminProcedure
|
||||
@@ -254,26 +183,20 @@ export const userRouter = createTRPCRouter({
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const limit = input.limit;
|
||||
const users = await ctx.prisma.user.findMany({
|
||||
take: limit + 1,
|
||||
skip: limit * input.page,
|
||||
where: {
|
||||
name: {
|
||||
contains: input.search,
|
||||
},
|
||||
},
|
||||
const dbUsers = await db.query.users.findMany({
|
||||
limit: limit + 1,
|
||||
offset: limit * input.page,
|
||||
where: input.search ? like(users.name, `%${input.search}%`) : undefined,
|
||||
});
|
||||
|
||||
const countUsers = await ctx.prisma.user.count({
|
||||
where: {
|
||||
name: {
|
||||
contains: input.search,
|
||||
},
|
||||
},
|
||||
});
|
||||
const countUsers = await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.where(input.search ? like(users.name, `%${input.search}%`) : undefined)
|
||||
.then((rows) => rows[0].count);
|
||||
|
||||
return {
|
||||
users: users.map((user) => ({
|
||||
users: dbUsers.map((user) => ({
|
||||
id: user.id,
|
||||
name: user.name!,
|
||||
email: user.email,
|
||||
@@ -284,7 +207,7 @@ export const userRouter = createTRPCRouter({
|
||||
};
|
||||
}),
|
||||
create: adminProcedure.input(createNewUserSchema).mutation(async ({ ctx, input }) => {
|
||||
await createUserIfNotPresent(ctx, input);
|
||||
await createUserIfNotPresent(input);
|
||||
}),
|
||||
|
||||
deleteUser: adminProcedure
|
||||
@@ -294,10 +217,8 @@ export const userRouter = createTRPCRouter({
|
||||
})
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const user = await ctx.prisma.user.findUnique({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
const user = await db.query.users.findFirst({
|
||||
where: eq(users.id, input.id),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -320,26 +241,19 @@ export const userRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.prisma.user.delete({
|
||||
where: {
|
||||
id: input.id,
|
||||
},
|
||||
});
|
||||
await db.delete(users).where(eq(users.id, input.id));
|
||||
}),
|
||||
});
|
||||
|
||||
const createUserIfNotPresent = async (
|
||||
ctx: TRPCContext,
|
||||
input: z.infer<typeof createNewUserSchema>,
|
||||
options: {
|
||||
defaultSettings?: Partial<UserSettings>;
|
||||
isOwner?: boolean;
|
||||
} | void
|
||||
) => {
|
||||
const existingUser = await ctx.prisma.user.findFirst({
|
||||
where: {
|
||||
name: input.username,
|
||||
},
|
||||
const existingUser = await db.query.users.findFirst({
|
||||
where: eq(users.name, input.username),
|
||||
});
|
||||
|
||||
if (existingUser) {
|
||||
@@ -351,17 +265,22 @@ const createUserIfNotPresent = async (
|
||||
|
||||
const salt = bcrypt.genSaltSync(10);
|
||||
const hashedPassword = hashPassword(input.password, salt);
|
||||
await ctx.prisma.user.create({
|
||||
data: {
|
||||
name: input.username,
|
||||
email: input.email,
|
||||
password: hashedPassword,
|
||||
salt: salt,
|
||||
isAdmin: options?.isOwner ?? false,
|
||||
isOwner: options?.isOwner ?? false,
|
||||
settings: {
|
||||
create: options?.defaultSettings ?? {},
|
||||
},
|
||||
},
|
||||
const userId = randomUUID();
|
||||
await db.insert(users).values({
|
||||
id: userId,
|
||||
name: input.username,
|
||||
email: input.email,
|
||||
password: hashedPassword,
|
||||
salt: salt,
|
||||
isAdmin: options?.isOwner ?? false,
|
||||
isOwner: options?.isOwner ?? false,
|
||||
});
|
||||
|
||||
await db.insert(userSettings).values({
|
||||
id: randomUUID(),
|
||||
userId,
|
||||
...(options?.defaultSettings ?? {}),
|
||||
});
|
||||
|
||||
return userId;
|
||||
};
|
||||
|
||||
@@ -13,7 +13,6 @@ import superjson from 'superjson';
|
||||
import { ZodError } from 'zod';
|
||||
|
||||
import { getServerAuthSession } from '../auth';
|
||||
import { prisma } from '../db';
|
||||
|
||||
/**
|
||||
* 1. CONTEXT
|
||||
@@ -41,7 +40,6 @@ interface CreateContextOptions {
|
||||
const createInnerTRPCContext = (opts: CreateContextOptions) => ({
|
||||
session: opts.session,
|
||||
cookies: opts.cookies,
|
||||
prisma,
|
||||
});
|
||||
|
||||
export type TRPCContext = ReturnType<typeof createInnerTRPCContext>;
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { PrismaAdapter } from '@next-auth/prisma-adapter';
|
||||
import { DrizzleAdapter } from '@auth/drizzle-adapter';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import Consola from 'consola';
|
||||
import Cookies from 'cookies';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { type GetServerSidePropsContext, type NextApiRequest, type NextApiResponse } from 'next';
|
||||
import { type DefaultSession, type NextAuthOptions, getServerSession } from 'next-auth';
|
||||
import { Adapter } from 'next-auth/adapters';
|
||||
import { decode, encode } from 'next-auth/jwt';
|
||||
import Credentials from 'next-auth/providers/credentials';
|
||||
import { prisma } from '~/server/db';
|
||||
import EmptyNextAuthProvider from '~/utils/empty-provider';
|
||||
import { fromDate, generateSessionToken } from '~/utils/session';
|
||||
import { colorSchemeParser, signInSchema } from '~/validations/user';
|
||||
|
||||
import { db } from './db';
|
||||
import { users } from './db/schema';
|
||||
|
||||
/**
|
||||
* Module augmentation for `next-auth` types. Allows us to add custom properties to the `session`
|
||||
* object and keep type safety.
|
||||
@@ -48,7 +51,7 @@ declare module 'next-auth/jwt' {
|
||||
}
|
||||
}
|
||||
|
||||
const adapter = PrismaAdapter(prisma);
|
||||
const adapter = DrizzleAdapter(db);
|
||||
const sessionMaxAgeInSeconds = 30 * 24 * 60 * 60; // 30 days
|
||||
|
||||
/**
|
||||
@@ -68,25 +71,25 @@ export const constructAuthOptions = (
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
session.user.name = user.name as string;
|
||||
|
||||
const userFromDatabase = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
include: {
|
||||
const userFromDatabase = await db.query.users.findFirst({
|
||||
with: {
|
||||
settings: {
|
||||
select: {
|
||||
columns: {
|
||||
colorScheme: true,
|
||||
language: true,
|
||||
autoFocusSearch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: eq(users.id, user.id),
|
||||
});
|
||||
|
||||
session.user.isAdmin = userFromDatabase.isAdmin;
|
||||
session.user.colorScheme = colorSchemeParser.parse(userFromDatabase.settings?.colorScheme);
|
||||
session.user.language = userFromDatabase.settings?.language ?? 'en';
|
||||
session.user.autoFocusSearch = userFromDatabase.settings?.autoFocusSearch ?? false;
|
||||
session.user.isAdmin = userFromDatabase?.isAdmin ?? false;
|
||||
session.user.colorScheme = userFromDatabase
|
||||
? colorSchemeParser.parse(userFromDatabase.settings?.colorScheme)
|
||||
: 'environment';
|
||||
session.user.language = userFromDatabase?.settings?.language ?? 'en';
|
||||
session.user.autoFocusSearch = userFromDatabase?.settings?.autoFocusSearch ?? false;
|
||||
}
|
||||
|
||||
return session;
|
||||
@@ -129,7 +132,7 @@ export const constructAuthOptions = (
|
||||
signIn: '/auth/login',
|
||||
error: '/auth/login',
|
||||
},
|
||||
adapter: PrismaAdapter(prisma),
|
||||
adapter: adapter as Adapter,
|
||||
providers: [
|
||||
Credentials({
|
||||
name: 'credentials',
|
||||
@@ -143,19 +146,17 @@ export const constructAuthOptions = (
|
||||
async authorize(credentials) {
|
||||
const data = await signInSchema.parseAsync(credentials);
|
||||
|
||||
const user = await prisma.user.findFirst({
|
||||
where: {
|
||||
name: data.name,
|
||||
},
|
||||
include: {
|
||||
const user = await db.query.users.findFirst({
|
||||
with: {
|
||||
settings: {
|
||||
select: {
|
||||
columns: {
|
||||
colorScheme: true,
|
||||
language: true,
|
||||
autoFocusSearch: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: eq(users.name, data.name),
|
||||
});
|
||||
|
||||
if (!user || !user.password) {
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { env } from '~/env';
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient({
|
||||
log: env.NEXT_PUBLIC_NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
|
||||
});
|
||||
|
||||
if (env.NEXT_PUBLIC_NODE_ENV !== 'production') globalForPrisma.prisma = prisma;
|
||||
9
src/server/db/index.ts
Normal file
9
src/server/db/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import { drizzle } from 'drizzle-orm/better-sqlite3';
|
||||
import { env } from '~/env';
|
||||
|
||||
import * as schema from './schema';
|
||||
|
||||
const sqlite = new Database(env.DATABASE_URL?.replace('file:', ''));
|
||||
|
||||
export const db = drizzle(sqlite, { schema });
|
||||
11
src/server/db/queries/user.ts
Normal file
11
src/server/db/queries/user.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
|
||||
import { db } from '..';
|
||||
import { users } from '../schema';
|
||||
|
||||
export const getTotalUserCountAsync = async () => {
|
||||
return await db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(users)
|
||||
.then((rows) => rows[0].count);
|
||||
};
|
||||
18
src/server/db/queries/userSettings.ts
Normal file
18
src/server/db/queries/userSettings.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '..';
|
||||
import { userSettings } from '../schema';
|
||||
|
||||
export const getDefaultBoardAsync = async (
|
||||
userId: string | undefined,
|
||||
fallback: string = 'default'
|
||||
) => {
|
||||
if (!userId) {
|
||||
return fallback;
|
||||
}
|
||||
return await db.query.userSettings
|
||||
.findFirst({
|
||||
where: eq(userSettings.userId, userId),
|
||||
})
|
||||
.then((settings) => settings?.defaultBoard ?? fallback);
|
||||
};
|
||||
133
src/server/db/schema.ts
Normal file
133
src/server/db/schema.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import { InferSelectModel, relations } from 'drizzle-orm';
|
||||
import { index, int, integer, primaryKey, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { type AdapterAccount } from 'next-auth/adapters';
|
||||
|
||||
export const users = sqliteTable('user', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
name: text('name'),
|
||||
email: text('email'),
|
||||
emailVerified: integer('emailVerified', { mode: 'timestamp_ms' }),
|
||||
image: text('image'),
|
||||
password: text('password'),
|
||||
salt: text('salt'),
|
||||
isAdmin: int('is_admin', { mode: 'boolean' }).notNull().default(false),
|
||||
isOwner: int('is_owner', { mode: 'boolean' }).notNull().default(false),
|
||||
});
|
||||
|
||||
export const accounts = sqliteTable(
|
||||
'account',
|
||||
{
|
||||
userId: text('userId')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
type: text('type').$type<AdapterAccount['type']>().notNull(),
|
||||
provider: text('provider').notNull(),
|
||||
providerAccountId: text('providerAccountId').notNull(),
|
||||
refresh_token: text('refresh_token'),
|
||||
access_token: text('access_token'),
|
||||
expires_at: integer('expires_at'),
|
||||
token_type: text('token_type'),
|
||||
scope: text('scope'),
|
||||
id_token: text('id_token'),
|
||||
session_state: text('session_state'),
|
||||
},
|
||||
(account) => ({
|
||||
compoundKey: primaryKey(account.provider, account.providerAccountId),
|
||||
userIdIdx: index('userId_idx').on(account.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export const sessions = sqliteTable(
|
||||
'session',
|
||||
{
|
||||
sessionToken: text('sessionToken').notNull().primaryKey(),
|
||||
userId: text('userId')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
expires: integer('expires', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(session) => ({
|
||||
userIdIdx: index('user_id_idx').on(session.userId),
|
||||
})
|
||||
);
|
||||
|
||||
export const verificationTokens = sqliteTable(
|
||||
'verificationToken',
|
||||
{
|
||||
identifier: text('identifier').notNull(),
|
||||
token: text('token').notNull(),
|
||||
expires: integer('expires', { mode: 'timestamp_ms' }).notNull(),
|
||||
},
|
||||
(vt) => ({
|
||||
compoundKey: primaryKey(vt.identifier, vt.token),
|
||||
})
|
||||
);
|
||||
|
||||
const validColorScheme = ['environment', 'light', 'dark'] as const;
|
||||
type ValidColorScheme = (typeof validColorScheme)[number];
|
||||
const firstDaysOfWeek = ['monday', 'saturday', 'sunday'] as const;
|
||||
type ValidFirstDayOfWeek = (typeof firstDaysOfWeek)[number];
|
||||
|
||||
export const userSettings = sqliteTable('user_setting', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
userId: text('user_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
colorScheme: text('color_scheme').$type<ValidColorScheme>().notNull().default('environment'),
|
||||
language: text('language').notNull().default('en'),
|
||||
defaultBoard: text('default_board').notNull().default('default'),
|
||||
firstDayOfWeek: text('first_day_of_week')
|
||||
.$type<ValidFirstDayOfWeek>()
|
||||
.notNull()
|
||||
.default('monday'),
|
||||
searchTemplate: text('search_template').notNull().default('https://google.com/search?q=%s'),
|
||||
openSearchInNewTab: int('open_search_in_new_tab', { mode: 'boolean' }).notNull().default(true),
|
||||
disablePingPulse: int('disable_ping_pulse', { mode: 'boolean' }).notNull().default(false),
|
||||
replacePingWithIcons: int('replace_ping_with_icons', { mode: 'boolean' })
|
||||
.notNull()
|
||||
.default(false),
|
||||
useDebugLanguage: int('use_debug_language', { mode: 'boolean' }).notNull().default(false),
|
||||
autoFocusSearch: int('auto_focus_search', { mode: 'boolean' }).notNull().default(false),
|
||||
});
|
||||
|
||||
export type UserSettings = InferSelectModel<typeof userSettings>;
|
||||
|
||||
export const invites = sqliteTable('invite', {
|
||||
id: text('id').notNull().primaryKey(),
|
||||
token: text('token').notNull().unique(),
|
||||
expires: int('expires', {
|
||||
mode: 'timestamp',
|
||||
}).notNull(),
|
||||
createdById: text('created_by_id')
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: 'cascade' }),
|
||||
});
|
||||
|
||||
export type Invite = InferSelectModel<typeof invites>;
|
||||
|
||||
export const accountRelations = relations(accounts, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [accounts.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const userRelations = relations(users, ({ many, one }) => ({
|
||||
accounts: many(accounts),
|
||||
settings: one(userSettings),
|
||||
invites: many(invites),
|
||||
}));
|
||||
|
||||
export const userSettingRelations = relations(userSettings, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [userSettings.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const inviteRelations = relations(invites, ({ one }) => ({
|
||||
createdBy: one(users, {
|
||||
fields: [invites.createdById],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
@@ -3,11 +3,11 @@ import { Calendar } from '@mantine/dates';
|
||||
import { IconCalendarTime } from '@tabler/icons-react';
|
||||
import { useSession } from 'next-auth/react';
|
||||
import { useState } from 'react';
|
||||
import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { getLanguageByCode } from '~/tools/language';
|
||||
import { RouterOutputs, api } from '~/utils/api';
|
||||
|
||||
import { useEditModeStore } from '~/components/Dashboard/Views/useEditModeStore';
|
||||
import { useConfigContext } from '~/config/provider';
|
||||
import { defineWidget } from '../helper';
|
||||
import { IWidget } from '../widgets';
|
||||
import { CalendarDay } from './CalendarDay';
|
||||
@@ -33,22 +33,12 @@ const definition = defineWidget({
|
||||
radarrReleaseType: {
|
||||
type: 'select',
|
||||
defaultValue: 'inCinemas',
|
||||
data: [
|
||||
{ value: 'inCinemas' },
|
||||
{ value: 'physicalRelease' },
|
||||
{ value: 'digitalRelease' },
|
||||
],
|
||||
data: [{ value: 'inCinemas' }, { value: 'physicalRelease' }, { value: 'digitalRelease' }],
|
||||
},
|
||||
fontSize: {
|
||||
type: 'select',
|
||||
defaultValue: 'xs',
|
||||
data: [
|
||||
{ value: 'xs' },
|
||||
{ value: 'sm' },
|
||||
{ value: 'md' },
|
||||
{ value: 'lg' },
|
||||
{ value: 'xl' },
|
||||
],
|
||||
data: [{ value: 'xs' }, { value: 'sm' }, { value: 'md' }, { value: 'lg' }, { value: 'xl' }],
|
||||
},
|
||||
},
|
||||
gridstack: {
|
||||
@@ -83,7 +73,10 @@ function CalendarTile({ widget }: CalendarTileProps) {
|
||||
configName: configName!,
|
||||
month: month.getMonth() + 1,
|
||||
year: month.getFullYear(),
|
||||
options: { useSonarrv4: widget.properties.useSonarrv4, showUnmonitored: widget.properties.showUnmonitored },
|
||||
options: {
|
||||
useSonarrv4: widget.properties.useSonarrv4,
|
||||
showUnmonitored: widget.properties.showUnmonitored,
|
||||
},
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60 * 60 * 5,
|
||||
|
||||
Reference in New Issue
Block a user