diff --git a/drizzle.config.ts b/drizzle.config.ts index e6aa45846..2704fe59e 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -6,6 +6,6 @@ export default { driver: 'better-sqlite', out: './drizzle', dbCredentials: { - url: process.env.DATABASE_URL!, + url: process.env.DATABASE_URL ?? './database/db.sqlite', }, } satisfies Config; diff --git a/src/pages/board/index.tsx b/src/pages/board/index.tsx index 20f25295a..4a9c1223f 100644 --- a/src/pages/board/index.tsx +++ b/src/pages/board/index.tsx @@ -1,34 +1,70 @@ -import { eq } from 'drizzle-orm'; import { GetServerSideProps, InferGetServerSidePropsType } from 'next'; import { SSRConfig } from 'next-i18next'; +import { createContext, useContext } from 'react'; import { Dashboard } from '~/components/Dashboard/Dashboard'; import { BoardLayout } from '~/components/layout/Templates/BoardLayout'; -import { useInitConfig } from '~/config/init'; import { env } from '~/env'; +import { createTrpcServersideHelpers } from '~/server/api/helper'; import { getServerAuthSession } from '~/server/auth'; -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'; -import { ConfigType } from '~/types/config'; +import { RouterOutputs, api } from '~/utils/api'; -export default function BoardPage({ - config: initialConfig, - dockerEnabled, -}: InferGetServerSidePropsType) { - useInitConfig(initialConfig); +type BoardContextType = { + boardName: string; + layout?: string; + board: RouterOutputs['boards']['byName']; +}; +const BoardContext = createContext(null!); +type BoardProviderProps = { + boardName: string; + layout?: string; + children: React.ReactNode; +}; +const BoardProvider = ({ children, ...props }: BoardProviderProps) => { + const { data: board } = api.boards.byName.useQuery(props); return ( - - - + + {children} + + ); +}; + +const useBoard = () => { + const ctx = useContext(BoardContext); + if (!ctx) throw new Error('useBoard must be used within a BoardProvider'); + return ctx.board; +}; + +const Data = () => { + const board = useBoard(); + + return
{JSON.stringify(board, null, 2)}
; +}; + +export default function BoardPage({ + boardName, + dockerEnabled, +}: InferGetServerSidePropsType) { + return ( + + + + + + ); } type BoardGetServerSideProps = { - config: ConfigType; + boardName: string; dockerEnabled: boolean; _nextI18Next?: SSRConfig['_nextI18Next']; }; @@ -43,25 +79,23 @@ export const getServerSideProps: GetServerSideProps = a ctx.req, ctx.res ); - const config = await getFrontendConfig(boardName); - if (!config.settings.access.allowGuests && !session?.user) { + const helpers = await createTrpcServersideHelpers(ctx); + await helpers.boards.byName.prefetch({ boardName }); + const board = await helpers.boards.byNameSimple.fetch({ boardName }); + + if (!board.allowGuests && !session?.user) { return { notFound: true, - props: { - primaryColor: config.settings.customization.colors.primary, - secondaryColor: config.settings.customization.colors.secondary, - primaryShade: config.settings.customization.colors.shade, - }, }; } return { props: { - config, - primaryColor: config.settings.customization.colors.primary, - secondaryColor: config.settings.customization.colors.secondary, - primaryShade: config.settings.customization.colors.shade, + boardName, + primaryColor: board.primaryColor, + secondaryColor: board.secondaryColor, + primaryShade: board.primaryShade, dockerEnabled: !!env.DOCKER_HOST && !!env.DOCKER_PORT, ...translations, }, diff --git a/src/server/api/routers/board.ts b/src/server/api/routers/board.ts index ce244302d..f1dbd948d 100644 --- a/src/server/api/routers/board.ts +++ b/src/server/api/routers/board.ts @@ -1,13 +1,16 @@ import { TRPCError } from '@trpc/server'; +import { eq, inArray } from 'drizzle-orm'; import fs from 'fs'; import { z } from 'zod'; +import { db } from '~/server/db'; import { getDefaultBoardAsync } from '~/server/db/queries/userSettings'; +import { boards, layoutItems, layouts, sections } from '~/server/db/schema'; import { configExists } from '~/tools/config/configExists'; import { getConfig } from '~/tools/config/getConfig'; import { getFrontendConfig } from '~/tools/config/getFrontendConfig'; import { generateDefaultApp } from '~/tools/shared/app'; -import { adminProcedure, createTRPCRouter, protectedProcedure } from '../trpc'; +import { adminProcedure, createTRPCRouter, protectedProcedure, publicProcedure } from '../trpc'; import { configNameSchema } from './config'; export const boardRouter = createTRPCRouter({ @@ -82,4 +85,281 @@ export const boardRouter = createTRPCRouter({ const targetPath = `data/configs/${input.boardName}.json`; fs.writeFileSync(targetPath, JSON.stringify(newConfig, null, 2), 'utf8'); }), + byName: publicProcedure + .input(z.object({ boardName: configNameSchema, layout: z.string().optional() })) + .query(async ({ input }) => { + const board = await getFullBoardWithLayoutSectionsAsync( + input.boardName, + input.layout ?? 'default' + ); + + if (!board) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Board not found', + }); + } + + const layout = board.layouts.at(0); + if (!layout) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Layout not found', + }); + } + const sectionIds = layout.sections.map((x) => x.id); + const apps = await getAppsForSectionsAsync(sectionIds); + const widgets = await getWidgetsForSectionsAsync(sectionIds); + + const preparedSections = layout.sections.map((section) => { + const filteredApps = apps.filter((x) => + x.item.layouts.some((y) => y.sectionId === section.id) + ); + const filteredWidgets = widgets.filter((x) => + x.item.layouts.some((y) => y.sectionId === section.id) + ); + return mapSection(section, [ + ...filteredApps.map(mapApp), + ...filteredWidgets.map(mapWidget), + ]); + }); + const { layouts, ...withoutLayouts } = board; + return { + ...withoutLayouts, + sections: preparedSections, + }; + }), + byNameSimple: publicProcedure + .input(z.object({ boardName: configNameSchema })) + .query(async ({ input }) => { + const board = await db.query.boards.findFirst({ + where: eq(boards.name, input.boardName), + }); + + if (!board) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Board not found', + }); + } + + console.log(board); + return board; + }), }); + +const getAppsForSectionsAsync = async (sectionIds: string[]) => { + if (sectionIds.length === 0) return []; + return await db.query.appItems.findMany({ + with: { + app: { + with: { + integration: { + with: { + secrets: true, + }, + }, + statusCodes: { + columns: { + code: true, + }, + }, + }, + }, + item: { + with: { + layouts: { + where: inArray(layoutItems.sectionId, sectionIds), + }, + }, + }, + }, + }); +}; + +const getFullBoardWithLayoutSectionsAsync = async (boardName: string, layoutName: string) => { + return await db.query.boards.findFirst({ + columns: { + ownerId: false, + }, + with: { + owner: { + columns: { + id: true, + name: true, + }, + }, + layouts: { + columns: { + name: true, + }, + with: { + sections: { + orderBy: sections.position, + }, + }, + where: eq(layouts.name, layoutName), + }, + }, + where: eq(boards.name, boardName), + }); +}; + +const getWidgetsForSectionsAsync = async (sectionIds: string[]) => { + if (sectionIds.length === 0) return []; + return await db.query.widgets.findMany({ + with: { + options: true, + item: { + with: { + layouts: { + where: inArray(layoutItems.sectionId, sectionIds), + }, + }, + }, + }, + }); +}; + +type FullBoardWithLayout = Exclude< + Awaited>, + undefined +>; + +type MapSection = FullBoardWithLayout['layouts'][number]['sections'][number]; +type MapApp = Awaited>[number]; +type MapWidget = Awaited>[number]; +type MapSecret = Exclude['secrets'][number]; +type MapOption = MapWidget['options'][number]; + +const mapSection = ( + section: Omit, + items: (ReturnType | ReturnType)[] +) => { + const { layoutId, ...withoutLayoutId } = section; + if (section.type === 'empty') { + const { name, position, type, ...sectionProps } = withoutLayoutId; + return { + ...sectionProps, + type, + position: section.position!, + items, + }; + } + if (section.type === 'hidden') { + const { name, position, type, ...sectionProps } = withoutLayoutId; + return { + ...sectionProps, + type, + position: null, + items, + }; + } + if (section.type === 'category') { + const { name, position, type, ...sectionProps } = withoutLayoutId; + return { + ...sectionProps, + type, + name: name!, + position: section.position!, + items, + }; + } + + const { name, position, type, ...sectionProps } = withoutLayoutId; + + return { + ...sectionProps, + type, + position: section.position === 0 ? ('left' as const) : ('right' as const), + items, + }; +}; + +const mapWidget = (widgetItem: MapWidget) => { + const { sectionId, itemId, id, ...commonLayoutItem } = widgetItem.item.layouts.at(0)!; + const common = { ...commonLayoutItem, id: itemId }; + const { id: _id, itemId: _itemId, type, item, ...widget } = widgetItem; + return { + ...common, + ...widget, + type: 'widget' as const, + sort: type, + options: mapOptions(widget.options), + }; +}; + +const mapApp = (appItem: MapApp) => { + const { sectionId, itemId, id, ...commonLayoutItem } = appItem.item.layouts.at(0)!; + const common = { ...commonLayoutItem, id: itemId }; + const { app: innerApp, appId, itemId: _itemId, item, ...otherAppItem } = appItem; + const { id: _id, integration, statusCodes, ...app } = appItem.app!; + return { + ...common, + ...otherAppItem, + ...app, + type: 'app' as const, + integration: integration + ? { + ...integration, + secrets: integration.secrets.map(mapSecret), + } + : null, + statusCodes: statusCodes.map((x) => x.code), + }; +}; + +const mapSecret = ({ integrationId, ...secret }: MapSecret) => { + const isDefined = secret.value !== null && secret.value !== ''; + if (secret.visibility === 'private') { + return { + ...secret, + visibility: 'private' as const, + isDefined, + value: null, + }; + } + + return { + ...secret, + visibility: 'public' as const, + isDefined, + }; +}; + +const mapOptions = (options: MapOption[]) => { + const result = {} as Record; + const sorted = options.sort((a, b) => a.path.localeCompare(b.path)); + sorted.forEach((item) => { + addAtPath(result, item); + }); + return result; +}; + +const addAtPath = (outerObj: Record, item: MapOption) => { + const { path, value } = item; + const pathArray = path.split('.'); + const lastKey = pathArray.pop()!; + let current: any = outerObj; + pathArray.forEach((key) => { + if (Array.isArray(current)) { + current = current[parseInt(key, 10)]; + } else if (typeof current === 'object') { + current = current[key]; + } + }); + + if (item.type === 'array') { + current[lastKey] = []; + } else if (item.type === 'object') { + current[lastKey] = {}; + } else if (item.type === 'number' && value) { + current[lastKey] = value.includes('.') ? parseFloat(value) : parseInt(value, 10); + } else if (item.type === 'boolean') { + current[lastKey] = value === 'true'; + } else if (item.type === 'string') { + current[lastKey] = value; + } else if (item.type === 'null') { + current[lastKey] = null; + } +}; diff --git a/src/server/db/items.ts b/src/server/db/items.ts new file mode 100644 index 000000000..1a89746a3 --- /dev/null +++ b/src/server/db/items.ts @@ -0,0 +1,178 @@ +import { IconKey, IconPassword, IconUser, TablerIconsProps } from '@tabler/icons-react'; +import { objectEntries, objectKeys } from '~/tools/object'; +import widgets from '~/widgets'; + +type IntegrationTypeDefinition = { + secrets: IntegrationSecretKey[]; + iconUrl: string; + label: string; + groups: IntegrationGroup[]; +}; +type IntegrationGroup = + | 'mediaServer' + | 'mediaRequest' + | 'mediaApp' + | 'usenet' + | 'torrent' + | 'download' + | 'dns'; + +type IntegrationSecretDefinition = { + visibility: IntegrationSecretVisibility; + icon: (props: TablerIconsProps) => JSX.Element; +}; + +export const colorSchemes = ['environment', 'light', 'dark'] as const; +export const firstDaysOfWeek = ['monday', 'saturday', 'sunday'] as const; +export const integrationSecretVisibility = ['private', 'public'] as const; +export const integrationSecrets = { + apiKey: { + visibility: 'private', + icon: IconKey, + }, + username: { + visibility: 'public', + icon: IconUser, + }, + password: { + visibility: 'private', + icon: IconPassword, + }, +} satisfies Record; +export const widgetTypes = objectKeys(widgets); +export const widgetOptionTypes = [ + 'string', + 'number', + 'boolean', + 'object', + 'array', + 'null', +] as const; +export const appNamePosition = ['right', 'left', 'top', 'bottom'] as const; +export const appNameStyles = ['show', 'hide', 'hover'] as const; +export const statusCodeTypes = [ + 'information', + 'success', + 'redirect', + 'clientError', + 'serverError', +] as const; +export const sectionTypes = ['sidebar', 'empty', 'category', 'hidden'] as const; +export const integrationTypes = { + readarr: { + secrets: ['apiKey'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/readarr.png', + label: 'Readarr', + groups: ['mediaApp'], + }, + radarr: { + secrets: ['apiKey'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/radarr.png', + label: 'Radarr', + groups: ['mediaApp'], + }, + sonarr: { + secrets: ['apiKey'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sonarr.png', + label: 'Sonarr', + groups: ['mediaApp'], + }, + lidarr: { + secrets: ['apiKey'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/lidarr.png', + label: 'Lidarr', + groups: ['mediaApp'], + }, + sabnzbd: { + secrets: [], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/sabnzbd.png', + label: 'SABnzbd', + groups: ['usenet', 'download'], + }, + jellyseerr: { + secrets: ['apiKey'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyseerr.png', + label: 'Jellyseerr', + groups: ['mediaRequest'], + }, + overseerr: { + secrets: ['apiKey'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/overseerr.png', + label: 'Overseerr', + groups: ['mediaRequest'], + }, + deluge: { + secrets: ['password'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/deluge.png', + label: 'Deluge', + groups: ['torrent'], + }, + qBittorrent: { + secrets: ['username', 'password'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/qbittorrent.png', + label: 'qBittorrent', + groups: ['torrent'], + }, + transmission: { + secrets: ['username', 'password'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/transmission.png', + label: 'Transmission', + groups: ['torrent'], + }, + plex: { + secrets: ['apiKey'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/plex.png', + label: 'Plex', + groups: ['mediaServer'], + }, + jellyfin: { + secrets: ['username', 'password'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/jellyfin.png', + label: 'Jellyfin', + groups: ['mediaServer'], + }, + nzbGet: { + secrets: ['username', 'password'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/nzbget.png', + label: 'NZBGet', + groups: ['usenet', 'download'], + }, + pihole: { + secrets: ['apiKey'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/pi-hole.png', + label: 'PiHole', + groups: ['dns'], + }, + adGuardHome: { + secrets: ['username', 'password'], + iconUrl: 'https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/adguard-home.png', + label: 'AdGuard Home', + groups: ['dns'], + }, +} satisfies Record; + +export type ColorScheme = (typeof colorSchemes)[number]; +export type FirstDayOfWeek = (typeof firstDaysOfWeek)[number]; +export type IntegrationType = keyof typeof integrationTypes; +export type IntegrationSecretVisibility = (typeof integrationSecretVisibility)[number]; +export type IntegrationSecretKey = keyof typeof integrationSecrets; +export type WidgetType = (typeof widgetTypes)[number]; +export type WidgetOptionType = (typeof widgetOptionTypes)[number]; +export type AppNamePosition = (typeof appNamePosition)[number]; +export type AppNameStyle = (typeof appNameStyles)[number]; +export type StatusCodeType = (typeof statusCodeTypes)[number]; +export type SectionType = (typeof sectionTypes)[number]; + +type InferIntegrationTypeFromGroup = { + [key in keyof typeof integrationTypes]: (typeof integrationTypes)[key] extends { + groups: TGroup[]; + } + ? key + : never; +}[keyof typeof integrationTypes]; + +export const integrationGroup = (group: TGroup) => { + return objectEntries(integrationTypes) + .filter(([, { groups }]) => groups.some((g) => group === g)) + .map(([key]) => key) as InferIntegrationTypeFromGroup[]; +}; diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index 470f96a5e..5b4cc699c 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -2,6 +2,20 @@ 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'; +import { + AppNamePosition, + AppNameStyle, + ColorScheme, + FirstDayOfWeek, + IntegrationSecretKey, + IntegrationSecretVisibility, + IntegrationType, + SectionType, + StatusCodeType, + WidgetOptionType, + WidgetType, +} from './items'; + export const users = sqliteTable('user', { id: text('id').notNull().primaryKey(), name: text('name'), @@ -63,23 +77,15 @@ export const verificationTokens = sqliteTable( }) ); -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().notNull().default('environment'), + colorScheme: text('color_scheme').$type().notNull().default('environment'), language: text('language').notNull().default('en'), defaultBoard: text('default_board').notNull().default('default'), - firstDayOfWeek: text('first_day_of_week') - .$type() - .notNull() - .default('monday'), + firstDayOfWeek: text('first_day_of_week').$type().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), @@ -90,8 +96,6 @@ export const userSettings = sqliteTable('user_setting', { autoFocusSearch: int('auto_focus_search', { mode: 'boolean' }).notNull().default(false), }); -export type UserSettings = InferSelectModel; - export const invites = sqliteTable('invite', { id: text('id').notNull().primaryKey(), token: text('token').notNull().unique(), @@ -103,7 +107,270 @@ export const invites = sqliteTable('invite', { .references(() => users.id, { onDelete: 'cascade' }), }); -export type Invite = InferSelectModel; +export const boards = sqliteTable('board', { + // Common + id: text('id').notNull().primaryKey(), + name: text('name').notNull(), + + // Layout settings + isLeftSidebarVisible: int('is_left_sidebar_visible', { mode: 'boolean' }).default(false), + isRightSidebarVisible: int('is_right_sidebar_visible', { mode: 'boolean' }).default(false), + isPingEnabled: int('is_ping_enabled', { mode: 'boolean' }).default(false), + + // Access control + allowGuests: int('allow_guests', { mode: 'boolean' }).default(false), + + // Page metadata + pageTitle: text('page_title'), + metaTitle: text('meta_title'), + logoImageUrl: text('logo_image_url'), + faviconImageUrl: text('favicon_image_url'), + + // Appearance + backgroundImageUrl: text('background_image_url'), + primaryColor: text('primary_color'), + secondaryColor: text('secondary_color'), + primaryShade: text('primary_shade'), + appOpacity: int('app_opacity'), + customCss: text('custom_css'), + + // Other + ownerId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), +}); + +export const integrations = sqliteTable('integration', { + id: text('id').notNull().primaryKey(), + name: text('name').notNull(), + type: text('type').$type().notNull(), + url: text('url').notNull(), +}); + +export const integrationSecrets = sqliteTable( + 'integration_secret', + { + key: text('key').$type().notNull(), + value: text('value'), + visibility: text('visibility').$type().notNull(), + integrationId: text('integration_id') + .notNull() + .references(() => integrations.id, { onDelete: 'cascade' }), + }, + (secret) => ({ + compoundKey: primaryKey(secret.integrationId, secret.key), + }) +); + +export const widgets = sqliteTable('widget', { + id: text('id').notNull().primaryKey(), + type: text('type').$type().notNull(), + itemId: text('item_id').notNull(), +}); + +export const widgetOptions = sqliteTable( + 'widget_option', + { + path: text('path').notNull(), + value: text('value'), + type: text('type').$type().notNull(), + widgetId: text('widget_id') + .notNull() + .references(() => widgets.id, { onDelete: 'cascade' }), + }, + (widgetOption) => ({ + compoundKey: primaryKey(widgetOption.widgetId, widgetOption.path), + }) +); + +export const items = sqliteTable('item', { + id: text('id').notNull().primaryKey(), + type: text('type').$type<'app' | 'widget'>().notNull(), + boardId: text('board_id') + .notNull() + .references(() => boards.id, { onDelete: 'cascade' }), +}); + +export const apps = sqliteTable('app', { + id: text('id').notNull().primaryKey(), + name: text('name').notNull(), + description: text('description'), + internalUrl: text('internal_url').notNull(), + externalUrl: text('external_url'), + iconUrl: text('icon_url'), + integrationId: text('integration_id').references(() => integrations.id, { onDelete: 'cascade' }), +}); + +export const appItems = sqliteTable('app_item', { + openInNewTab: int('open_in_new_tab', { mode: 'boolean' }).notNull().default(false), + isPingEnabled: int('is_ping_enabled', { mode: 'boolean' }).notNull().default(false), + fontSize: int('font_size').notNull().default(16), + namePosition: text('name_position').$type().notNull().default('right'), + nameStyle: text('name_style').$type().notNull().default('show'), + nameLineClamp: int('name_line_clamp').notNull().default(1), + appId: text('app_id') + .notNull() + .references(() => apps.id, { onDelete: 'cascade' }), + itemId: text('item_id') + .notNull() + .references(() => items.id, { onDelete: 'cascade' }), +}); + +export const statusCodes = sqliteTable('status_code', { + code: int('code').notNull().primaryKey(), + group: text('group').$type().notNull(), +}); + +export const appStatusCodes = sqliteTable( + 'app_status_code', + { + appId: text('app_id') + .notNull() + .references(() => apps.id, { onDelete: 'cascade' }), + code: int('code') + .notNull() + .references(() => statusCodes.code, { onDelete: 'cascade' }), + }, + (appStatusCode) => ({ + compoundKey: primaryKey(appStatusCode.appId, appStatusCode.code), + }) +); + +export const layoutItems = sqliteTable('layout_item', { + id: text('id').notNull().primaryKey(), + sectionId: text('section_id').notNull(), + itemId: text('item_id').notNull(), + x: int('x').notNull(), + y: int('y').notNull(), + width: int('width').notNull(), + height: int('height').notNull(), +}); + +export const layouts = sqliteTable('layout', { + id: text('id').notNull().primaryKey(), + name: text('name').notNull(), + boardId: text('board_id') + .notNull() + .references(() => boards.id, { onDelete: 'cascade' }), +}); + +export const sections = sqliteTable('section', { + id: text('id').notNull().primaryKey(), + type: text('type').$type().notNull(), + position: int('position'), // number, right = 0, left = 1 + name: text('name'), + layoutId: text('layout_id') + .notNull() + .references(() => layouts.id, { onDelete: 'cascade' }), +}); + +export const sectionRelations = relations(sections, ({ one, many }) => ({ + layout: one(layouts, { + fields: [sections.layoutId], + references: [layouts.id], + }), + items: many(layoutItems), +})); + +export const layoutRelations = relations(layouts, ({ many, one }) => ({ + sections: many(sections), + board: one(boards, { + fields: [layouts.boardId], + references: [boards.id], + }), +})); + +export const layoutItemRelations = relations(layoutItems, ({ one }) => ({ + item: one(items, { + fields: [layoutItems.itemId], + references: [items.id], + }), + section: one(sections, { + fields: [layoutItems.sectionId], + references: [sections.id], + }), +})); + +export const statusCodeRelations = relations(statusCodes, ({ many }) => ({ + apps: many(appStatusCodes), +})); + +export const appStatusCodeRelations = relations(appStatusCodes, ({ one }) => ({ + statusCode: one(statusCodes, { + fields: [appStatusCodes.code], + references: [statusCodes.code], + }), + app: one(apps, { + fields: [appStatusCodes.appId], + references: [apps.id], + }), +})); + +export const widgetOptionRelations = relations(widgetOptions, ({ one }) => ({ + widget: one(widgets, { + fields: [widgetOptions.widgetId], + references: [widgets.id], + }), +})); + +export const widgetRelations = relations(widgets, ({ one, many }) => ({ + item: one(items, { + fields: [widgets.itemId], + references: [items.id], + }), + options: many(widgetOptions), +})); + +export const itemRelations = relations(items, ({ one, many }) => ({ + board: one(boards, { + fields: [items.boardId], + references: [boards.id], + }), + widget: one(widgets), + app: one(appItems), + layouts: many(layoutItems), +})); + +export const integrationRelations = relations(integrations, ({ many }) => ({ + secrets: many(integrationSecrets), + apps: many(apps), +})); + +export const appRelations = relations(apps, ({ one, many }) => ({ + integration: one(integrations, { + fields: [apps.integrationId], + references: [integrations.id], + }), + statusCodes: many(appStatusCodes), + items: many(appItems), +})); + +export const appItemRelations = relations(appItems, ({ one }) => ({ + app: one(apps, { + fields: [appItems.appId], + references: [apps.id], + }), + item: one(items, { + fields: [appItems.itemId], + references: [items.id], + }), +})); + +export const integrationSecretRelations = relations(integrationSecrets, ({ one }) => ({ + integration: one(integrations, { + fields: [integrationSecrets.integrationId], + references: [integrations.id], + }), +})); + +export const boardRelations = relations(boards, ({ one, many }) => ({ + owner: one(users, { + fields: [boards.ownerId], + references: [users.id], + }), + items: many(items), + layouts: many(layouts), +})); export const accountRelations = relations(accounts, ({ one }) => ({ user: one(users, { @@ -116,6 +383,7 @@ export const userRelations = relations(users, ({ many, one }) => ({ accounts: many(accounts), settings: one(userSettings), invites: many(invites), + boards: many(boards), })); export const userSettingRelations = relations(userSettings, ({ one }) => ({ @@ -131,3 +399,7 @@ export const inviteRelations = relations(invites, ({ one }) => ({ references: [users.id], }), })); + +export type UserSettings = InferSelectModel; +export type Invite = InferSelectModel; +export type Section = InferSelectModel; diff --git a/src/tools/object.ts b/src/tools/object.ts new file mode 100644 index 000000000..20384d0bf --- /dev/null +++ b/src/tools/object.ts @@ -0,0 +1,41 @@ +export const objectKeys = ( + obj: T +): (keyof T extends infer U + ? U extends string + ? U + : U extends number + ? `${U}` + : never + : never)[] => { + return Object.keys(obj) as any; +}; + +export const objectEntries = (object: T): ReadonlyArray> => { + return Object.entries(object) as unknown as ReadonlyArray>; +}; + +type TupleEntry< + T extends readonly unknown[], + I extends unknown[] = [], + R = never, +> = T extends readonly [infer Head, ...infer Tail] + ? TupleEntry + : R; + +type ObjectEntry = + // eslint-disable-next-line @typescript-eslint/ban-types + T extends object + ? { [K in keyof T]: [K, Required[K]] }[keyof T] extends infer E + ? E extends [infer K, infer V] + ? K extends string | number + ? [`${K}`, V] + : never + : never + : never + : never; + +export type Entry = T extends readonly [unknown, ...unknown[]] + ? TupleEntry + : T extends ReadonlyArray + ? [`${number}`, U] + : ObjectEntry;