⚗️ Add database board procedure

This commit is contained in:
Meier Lukas
2023-09-30 17:22:20 +02:00
parent 092a5b6241
commit df847b57f8
6 changed files with 846 additions and 41 deletions

View File

@@ -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;

View File

@@ -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<typeof getServerSideProps>) {
useInitConfig(initialConfig);
type BoardContextType = {
boardName: string;
layout?: string;
board: RouterOutputs['boards']['byName'];
};
const BoardContext = createContext<BoardContextType>(null!);
type BoardProviderProps = {
boardName: string;
layout?: string;
children: React.ReactNode;
};
const BoardProvider = ({ children, ...props }: BoardProviderProps) => {
const { data: board } = api.boards.byName.useQuery(props);
return (
<BoardLayout dockerEnabled={dockerEnabled}>
<Dashboard />
</BoardLayout>
<BoardContext.Provider
value={{
...props,
board: board!,
}}
>
{children}
</BoardContext.Provider>
);
};
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 <pre>{JSON.stringify(board, null, 2)}</pre>;
};
export default function BoardPage({
boardName,
dockerEnabled,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<BoardProvider boardName={boardName}>
<BoardLayout dockerEnabled={dockerEnabled}>
<Data />
<Dashboard />
</BoardLayout>
</BoardProvider>
);
}
type BoardGetServerSideProps = {
config: ConfigType;
boardName: string;
dockerEnabled: boolean;
_nextI18Next?: SSRConfig['_nextI18Next'];
};
@@ -43,25 +79,23 @@ export const getServerSideProps: GetServerSideProps<BoardGetServerSideProps> = 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,
},

View File

@@ -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<ReturnType<typeof getFullBoardWithLayoutSectionsAsync>>,
undefined
>;
type MapSection = FullBoardWithLayout['layouts'][number]['sections'][number];
type MapApp = Awaited<ReturnType<typeof getAppsForSectionsAsync>>[number];
type MapWidget = Awaited<ReturnType<typeof getWidgetsForSectionsAsync>>[number];
type MapSecret = Exclude<MapApp['app']['integration'], null>['secrets'][number];
type MapOption = MapWidget['options'][number];
const mapSection = (
section: Omit<MapSection, 'items'>,
items: (ReturnType<typeof mapWidget> | ReturnType<typeof mapApp>)[]
) => {
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<string, unknown>;
const sorted = options.sort((a, b) => a.path.localeCompare(b.path));
sorted.forEach((item) => {
addAtPath(result, item);
});
return result;
};
const addAtPath = (outerObj: Record<string, unknown>, 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;
}
};

178
src/server/db/items.ts Normal file
View File

@@ -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<string, IntegrationSecretDefinition>;
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<string, IntegrationTypeDefinition>;
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<TGroup extends IntegrationGroup> = {
[key in keyof typeof integrationTypes]: (typeof integrationTypes)[key] extends {
groups: TGroup[];
}
? key
: never;
}[keyof typeof integrationTypes];
export const integrationGroup = <TGroup extends IntegrationGroup>(group: TGroup) => {
return objectEntries(integrationTypes)
.filter(([, { groups }]) => groups.some((g) => group === g))
.map(([key]) => key) as InferIntegrationTypeFromGroup<TGroup>[];
};

View File

@@ -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<ValidColorScheme>().notNull().default('environment'),
colorScheme: text('color_scheme').$type<ColorScheme>().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'),
firstDayOfWeek: text('first_day_of_week').$type<FirstDayOfWeek>().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<typeof userSettings>;
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<typeof invites>;
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<IntegrationType>().notNull(),
url: text('url').notNull(),
});
export const integrationSecrets = sqliteTable(
'integration_secret',
{
key: text('key').$type<IntegrationSecretKey>().notNull(),
value: text('value'),
visibility: text('visibility').$type<IntegrationSecretVisibility>().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<WidgetType>().notNull(),
itemId: text('item_id').notNull(),
});
export const widgetOptions = sqliteTable(
'widget_option',
{
path: text('path').notNull(),
value: text('value'),
type: text('type').$type<WidgetOptionType>().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<AppNamePosition>().notNull().default('right'),
nameStyle: text('name_style').$type<AppNameStyle>().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<StatusCodeType>().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<SectionType>().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<typeof userSettings>;
export type Invite = InferSelectModel<typeof invites>;
export type Section = InferSelectModel<typeof sections>;

41
src/tools/object.ts Normal file
View File

@@ -0,0 +1,41 @@
export const objectKeys = <T extends object>(
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 = <T extends {}>(object: T): ReadonlyArray<Entry<T>> => {
return Object.entries(object) as unknown as ReadonlyArray<Entry<T>>;
};
type TupleEntry<
T extends readonly unknown[],
I extends unknown[] = [],
R = never,
> = T extends readonly [infer Head, ...infer Tail]
? TupleEntry<Tail, [...I, unknown], R | [`${I['length']}`, Head]>
: R;
type ObjectEntry<T extends {}> =
// eslint-disable-next-line @typescript-eslint/ban-types
T extends object
? { [K in keyof T]: [K, Required<T>[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 {}> = T extends readonly [unknown, ...unknown[]]
? TupleEntry<T>
: T extends ReadonlyArray<infer U>
? [`${number}`, U]
: ObjectEntry<T>;