mountPortalTarget(node, "preview")}
- className={advancedFocusClasses.surfaceHostAdvanced}
- />
+
)}
{isManual && activeFocus && advancedRect && closePosition && (
@@ -357,10 +394,7 @@ const LoadedBoardItemContent = ({
contentRef={setManualSurface}
onClose={() => close()}
>
-
mountPortalTarget(node, "manual")}
- className={advancedFocusClasses.manualSurfaceMount}
- />
+
)}
@@ -400,7 +434,7 @@ interface InnerContentProps {
Component: ComponentType
>;
}
-const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => {
+const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) {
const settings = useSettings();
const board = useRequiredBoard();
const [isEditMode] = useEditMode();
@@ -474,7 +508,7 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
)}
);
-};
+});
const Throw = ({ when, error }: { when: boolean; error: Error }) => {
if (when) throw error;
diff --git a/apps/nextjs/src/components/board/layout/README.md b/apps/nextjs/src/components/board/layout/README.md
index 8a12bc180..12ac371d0 100644
--- a/apps/nextjs/src/components/board/layout/README.md
+++ b/apps/nextjs/src/components/board/layout/README.md
@@ -5,12 +5,14 @@ The board uses fixed logical units:
- one `1 × 1` item is exactly `200 × 200` logical pixels;
- the gap is always `24` logical pixels, giving a `224` pixel track pitch;
- multi-cell items include the gaps between their cells (`2 × 1` is `424 × 200`);
-- the canvas is transformed once with one uniform scale derived from its available width.
+- the canvas is zoomed once with one uniform scale derived from its available width.
Items keep the same logical dimensions at every viewport. The viewport reserves
the painted size while the inner canvas remains at its logical dimensions, so a
widget's `clientWidth`, `clientHeight`, and `ResizeObserver` measurements stay
-stable.
+stable. The visual canvas always fits the available width, every root lane
+keeps at least one viewport of height, and edit-mode previews extend it downward
+as needed.
The read-only renderer should:
diff --git a/apps/nextjs/src/components/board/layout/constants.ts b/apps/nextjs/src/components/board/layout/constants.ts
index 9c3103b4b..26e3f0765 100644
--- a/apps/nextjs/src/components/board/layout/constants.ts
+++ b/apps/nextjs/src/components/board/layout/constants.ts
@@ -5,6 +5,5 @@
export const LOGICAL_GRID_CELL_SIZE = 200;
export const LOGICAL_GRID_GAP = 24;
export const LOGICAL_GRID_PITCH = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
-export const MIN_ACCESSIBLE_CANVAS_SCALE = 0.75;
export const COLLAPSED_SECTION_ROW_COUNT = 0.5;
diff --git a/apps/nextjs/src/components/board/layout/geometry.ts b/apps/nextjs/src/components/board/layout/geometry.ts
index 32b7fced4..449e74d4d 100644
--- a/apps/nextjs/src/components/board/layout/geometry.ts
+++ b/apps/nextjs/src/components/board/layout/geometry.ts
@@ -21,6 +21,13 @@ export const getLogicalItemStyle = ({ x, y, w, h }: GridCoordinates): CSSPropert
export const getLayoutRowCount = (placements: readonly GridCoordinates[]) =>
placements.reduce((rowCount, placement) => Math.max(rowCount, placement.y + placement.h), 0);
+export const getGridRowCountForVisualHeight = (visualHeight: number, visualScale: number) => {
+ if (!Number.isFinite(visualHeight) || visualHeight <= 0) return 1;
+ if (!Number.isFinite(visualScale) || visualScale <= 0) return 1;
+
+ return Math.max(1, Math.ceil((visualHeight / visualScale + LOGICAL_GRID_GAP) / LOGICAL_GRID_PITCH));
+};
+
export const normalizeGridPlacement = (
placement: TPlacement,
columnCount: number,
diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css
index 6c98e40a2..feb2a8ac5 100644
--- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css
+++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.module.css
@@ -2,9 +2,7 @@
width: 100%;
max-width: 100%;
min-width: 0;
- overflow: visible;
- overflow-x: auto;
- overscroll-behavior-inline: contain;
+ overflow: clip;
}
.viewport[data-board-hydrated="false"] {
@@ -18,8 +16,6 @@
.canvas {
position: absolute;
inset: 0 auto auto 0;
- transform-origin: top left;
- will-change: transform;
/*
* Keep dashboard UI at its normal readable density while the fixed canvas
@@ -44,9 +40,3 @@
--mantine-h5-font-size: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-h6-font-size: calc(0.875rem * var(--board-canvas-ui-scale, 1));
}
-
-@media (prefers-reduced-motion: reduce) {
- .canvas {
- will-change: auto;
- }
-}
diff --git a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx
index 3d5de2b72..b11356b93 100644
--- a/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx
+++ b/apps/nextjs/src/components/board/layout/scaled-board-canvas.tsx
@@ -5,11 +5,8 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { Box } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
-import { MIN_ACCESSIBLE_CANVAS_SCALE } from "./constants";
import classes from "./scaled-board-canvas.module.css";
-export { MIN_ACCESSIBLE_CANVAS_SCALE };
-
const BoardCanvasScaleContext = createContext(1);
export const useBoardCanvasScale = () => useContext(BoardCanvasScaleContext);
@@ -18,7 +15,7 @@ export const calculateBoardCanvasScale = (availableWidth: number, logicalWidth:
if (!Number.isFinite(availableWidth) || !Number.isFinite(logicalWidth)) return 1;
if (availableWidth <= 0 || logicalWidth <= 0) return 1;
- return Math.max(MIN_ACCESSIBLE_CANVAS_SCALE, availableWidth / logicalWidth);
+ return availableWidth / logicalWidth;
};
export const calculateBoardUiScale = (canvasScale: number) => {
@@ -36,9 +33,8 @@ interface ScaledBoardCanvasProps {
/**
* Scales the complete board as one surface while keeping widget layout in
- * fixed logical pixels. The scale floor deliberately allows horizontal
- * overflow so browser zoom still magnifies content instead of being cancelled
- * by responsive downscaling.
+ * fixed logical pixels. The complete canvas always fits its viewport so board
+ * content can never widen the document or require horizontal scrolling.
*/
export const ScaledBoardCanvas = ({
logicalWidth,
@@ -62,6 +58,7 @@ export const ScaledBoardCanvas = ({
[logicalWidth, resolvedAvailableWidth],
);
const uiScale = calculateBoardUiScale(scale);
+ const inverseScale = scale > 0 ? 1 / scale : 1;
const visualWidth = logicalWidth * scale;
const hasHorizontalOverflow = resolvedAvailableWidth > 0 && visualWidth - resolvedAvailableWidth > 0.5;
@@ -84,8 +81,9 @@ export const ScaledBoardCanvas = ({
className={classes.canvas}
w={logicalWidth}
style={{
+ "--board-canvas-inverse-scale": inverseScale,
"--board-canvas-ui-scale": uiScale,
- transform: `scale(${scale})`,
+ zoom: scale,
}}
>
{children}
diff --git a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts
index ecb537089..80ec9c8c3 100644
--- a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts
+++ b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts
@@ -16,6 +16,7 @@ describe("board lanes", () => {
leftGutterColumnCount: 2,
rightGutterColumnCount: 3,
breakpoint: 0,
+ role: "base",
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(2);
@@ -31,6 +32,7 @@ describe("board lanes", () => {
leftGutterColumnCount: 3,
rightGutterColumnCount: 3,
breakpoint: 0,
+ role: "base",
} satisfies Board["layouts"][number];
expect(getBoardLaneColumnCount(layout, "left")).toBe(3);
@@ -104,6 +106,7 @@ describe("board lanes", () => {
leftGutterColumnCount: 1,
rightGutterColumnCount: 0,
breakpoint: 0,
+ role: "base",
},
];
diff --git a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts
index 66f084703..3d46398bb 100644
--- a/apps/nextjs/src/components/board/layout/test/geometry.spec.ts
+++ b/apps/nextjs/src/components/board/layout/test/geometry.spec.ts
@@ -1,6 +1,12 @@
import { describe, expect, test } from "vitest";
-import { getLayoutRowCount, getLogicalItemStyle, getLogicalTrackSize, normalizeGridPlacement } from "../index";
+import {
+ getGridRowCountForVisualHeight,
+ getLayoutRowCount,
+ getLogicalItemStyle,
+ getLogicalTrackSize,
+ normalizeGridPlacement,
+} from "../index";
describe("fixed dashboard geometry", () => {
test("keeps a 1x1 item exactly 200px with a fixed 24px gap", () => {
@@ -24,4 +30,12 @@ describe("fixed dashboard geometry", () => {
expect(placement).toEqual({ id: "wide", x: 0, y: 0, w: 4, h: 2 });
expect(getLayoutRowCount([placement, { x: 0, y: 4, w: 1, h: 3 }])).toBe(7);
});
+
+ test("derives enough logical rows to fill the visual viewport at any canvas scale", () => {
+ expect(getGridRowCountForVisualHeight(1200, 1)).toBe(6);
+ expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 1))).toBeGreaterThanOrEqual(1200);
+
+ expect(getGridRowCountForVisualHeight(1200, 0.5)).toBe(11);
+ expect(getLogicalTrackSize(getGridRowCountForVisualHeight(1200, 0.5)) * 0.5).toBeGreaterThanOrEqual(1200);
+ });
});
diff --git a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts
index 83e14ea02..50fae3420 100644
--- a/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts
+++ b/apps/nextjs/src/components/board/layout/test/scaled-board-canvas.spec.ts
@@ -1,9 +1,9 @@
import { describe, expect, test } from "vitest";
-import { calculateBoardCanvasScale, calculateBoardUiScale, MIN_ACCESSIBLE_CANVAS_SCALE } from "../scaled-board-canvas";
+import { calculateBoardCanvasScale, calculateBoardUiScale } from "../scaled-board-canvas";
describe("board canvas scale policy", () => {
- test("fits the logical canvas exactly when the viewport can preserve the minimum scale", () => {
+ test("fits the logical canvas exactly", () => {
const scale = calculateBoardCanvasScale(840, 1000);
expect(scale).toBe(0.84);
@@ -14,16 +14,16 @@ describe("board canvas scale policy", () => {
expect(calculateBoardCanvasScale(1800, 1000)).toBe(1.8);
});
- test("stops counter-scaling at 0.75 and lets the canvas overflow", () => {
+ test("keeps fitting narrow viewports without horizontal overflow", () => {
const availableWidth = 600;
const logicalWidth = 1000;
const scale = calculateBoardCanvasScale(availableWidth, logicalWidth);
- expect(scale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
- expect(logicalWidth * scale).toBeGreaterThan(availableWidth);
+ expect(scale).toBe(0.6);
+ expect(logicalWidth * scale).toBe(availableWidth);
});
- test("keeps content materially magnified at 200% effective zoom", () => {
+ test("keeps the board fitted at 200% effective zoom", () => {
const logicalWidth = 1696;
const normalAvailableWidth = 1872;
const normalScale = calculateBoardCanvasScale(normalAvailableWidth, logicalWidth);
@@ -31,8 +31,8 @@ describe("board canvas scale policy", () => {
const normalPaintedCellSize = 200 * normalScale;
const zoomedPaintedCellSize = 200 * zoomedScale * 2;
- expect(zoomedScale).toBe(MIN_ACCESSIBLE_CANVAS_SCALE);
- expect(zoomedPaintedCellSize).toBeGreaterThan(normalPaintedCellSize * 1.25);
+ expect(zoomedScale).toBeCloseTo(normalScale / 2);
+ expect(zoomedPaintedCellSize).toBeCloseTo(normalPaintedCellSize);
});
test.each([
diff --git a/apps/nextjs/src/components/board/sections/empty-section.tsx b/apps/nextjs/src/components/board/sections/empty-section.tsx
index 5e4ef1d65..ab958e75f 100644
--- a/apps/nextjs/src/components/board/sections/empty-section.tsx
+++ b/apps/nextjs/src/components/board/sections/empty-section.tsx
@@ -18,7 +18,7 @@ export const BoardEmptySection = ({ section, columnCount, requestedRowCount, rai
const [isEditMode] = useEditMode();
const t = useScopedI18n("board.landmark");
- if (totalLength === 0 && !isEditMode && requestedRowCount === 0) return null;
+ if (totalLength === 0 && !isEditMode && requestedRowCount === 0 && railPlacement !== "main") return null;
return (
.board-grid-content-mount {
+ overflow: clip;
+}
+
.board-grid-placeholder {
z-index: 0;
background: color-mix(in srgb, var(--mantine-primary-color-filled) 16%, transparent);
@@ -178,7 +182,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
.board-grid-resize-handle[data-grid-resize-handle="s"] {
right: 44px;
left: 44px;
- height: 32px;
+ height: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
cursor: ns-resize;
}
@@ -194,7 +198,7 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
.board-grid-resize-handle[data-grid-resize-handle="w"] {
top: 44px;
bottom: 44px;
- width: 32px;
+ width: max(32px, calc(25px * var(--board-canvas-inverse-scale, 1)));
cursor: ew-resize;
}
@@ -217,8 +221,8 @@ body[data-board-grid-interacting="drag"] .board-grid-placeholder {
.board-grid-resize-handle[data-grid-resize-handle="sw"],
.board-grid-resize-handle[data-grid-resize-handle="nw"] {
z-index: 9;
- width: 44px;
- height: 44px;
+ width: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
+ height: max(44px, calc(25px * var(--board-canvas-inverse-scale, 1)));
}
.board-grid-resize-handle[data-grid-resize-handle="ne"] {
diff --git a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
index 192cbf7f9..d4d0782ab 100644
--- a/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/grid-editor.tsx
@@ -10,6 +10,7 @@ import { CollisionPriority } from "@dnd-kit/abstract";
import { pointerIntersection } from "@dnd-kit/collision";
import type { DragDropManager, Draggable as DomDraggable } from "@dnd-kit/dom";
import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
+import { RestrictToElement } from "@dnd-kit/dom/modifiers";
import { getEventCoordinates, getFrameTransform, isPointerEvent } from "@dnd-kit/dom/utilities";
import type {
BeforeDragStartEvent,
@@ -720,6 +721,7 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
if (!previewGrid || !interaction) return placements;
if (interaction.sourceGridId === sectionId) {
+ if (interaction.mode === "resize") return previewGrid.placements;
const original = controlledById.get(interaction.activeId);
if (!original) return previewGrid.placements;
return previewGrid.placements.some((placement) => placement.id === interaction.activeId)
@@ -734,9 +736,16 @@ export default function GridEditor({ sectionId, columnCount, rowCount, placement
);
const targetPlacement =
interaction?.valid && interaction.targetGridId === sectionId ? interaction.targetPlacement : null;
+ const growsDuringDrag =
+ interaction?.mode === "drag" &&
+ (interaction.targetGridId === sectionId ||
+ (interaction.targetGridId === null && interaction.sourceGridId === sectionId));
+ const participatesInInteraction =
+ interaction && (interaction.sourceGridId === sectionId || interaction.targetGridId === sectionId);
+ const previewRowCount = getLayoutRowCount(previewGrid?.placements ?? renderPlacements);
const renderedRowCount =
- section.kind !== "container" && interaction?.mode === "resize"
- ? Math.max(rowCount, getLayoutRowCount(renderPlacements))
+ section.kind !== "container" && participatesInInteraction
+ ? Math.max(rowCount, previewRowCount) + (growsDuringDrag ? 1 : 0)
: rowCount;
const { ref: droppableRef, isDropTarget } = useDroppable({
@@ -876,6 +885,14 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
],
[],
);
+ const modifiers = useMemo(
+ () => [
+ RestrictToElement.configure({
+ element: () => shellRef.current?.closest('[data-testid="board-canvas"]') ?? null,
+ }),
+ ],
+ [],
+ );
const {
ref: draggableRef,
handleRef,
@@ -886,6 +903,7 @@ const DndGridEntry = ({ sectionId, placement, label, columnCount, maxRowCount }:
type: GRID_ENTRY_TYPE,
data: sourceData,
sensors,
+ modifiers,
});
const setShellRef = useCallback(
(element: HTMLDivElement | null) => {
diff --git a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
index 3ab7b292b..ab1b1fb4b 100644
--- a/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
+++ b/apps/nextjs/src/components/board/sections/grid/section-grid.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useMemo } from "react";
+import { useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { Box, LoadingOverlay } from "@mantine/core";
import combineClasses from "clsx";
@@ -13,12 +13,14 @@ import type { ContainerSectionItem, Section } from "~/app/[locale]/boards/_types
import {
getCollapsedDisplayLayout,
getEditableCanvasAttributes,
+ getGridRowCountForVisualHeight,
getLayoutRowCount,
getLogicalItemStyle,
getLogicalTrackSize,
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
+import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { loadGridEditorAsync } from "./grid-editor-loader";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -81,6 +83,7 @@ export const SectionGrid = ({
className,
}: SectionGridProps) => {
const [isEditMode] = useEditMode();
+ const canvasScale = useBoardCanvasScale();
const editorRuntimeStatus = useGridEditorRuntimeStatus();
const board = useRequiredBoard();
const currentLayoutId = useCurrentLayout();
@@ -144,8 +147,9 @@ export const SectionGrid = ({
);
const displayedItems = items.map((item) => withPlacement(item, placementById.get(item.id)));
const displayedInnerSections = innerSections.map((item) => withPlacement(item, placementById.get(item.id)));
+ const minimumViewportRowCount = useMinimumViewportRowCount(section.kind === "empty", canvasScale);
const contentRowCount = Math.max(1, getLayoutRowCount(displayPlacements));
- const rowCount = Math.max(contentRowCount, requestedRowCount);
+ const rowCount = Math.max(contentRowCount, requestedRowCount, minimumViewportRowCount);
const logicalWidth = getLogicalTrackSize(columnCount);
const logicalHeight = getLogicalTrackSize(rowCount);
const canvasAttributes = isEditMode
@@ -199,6 +203,24 @@ export const SectionGrid = ({
);
};
+const useMinimumViewportRowCount = (enabled: boolean, canvasScale: number) => {
+ const [visualHeight, setVisualHeight] = useState(0);
+
+ useEffect(() => {
+ if (!enabled) return;
+ const update = () => setVisualHeight(window.visualViewport?.height ?? window.innerHeight);
+ update();
+ window.addEventListener("resize", update, { passive: true });
+ window.visualViewport?.addEventListener("resize", update, { passive: true });
+ return () => {
+ window.removeEventListener("resize", update);
+ window.visualViewport?.removeEventListener("resize", update);
+ };
+ }, [enabled]);
+
+ return enabled ? getGridRowCountForVisualHeight(visualHeight, canvasScale) : 0;
+};
+
const getContainerMinimumSize = (
board: ReturnType,
layoutId: string,
diff --git a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts
index 8491dfc17..6da6a4f2a 100644
--- a/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts
+++ b/apps/nextjs/src/components/board/sections/grid/test/grid-import-boundary.spec.ts
@@ -27,6 +27,7 @@ describe("board grid import boundary", () => {
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/abstract" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/collision" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom" },
+ { file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/modifiers" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/dom/utilities" },
{ file: "components/board/sections/grid/grid-editor.tsx", kind: "static", specifier: "@dnd-kit/react" },
]);
diff --git a/apps/nextjs/src/components/board/sections/section-actions.spec.ts b/apps/nextjs/src/components/board/sections/section-actions.spec.ts
new file mode 100644
index 000000000..441e71ef7
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/section-actions.spec.ts
@@ -0,0 +1,42 @@
+import { describe, expect, test } from "vitest";
+
+import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock";
+import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock";
+import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock";
+import { wouldCreateSectionCycle } from "./section-actions";
+
+describe("wouldCreateSectionCycle", () => {
+ test("rejects moving a container into itself or one of its descendants", () => {
+ const layoutId = "layout";
+ const rootSection = new EmptySectionMockBuilder({ id: "root" }).build();
+ const parentSection = new ContainerSectionMockBuilder({ id: "parent" })
+ .addLayout({ layoutId, parentSectionId: rootSection.id })
+ .build();
+ const childSection = new ContainerSectionMockBuilder({ id: "child" })
+ .addLayout({ layoutId, parentSectionId: parentSection.id })
+ .build();
+ const board = new BoardMockBuilder().addSections([rootSection, parentSection, childSection]).build();
+
+ expect(
+ wouldCreateSectionCycle(board, {
+ innerSectionId: parentSection.id,
+ sectionId: parentSection.id,
+ layoutId,
+ }),
+ ).toBe(true);
+ expect(
+ wouldCreateSectionCycle(board, {
+ innerSectionId: parentSection.id,
+ sectionId: childSection.id,
+ layoutId,
+ }),
+ ).toBe(true);
+ expect(
+ wouldCreateSectionCycle(board, {
+ innerSectionId: childSection.id,
+ sectionId: rootSection.id,
+ layoutId,
+ }),
+ ).toBe(false);
+ });
+});
diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx
index a978e712f..47813fc16 100644
--- a/apps/nextjs/src/components/board/sections/section-actions.tsx
+++ b/apps/nextjs/src/components/board/sections/section-actions.tsx
@@ -1,8 +1,10 @@
import { useCallback } from "react";
-import { getCurrentLayout } from "@homarr/boards/context";
+import { useCurrentLayout } from "@homarr/boards/context";
import { useUpdateBoard } from "@homarr/boards/updater";
+import type { Board } from "~/app/[locale]/boards/_types";
+
interface MoveAndResizeInnerSection {
innerSectionId: string;
xOffset: number;
@@ -21,6 +23,7 @@ interface MoveInnerSectionToSection {
export const useSectionActions = () => {
const { updateBoard } = useUpdateBoard();
+ const currentLayoutId = useCurrentLayout();
const moveAndResizeInnerSection = useCallback(
({ innerSectionId, ...positionProps }: MoveAndResizeInnerSection) => {
@@ -31,12 +34,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
- const currentLayout = getCurrentLayout(previous);
-
return {
...section,
layouts: section.layouts.map((layout) => {
- if (layout.layoutId !== currentLayout) return layout;
+ if (layout.layoutId !== currentLayoutId) return layout;
return {
...layout,
...positionProps,
@@ -46,12 +47,16 @@ export const useSectionActions = () => {
}),
}));
},
- [updateBoard],
+ [currentLayoutId, updateBoard],
);
const moveInnerSectionToSection = useCallback(
({ innerSectionId, sectionId, ...positionProps }: MoveInnerSectionToSection) => {
updateBoard((previous) => {
+ if (wouldCreateSectionCycle(previous, { innerSectionId, sectionId, layoutId: currentLayoutId })) {
+ return previous;
+ }
+
return {
...previous,
sections: previous.sections.map((section) => {
@@ -59,12 +64,10 @@ export const useSectionActions = () => {
if (section.id !== innerSectionId) return section;
if (section.kind !== "container") return section;
- const currentLayout = getCurrentLayout(previous);
-
return {
...section,
layouts: section.layouts.map((layout) => {
- if (layout.layoutId !== currentLayout) return layout;
+ if (layout.layoutId !== currentLayoutId) return layout;
return {
...layout,
...positionProps,
@@ -76,7 +79,7 @@ export const useSectionActions = () => {
};
});
},
- [updateBoard],
+ [currentLayoutId, updateBoard],
);
return {
@@ -84,3 +87,24 @@ export const useSectionActions = () => {
moveInnerSectionToSection,
};
};
+
+export const wouldCreateSectionCycle = (
+ board: Board,
+ { innerSectionId, sectionId, layoutId }: { innerSectionId: string; sectionId: string; layoutId: string },
+) => {
+ const visitedSectionIds = new Set();
+ let currentSectionId: string | null = sectionId;
+
+ while (currentSectionId) {
+ if (currentSectionId === innerSectionId) return true;
+ if (visitedSectionIds.has(currentSectionId)) return true;
+ visitedSectionIds.add(currentSectionId);
+
+ const currentSection = board.sections.find((section) => section.id === currentSectionId);
+ if (currentSection?.kind !== "container") return false;
+
+ currentSectionId = currentSection.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null;
+ }
+
+ return false;
+};
diff --git a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
index 89ab01c59..0c79f728f 100644
--- a/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
+++ b/apps/nextjs/src/components/workshop/workshop-css-import-button.tsx
@@ -4,6 +4,8 @@ import { useState } from "react";
import { Button, Modal } from "@mantine/core";
import { IconDownload } from "@tabler/icons-react";
+import { modalSizeSelect } from "@homarr/modals";
+
import { WorkshopBrowser } from "./workshop-browser";
export function WorkshopCssImportButton({ onImport }: { onImport(css: string): void }) {
@@ -14,7 +16,7 @@ export function WorkshopCssImportButton({ onImport }: { onImport(css: string): v
} onClick={() => setOpened(true)}>
Import from Workshop
- setOpened(false)} title="Import Custom CSS" size="xl">
+ setOpened(false)} title="Import Custom CSS" size={modalSizeSelect}>
{
diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts
index 9b2448b91..b970eec28 100644
--- a/e2e/board-advanced-interactions.spec.ts
+++ b/e2e/board-advanced-interactions.spec.ts
@@ -130,7 +130,11 @@ describe("Board advanced interactions", () => {
await interactivePreviewProbe.evaluate((element) => element.remove());
await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4);
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
+ await expect(previewSurface).toBeVisible();
+ await expect(bookmarksPreviewSurface).toBeHidden();
+ await page.mouse.move(4, 4);
await expect(previewSurface).toBeHidden();
+ await otherWidget.hover({ position: { x: 4, y: 4 } });
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
await page.keyboard.up("Shift");
await expect(bookmarksPreviewSurface).toBeHidden();
diff --git a/e2e/board-grid.spec.ts b/e2e/board-grid.spec.ts
index 47d734263..1be6bdb9b 100644
--- a/e2e/board-grid.spec.ts
+++ b/e2e/board-grid.spec.ts
@@ -6,11 +6,7 @@ import { createId } from "@paralleldrive/cuid2";
import { stringify as stringifySuperJSON } from "superjson";
import { describe, test } from "vitest";
-import {
- LOGICAL_GRID_CELL_SIZE,
- LOGICAL_GRID_GAP,
- MIN_ACCESSIBLE_CANVAS_SCALE,
-} from "../apps/nextjs/src/components/board/layout/constants";
+import { LOGICAL_GRID_CELL_SIZE, LOGICAL_GRID_GAP } from "../apps/nextjs/src/components/board/layout/constants";
import * as sqliteSchema from "../packages/db/schema/sqlite";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
@@ -26,7 +22,6 @@ const gridRuntimeMarker = "homarr-dnd-kit-v1";
const dndKitPayloadMarker = "dnd-kit-description";
const logicalCellSize = LOGICAL_GRID_CELL_SIZE;
const logicalCellPitch = LOGICAL_GRID_CELL_SIZE + LOGICAL_GRID_GAP;
-const minimumCanvasScale = MIN_ACCESSIBLE_CANVAS_SCALE;
describe("Board grid", () => {
test("supports fixed, nested, rail, pointer, keyboard and reconciliation workflows", async () => {
@@ -100,6 +95,11 @@ describe("Board grid", () => {
await expect(rail).toBeVisible();
await expect(rail).toHaveAttribute("data-board-gutter", "left");
await expect(railRootSection).toHaveAttribute("data-rail-placement", "left");
+ await expect(rail).toHaveCSS("overflow-x", "hidden");
+ const initialRailBox = await expectBoundingBoxAsync(rail);
+ const initialRailRootBox = await expectBoundingBoxAsync(railRootSection);
+ expect(initialRailRootBox.width).toBeCloseTo(initialRailBox.width, 1);
+ expect(initialRailRootBox.height).toBeGreaterThanOrEqual(initialRailBox.height - 1);
await expect(railItem).toHaveAttribute("data-grid-x", "0");
await expect(containerSection).toHaveAttribute("data-grid-w", "2");
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
@@ -174,21 +174,15 @@ describe("Board grid", () => {
expect(await readLayoutShiftScoreAsync(page)).toBeLessThan(0.01);
// CSS zoom exercises Chromium's browser-zoom layout path without relying
- // on desktop browser chrome. At 200% the scale floor must preserve
- // magnification and expose horizontal scrolling instead of shrinking the
- // board by the inverse zoom factor.
+ // on desktop browser chrome. The board must keep fitting without adding
+ // horizontal scrolling at any effective zoom.
await setDocumentZoomAsync(page, 2);
- await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale));
- await expect(canvas).toHaveAttribute("data-canvas-overflow", "true");
+ const zoomedCanvasScale = await readCanvasScaleAsync(canvas);
+ expect(zoomedCanvasScale).toBeLessThan(normalCanvasScale);
+ await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
await expectFixedLogicalTileAsync(logicalTile);
await expectUniformVisualScaleAsync(logicalTile);
- const fittedClockFontSize = await firstItem.locator(".clock-time-text").evaluate((element) => {
- return Number.parseFloat(getComputedStyle(element).fontSize);
- });
- expect(fittedClockFontSize * minimumCanvasScale).toBeGreaterThanOrEqual(25);
- const zoomedTileBox = await expectBoundingBoxAsync(logicalTile);
- expect(zoomedTileBox.width).toBeGreaterThan(normalZoomTileBox.width * 1.25);
- await expectHorizontalOverflowAsync(canvas);
+ await expect(canvas).toHaveCSS("overflow-x", "clip");
await expectDocumentNotHorizontallyScrollableAsync(page);
const overflowRailY = (await expectBoundingBoxAsync(rail)).y;
await page.evaluate(() => window.scrollTo({ top: 300, behavior: "auto" }));
@@ -251,6 +245,14 @@ describe("Board grid", () => {
await expect(page.locator(`[data-grid-runtime="${gridRuntimeMarker}"]`)).toHaveCount(3);
await expect(page.getByTestId("board-grid-editor-loading")).toHaveCount(0);
await expect(page.getByTestId("board-canvas-row-count-button")).toHaveCount(0);
+ await expect(rail).toHaveAttribute("data-board-editing", "true");
+ await expect(rail).toHaveCSS("position", "sticky");
+ await expect(rail).toHaveCSS("transform", "none");
+ const railBox = await expectBoundingBoxAsync(rail);
+ expect(railBox.height + railBox.y).toBeCloseTo(page.viewportSize()?.height ?? 0, 0);
+ expect(await rail.evaluate((element) => getComputedStyle(element, "::before").content.replaceAll('"', ""))).toBe(
+ "Left dashboard rail",
+ );
await expect(logicalTile).toHaveCSS("overflow-x", "hidden");
await expect(logicalTile).toHaveCSS("overflow-y", "auto");
await logicalTile.evaluate((element) => {
@@ -337,8 +339,11 @@ describe("Board grid", () => {
await expect(itemSelectDialog).toHaveCount(0);
const mainGrid = page.locator(`[data-grid-section-id="${fixture.sectionId}"]`);
+ const editorViewport = page.viewportSize();
+ if (!editorViewport) throw new Error("Expected a browser viewport");
+ expect((await expectBoundingBoxAsync(mainGrid)).height).toBeGreaterThanOrEqual(editorViewport.height - 1);
const interactionSnapshot = await readEditorGridSnapshotAsync(mainGrid);
- await expectGridBoundaryRejectsOverflowAsync(page, mainGrid, firstItem);
+ await expectGridGrowsAtBoundaryAsync(page, canvas, mainGrid, firstItem);
await firstItem.locator("[data-grid-item-content]").evaluate((element) => {
const iframe = document.createElement("iframe");
iframe.dataset.e2eDragIframe = "true";
@@ -367,8 +372,8 @@ describe("Board grid", () => {
.evaluateAll((elements) => elements.forEach((element) => element.remove()));
await page.setViewportSize({ width: 600, height: 1200 });
- await expect(canvas).toHaveAttribute("data-canvas-scale", String(minimumCanvasScale));
- await expectHorizontalOverflowAsync(canvas);
+ await expect(canvas).toHaveAttribute("data-canvas-overflow", "false");
+ await expect(canvas).toHaveCSS("overflow-x", "clip");
await expectDocumentNotHorizontallyScrollableAsync(page);
for (const direction of ["n", "ne", "e", "se", "s", "sw", "w", "nw"] as const) {
await expectTargetSizeAsync(firstItem.locator(`:scope > [data-grid-resize-handle="${direction}"]`));
@@ -718,13 +723,12 @@ describe("Board grid", () => {
expect(continuousResizeWidth).toBeGreaterThan(logicalCellSize * canvasScale);
await page.mouse.move(eastResizeStart.x - logicalCellPitch * canvasScale, eastResizeStart.y, { steps: 6 });
- await expect(containerSection).toHaveAttribute("data-grid-w", "2");
+ await expect(containerSection).toHaveAttribute("data-grid-w", "1");
await expect(resizePlaceholder).toHaveAttribute("data-grid-w", "1");
expect((await expectBoundingBoxAsync(resizeOutline)).width).toBeCloseTo(logicalCellSize * canvasScale, 1);
- const liveNestedGridBox = await expectBoundingBoxAsync(nestedEditor);
const liveContainerBox = await expectBoundingBoxAsync(containerSection);
- expect(liveNestedGridBox.width).toBeCloseTo(originalResizeWidth, 1);
- expect(liveNestedGridBox.width).toBeLessThanOrEqual(liveContainerBox.width + 1);
+ expect(liveContainerBox.width).toBeCloseTo(logicalCellSize * canvasScale, 1);
+ await expect(containerSection.locator(":scope > .board-grid-content-mount")).toHaveCSS("overflow", "clip");
await page.mouse.up();
await expect(containerSection).toHaveAttribute("data-grid-w", "1");
await expect(resizeOutline).toHaveCount(0);
@@ -759,15 +763,26 @@ describe("Board grid", () => {
await expect(containerSection).toHaveAttribute("data-grid-h", "2");
const southResizeHandle = containerSection.locator(':scope > [data-grid-resize-handle="s"]');
- await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale);
+ const gridHeightBeforeSouthResize = (await expectBoundingBoxAsync(mainGrid)).height;
+ const southResizeBox = await expectBoundingBoxAsync(southResizeHandle);
+ const southResizeStart = {
+ x: southResizeBox.x + southResizeBox.width / 2,
+ y: southResizeBox.y + southResizeBox.height / 2,
+ };
+ await page.mouse.move(southResizeStart.x, southResizeStart.y);
+ await page.mouse.down();
+ await page.mouse.move(southResizeStart.x, southResizeStart.y + logicalCellPitch * canvasScale, { steps: 8 });
await expect(containerSection).toHaveAttribute("data-grid-h", "3");
await expect(belowItem).toHaveAttribute("data-grid-y", "3");
+ await expect
+ .poll(async () => (await expectBoundingBoxAsync(mainGrid)).height)
+ .toBeGreaterThanOrEqual(gridHeightBeforeSouthResize);
+ await page.mouse.up();
expect(
gridLayoutsOverlap(await readGridLayoutAsync(containerSection), await readGridLayoutAsync(belowItem)),
).toBe(false);
- // Auto-scroll needs real document overflow. Grow the canvas through an
- // explicit resize instead of relying on edit mode to add empty rows.
+ // Grow the canvas further to exercise sustained document auto-scroll.
await dragLocatorByAsync(page, southResizeHandle, 0, logicalCellPitch * canvasScale * 6);
await expect(containerSection).toHaveAttribute("data-grid-h", "9");
await expect(belowItem).toHaveAttribute("data-grid-y", "9");
@@ -843,14 +858,10 @@ describe("Board grid", () => {
false,
);
- const stickyRailY = (await expectBoundingBoxAsync(rail)).y;
await page.evaluate(() => window.scrollTo({ top: 500, behavior: "auto" }));
- await expect
- .poll(async () => {
- const y = (await expectBoundingBoxAsync(rail)).y;
- return y >= 0 && y <= stickyRailY + 0.5;
- })
- .toBe(true);
+ const stickyRailY = (await expectBoundingBoxAsync(rail)).y;
+ await page.evaluate(() => window.scrollTo({ top: 600, behavior: "auto" }));
+ await expect.poll(async () => (await expectBoundingBoxAsync(rail)).y).toBeCloseTo(stickyRailY, 0);
await page.evaluate(() => window.scrollTo({ top: 0, behavior: "auto" }));
await setDocumentZoomAsync(page, 1.25);
@@ -979,6 +990,15 @@ describe("Board grid", () => {
const emptyRightGutter = page.getByRole("complementary", { name: "Right dashboard rail" });
await expect(emptyRightGutter).toHaveCount(1);
expect((await expectBoundingBoxAsync(emptyRightGutter)).width).toBeGreaterThan(0);
+ await page.getByTestId("board-edit-mode-toggle").click();
+ await expect(emptyRightGutter).toHaveAttribute("data-board-editing", "true");
+ expect(
+ await emptyRightGutter.evaluate((element) => ({
+ content: getComputedStyle(element, "::before").content.replaceAll('"', ""),
+ insetInlineStart: getComputedStyle(element, "::before").insetInlineStart,
+ top: getComputedStyle(element, "::before").top,
+ })),
+ ).toEqual({ content: "Right dashboard rail", insetInlineStart: "0px", top: "0px" });
expect(pageErrors).toEqual([]);
} finally {
runtimeResources?.stop();
@@ -1713,9 +1733,10 @@ const expectImmediateOutsideReleaseRestoresSnapshotAsync = async (page: Page, gr
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
};
-const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator, locator: Locator) => {
+const expectGridGrowsAtBoundaryAsync = async (page: Page, canvas: Locator, grid: Locator, locator: Locator) => {
const snapshot = await readEditorGridSnapshotAsync(grid);
const gridBox = await expectBoundingBoxAsync(grid);
+ const canvasBox = await expectBoundingBoxAsync(canvas);
const itemBox = await expectBoundingBoxAsync(locator);
const start = { x: itemBox.x + itemBox.width * 0.63, y: itemBox.y + itemBox.height * 0.42 };
@@ -1723,14 +1744,25 @@ const expectGridBoundaryRejectsOverflowAsync = async (page: Page, grid: Locator,
await page.mouse.down();
await page.mouse.move(start.x + 8, start.y);
await expect(locator).toHaveAttribute("data-dnd-drag-source", "true");
- await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch, { steps: 12 });
- await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(0);
- const activeGridBox = await expectBoundingBoxAsync(grid);
- expect(activeGridBox.height).toBeCloseTo(gridBox.height, 1);
+ await expect.poll(async () => (await expectBoundingBoxAsync(grid)).height).toBeGreaterThan(gridBox.height);
+ await page.mouse.move(gridBox.x + gridBox.width / 2, gridBox.y + gridBox.height + logicalCellPitch / 2, {
+ steps: 12,
+ });
+ await expect(page.locator("[data-grid-placeholder-for]")).toHaveCount(1);
+ const activeGridBox = await expectBoundingBoxAsync(grid);
+ expect(activeGridBox.height).toBeGreaterThan(gridBox.height);
+
+ await page.mouse.move(canvasBox.x + canvasBox.width + 1000, start.y, { steps: 12 });
+ const overlayBox = await expectBoundingBoxAsync(page.locator(".board-grid-drag-overlay[data-dnd-dragging]"));
+ expect(overlayBox.x + overlayBox.width).toBeLessThanOrEqual(canvasBox.x + canvasBox.width + 1);
+
+ await page.keyboard.press("Escape");
await page.mouse.up();
await expect(page.locator("body")).not.toHaveAttribute("data-board-grid-interacting");
await expect.poll(async () => await readEditorGridSnapshotAsync(grid)).toEqual(snapshot);
+ await locator.evaluate((element) => element.scrollIntoView({ block: "center", behavior: "instant" }));
+ await expectGridEntryGeometrySettledAsync(locator);
};
const expectInvalidNestedDropRestoresSnapshotAsync = async (
@@ -2036,18 +2068,6 @@ const expectUniformVisualScaleAsync = async (locator: Locator) => {
expect(scale.horizontal).toBeCloseTo(scale.vertical, 3);
};
-const expectHorizontalOverflowAsync = async (locator: Locator) => {
- const dimensions = await locator.evaluate((element) => {
- const htmlElement = element as HTMLElement;
- return {
- clientWidth: htmlElement.clientWidth,
- scrollWidth: htmlElement.scrollWidth,
- };
- });
-
- expect(dimensions.scrollWidth).toBeGreaterThan(dimensions.clientWidth);
-};
-
const expectDocumentNotHorizontallyScrollableAsync = async (page: Page) => {
await expect
.poll(
diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts
index 3053da760..9795dc937 100644
--- a/packages/api/src/router/board.ts
+++ b/packages/api/src/router/board.ts
@@ -2,13 +2,15 @@ import { TRPCError } from "@trpc/server";
import superjson from "superjson";
import { z } from "zod/v4";
-import { createLogger } from "@homarr/core/infrastructure/logs";
import { constructBoardPermissions } from "@homarr/auth/shared";
-import { createId } from "@homarr/common";
+import { createId, generateResponsiveGridFor } from "@homarr/common";
+import type { GridAlgorithmItem } from "@homarr/common";
import type { DeviceType } from "@homarr/common/server";
+import { createLogger } from "@homarr/core/infrastructure/logs";
import type { Database, InferInsertModel, InferSelectModel, SQL } from "@homarr/db";
import { and, asc, eq, handleTransactionsAsync, inArray, isNull, like, not, or, sql } from "@homarr/db";
import { createDbInsertCollectionWithoutTransaction } from "@homarr/db/collection";
+import { seedProtectedBoardLayoutsAsync } from "@homarr/db/migrations/seed";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import {
boardGroupPermissions,
@@ -38,6 +40,7 @@ import {
getRootSectionLane,
getPermissionsWithChildren,
getPermissionsWithParents,
+ normalizeBoardLayoutRoles,
rootSectionOffsets,
widgetDefaultSizes,
widgetKinds,
@@ -52,6 +55,7 @@ import {
boardCreateSchema,
boardDuplicateSchema,
boardRenameSchema,
+ boardResetLayoutSchema,
boardSaveLayoutsSchema,
boardSavePartialSettingsSchema,
boardSavePermissionsSchema,
@@ -69,7 +73,6 @@ import {
throwIfCustomWidgetBoardDuplicationForbidden,
throwIfCustomWidgetPlacementChangeForbidden,
} from "./board/custom-widget-placement-access";
-import { generateResponsiveGridFor } from "./board/grid-algorithm";
import { validateTimetableOptionsChangeAsync } from "./widgets/timetable";
export const boardRouter = createTRPCRouter({
@@ -322,15 +325,28 @@ export const boardRouter = createTRPCRouter({
yOffset: 0,
boardId,
});
- createBoardCollection.layouts.push({
- id: createId(),
- name: "Base",
- columnCount: input.columnCount,
- leftGutterColumnCount: 0,
- rightGutterColumnCount: 0,
- breakpoint: 0,
- boardId,
- });
+ createBoardCollection.layouts.push(
+ {
+ id: createId(),
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile",
+ boardId,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: input.columnCount,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 768,
+ role: "base",
+ boardId,
+ },
+ );
await createBoardCollection.insertAllAsync(ctx.db);
@@ -387,8 +403,65 @@ export const boardRouter = createTRPCRouter({
const newBoardId = createId();
- const layoutsMap = new Map(boardLayouts.map((layout) => [layout.id, createId()]));
- const layoutsToInsert = boardLayouts.map((layout) => ({
+ const generatedMobileLayoutId = createId();
+ const generatedMobilePositions =
+ boardLayouts.length === 1 && boardLayouts[0]
+ ? getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: boardLayouts[0].id,
+ columnCount: boardLayouts[0].columnCount,
+ leftGutterColumnCount: boardLayouts[0].leftGutterColumnCount,
+ rightGutterColumnCount: boardLayouts[0].rightGutterColumnCount,
+ },
+ current: {
+ layoutId: generatedMobileLayoutId,
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ },
+ })
+ : null;
+ const normalizedBoardLayouts =
+ boardLayouts.length === 0
+ ? [
+ {
+ id: generatedMobileLayoutId,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile" as const,
+ boardId: board.id,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: 10,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 768,
+ role: "base" as const,
+ boardId: board.id,
+ },
+ ]
+ : boardLayouts.length === 1 && boardLayouts[0]
+ ? [
+ {
+ id: generatedMobileLayoutId,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile" as const,
+ boardId: board.id,
+ },
+ { ...boardLayouts[0], breakpoint: 768, role: "base" as const },
+ ]
+ : normalizeBoardLayoutRoles(boardLayouts);
+ const layoutsMap = new Map(normalizedBoardLayouts.map((layout) => [layout.id, createId()]));
+ const layoutsToInsert = normalizedBoardLayouts.map((layout) => ({
...layout,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
id: layoutsMap.get(layout.id)!,
@@ -405,9 +478,24 @@ export const boardRouter = createTRPCRouter({
}),
);
- const sectionLayoutsToInsert: InferInsertModel[] = boardSections.flatMap((section) =>
- section.layouts.map(
- (layoutSection): InferInsertModel => ({
+ const sectionLayoutsToInsert: InferInsertModel[] = boardSections
+ .flatMap((section) =>
+ section.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ ...layoutSection,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ layoutId: layoutsMap.get(layoutSection.layoutId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(layoutSection.sectionId)!,
+ parentSectionId: layoutSection.parentSectionId
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionMap.get(layoutSection.parentSectionId)!
+ : layoutSection.parentSectionId,
+ }),
+ ),
+ )
+ .concat(
+ (generatedMobilePositions?.sectionLayouts ?? []).map((layoutSection) => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
@@ -416,10 +504,9 @@ export const boardRouter = createTRPCRouter({
parentSectionId: layoutSection.parentSectionId
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionMap.get(layoutSection.parentSectionId)!
- : layoutSection.parentSectionId,
- }),
- ),
- );
+ : null,
+ })),
+ );
const sectionCollapseStatesToInsert: InferInsertModel[] = boardSections.flatMap(
(section) =>
section.collapseStates.map(
@@ -441,9 +528,22 @@ export const boardRouter = createTRPCRouter({
}),
);
- const itemLayoutsToInsert: InferInsertModel[] = boardItems.flatMap((item) =>
- item.layouts.map(
- (layoutSection): InferInsertModel => ({
+ const itemLayoutsToInsert: InferInsertModel[] = boardItems
+ .flatMap((item) =>
+ item.layouts.map(
+ (layoutSection): InferInsertModel => ({
+ ...layoutSection,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ sectionId: sectionMap.get(layoutSection.sectionId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ itemId: itemMap.get(layoutSection.itemId)!,
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
+ layoutId: layoutsMap.get(layoutSection.layoutId)!,
+ }),
+ ),
+ )
+ .concat(
+ (generatedMobilePositions?.itemSectionLayouts ?? []).map((layoutSection) => ({
...layoutSection,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
sectionId: sectionMap.get(layoutSection.sectionId)!,
@@ -451,9 +551,8 @@ export const boardRouter = createTRPCRouter({
itemId: itemMap.get(layoutSection.itemId)!,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
layoutId: layoutsMap.get(layoutSection.layoutId)!,
- }),
- ),
- );
+ })),
+ );
// Creates a list with all integration ids the user has access to
const hasAccessForAll = ctx.session.user.permissions.includes("integration-use-all");
@@ -694,153 +793,338 @@ export const boardRouter = createTRPCRouter({
return await getFullBoardWithWhereAsync(ctx.db, boardWhere, ctx.session?.user.id ?? null);
}),
- saveLayouts: protectedProcedure.input(boardSaveLayoutsSchema).mutation(async ({ ctx, input }) => {
- await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
+ saveLayouts: protectedProcedure
+ .input(boardSaveLayoutsSchema)
+ .output(boardSaveLayoutsSchema.shape.layouts)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.id), "modify");
- const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
- input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
- );
- await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
+ const requiredGutterRoots = (["left", "right"] as const).filter((lane) =>
+ input.layouts.some((layout) => getBoardLaneColumnCount(layout, lane) > 0),
+ );
+ await ensureGutterRootSectionsAsync(ctx.db, input.id, requiredGutterRoots);
- const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
+ const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.id), ctx.session.user.id);
+ const existingLayoutsById = new Map(board.layouts.map((layout) => [layout.id, layout]));
+ const addedLayouts = filterAddedItems(input.layouts, board.layouts);
+ const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
- const addedLayouts = filterAddedItems(input.layouts, board.layouts);
+ if (addedLayouts.some((layout) => layout.role !== "custom")) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "New layouts must use the custom role" });
+ }
+ if (removedLayouts.some((layout) => layout.role !== "custom")) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Mobile and Base layouts cannot be removed" });
+ }
+ if (
+ input.layouts.some((layout) => {
+ const existingLayout = existingLayoutsById.get(layout.id);
+ return existingLayout && existingLayout.role !== layout.role;
+ })
+ ) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Layout roles cannot be changed" });
+ }
- const layoutsToInsert: InferInsertModel[] = [];
- const itemSectionLayoutsToInsert: InferInsertModel[] = [];
- const sectionLayoutsToInsert: InferInsertModel[] = [];
+ const baseLayout = board.layouts.find((layout) => layout.role === "base");
+ if (!baseLayout) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" });
+ }
- for (const addedLayout of addedLayouts) {
- const layoutId = createId();
+ const layoutsToInsert: InferInsertModel[] = [];
+ const itemSectionLayoutsToInsert: InferInsertModel[] = [];
+ const sectionLayoutsToInsert: InferInsertModel[] = [];
+ const savedLayoutIds = new Map();
+ const requestedBaseLayout = input.layouts.find((layout) => layout.id === baseLayout.id);
+ const resizedBaseLayout =
+ requestedBaseLayout &&
+ (requestedBaseLayout.columnCount !== baseLayout.columnCount ||
+ requestedBaseLayout.leftGutterColumnCount !== baseLayout.leftGutterColumnCount ||
+ requestedBaseLayout.rightGutterColumnCount !== baseLayout.rightGutterColumnCount)
+ ? getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: baseLayout.id,
+ columnCount: baseLayout.columnCount,
+ leftGutterColumnCount: baseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: baseLayout.rightGutterColumnCount,
+ },
+ current: {
+ layoutId: baseLayout.id,
+ columnCount: requestedBaseLayout.columnCount,
+ leftGutterColumnCount: requestedBaseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: requestedBaseLayout.rightGutterColumnCount,
+ },
+ })
+ : null;
+ const baseSourceElements = resizedBaseLayout ? getElementsForProjectedLayout(resizedBaseLayout) : undefined;
+ const baseSourceGeometry = {
+ layoutId: baseLayout.id,
+ columnCount: requestedBaseLayout?.columnCount ?? baseLayout.columnCount,
+ leftGutterColumnCount: requestedBaseLayout?.leftGutterColumnCount ?? baseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: requestedBaseLayout?.rightGutterColumnCount ?? baseLayout.rightGutterColumnCount,
+ };
- const sortedLayouts = board.layouts.toSorted((layoutA, layoutB) => layoutA.columnCount - layoutB.columnCount);
- // Fallback to biggest if none exists with columnCount bigger than addedLayout.columnCount
- const layoutToClone =
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- sortedLayouts.find((layout) => layout.columnCount >= addedLayout.columnCount) ?? sortedLayouts.at(-1)!;
-
- const updatedBoardLayout = getUpdatedBoardLayout(board, {
- previous: {
- layoutId: layoutToClone.id,
- columnCount: layoutToClone.columnCount,
- leftGutterColumnCount: layoutToClone.leftGutterColumnCount,
- rightGutterColumnCount: layoutToClone.rightGutterColumnCount,
- },
- current: {
- layoutId,
+ for (const addedLayout of addedLayouts) {
+ const layoutId = createId();
+ savedLayoutIds.set(addedLayout.id, layoutId);
+ layoutsToInsert.push({
+ id: layoutId,
+ name: addedLayout.name,
columnCount: addedLayout.columnCount,
leftGutterColumnCount: addedLayout.leftGutterColumnCount,
rightGutterColumnCount: addedLayout.rightGutterColumnCount,
+ breakpoint: addedLayout.breakpoint,
+ role: "custom",
+ boardId: board.id,
+ });
+
+ const projectedLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ ...baseSourceGeometry,
+ elements: baseSourceElements,
+ },
+ current: {
+ layoutId,
+ columnCount: addedLayout.columnCount,
+ leftGutterColumnCount: addedLayout.leftGutterColumnCount,
+ rightGutterColumnCount: addedLayout.rightGutterColumnCount,
+ },
+ });
+ itemSectionLayoutsToInsert.push(...projectedLayout.itemSectionLayouts);
+ sectionLayoutsToInsert.push(...projectedLayout.sectionLayouts);
+ }
+
+ const itemSectionLayoutsToUpdate: InferInsertModel[] = [];
+ const sectionLayoutsToUpdate: InferInsertModel[] = [];
+ const layoutsToUpdate = filterUpdatedItems(input.layouts, board.layouts);
+
+ for (const updatedLayout of layoutsToUpdate) {
+ const dbLayout = existingLayoutsById.get(updatedLayout.id);
+ if (
+ !dbLayout ||
+ (dbLayout.columnCount === updatedLayout.columnCount &&
+ dbLayout.leftGutterColumnCount === updatedLayout.leftGutterColumnCount &&
+ dbLayout.rightGutterColumnCount === updatedLayout.rightGutterColumnCount)
+ )
+ continue;
+
+ const projectedLayout =
+ updatedLayout.role === "base" && resizedBaseLayout
+ ? resizedBaseLayout
+ : getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: dbLayout.id,
+ columnCount: dbLayout.columnCount,
+ leftGutterColumnCount: dbLayout.leftGutterColumnCount,
+ rightGutterColumnCount: dbLayout.rightGutterColumnCount,
+ },
+ current: {
+ layoutId: dbLayout.id,
+ columnCount: updatedLayout.columnCount,
+ leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
+ rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
+ },
+ });
+ itemSectionLayoutsToUpdate.push(...projectedLayout.itemSectionLayouts);
+ sectionLayoutsToUpdate.push(...projectedLayout.sectionLayouts);
+ }
+
+ const removedLayoutIds = removedLayouts.map((layout) => layout.id);
+
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ if (layoutsToInsert.length > 0) await transaction.insert(schema.layouts).values(layoutsToInsert);
+ if (itemSectionLayoutsToInsert.length > 0) {
+ await transaction.insert(schema.itemLayouts).values(itemSectionLayoutsToInsert);
+ }
+ if (sectionLayoutsToInsert.length > 0) {
+ await transaction.insert(schema.sectionLayouts).values(sectionLayoutsToInsert);
+ }
+
+ for (const itemLayout of itemSectionLayoutsToUpdate) {
+ await transaction
+ .update(schema.itemLayouts)
+ .set({
+ height: itemLayout.height,
+ width: itemLayout.width,
+ xOffset: itemLayout.xOffset,
+ yOffset: itemLayout.yOffset,
+ sectionId: itemLayout.sectionId,
+ })
+ .where(
+ and(
+ eq(schema.itemLayouts.itemId, itemLayout.itemId),
+ eq(schema.itemLayouts.layoutId, itemLayout.layoutId),
+ ),
+ );
+ }
+ for (const sectionLayout of sectionLayoutsToUpdate) {
+ await transaction
+ .update(schema.sectionLayouts)
+ .set({
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ parentSectionId: sectionLayout.parentSectionId,
+ })
+ .where(
+ and(
+ eq(schema.sectionLayouts.sectionId, sectionLayout.sectionId),
+ eq(schema.sectionLayouts.layoutId, sectionLayout.layoutId),
+ ),
+ );
+ }
+ for (const layout of layoutsToUpdate) {
+ await transaction
+ .update(schema.layouts)
+ .set({
+ name: layout.name,
+ columnCount: layout.columnCount,
+ leftGutterColumnCount: layout.leftGutterColumnCount,
+ rightGutterColumnCount: layout.rightGutterColumnCount,
+ breakpoint: layout.breakpoint,
+ })
+ .where(eq(schema.layouts.id, layout.id));
+ }
+ if (removedLayoutIds.length > 0) {
+ await transaction.delete(schema.layouts).where(inArray(schema.layouts.id, removedLayoutIds));
+ }
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ if (layoutsToInsert.length > 0) transaction.insert(layouts).values(layoutsToInsert).run();
+ if (itemSectionLayoutsToInsert.length > 0) {
+ transaction.insert(itemLayouts).values(itemSectionLayoutsToInsert).run();
+ }
+ if (sectionLayoutsToInsert.length > 0) {
+ transaction.insert(sectionLayouts).values(sectionLayoutsToInsert).run();
+ }
+
+ for (const itemLayout of itemSectionLayoutsToUpdate) {
+ transaction
+ .update(itemLayouts)
+ .set({
+ height: itemLayout.height,
+ width: itemLayout.width,
+ xOffset: itemLayout.xOffset,
+ yOffset: itemLayout.yOffset,
+ sectionId: itemLayout.sectionId,
+ })
+ .where(and(eq(itemLayouts.itemId, itemLayout.itemId), eq(itemLayouts.layoutId, itemLayout.layoutId)))
+ .run();
+ }
+ for (const sectionLayout of sectionLayoutsToUpdate) {
+ transaction
+ .update(sectionLayouts)
+ .set({
+ height: sectionLayout.height,
+ width: sectionLayout.width,
+ xOffset: sectionLayout.xOffset,
+ yOffset: sectionLayout.yOffset,
+ parentSectionId: sectionLayout.parentSectionId,
+ })
+ .where(
+ and(
+ eq(sectionLayouts.sectionId, sectionLayout.sectionId),
+ eq(sectionLayouts.layoutId, sectionLayout.layoutId),
+ ),
+ )
+ .run();
+ }
+ for (const layout of layoutsToUpdate) {
+ transaction
+ .update(layouts)
+ .set({
+ name: layout.name,
+ columnCount: layout.columnCount,
+ leftGutterColumnCount: layout.leftGutterColumnCount,
+ rightGutterColumnCount: layout.rightGutterColumnCount,
+ breakpoint: layout.breakpoint,
+ })
+ .where(eq(layouts.id, layout.id))
+ .run();
+ }
+ if (removedLayoutIds.length > 0)
+ transaction.delete(layouts).where(inArray(layouts.id, removedLayoutIds)).run();
+ });
},
});
- layoutsToInsert.push({
- id: layoutId,
- name: addedLayout.name,
- columnCount: addedLayout.columnCount,
- leftGutterColumnCount: addedLayout.leftGutterColumnCount,
- rightGutterColumnCount: addedLayout.rightGutterColumnCount,
- breakpoint: addedLayout.breakpoint,
- boardId: board.id,
- });
+ return input.layouts
+ .map((layout) => ({
+ ...layout,
+ id: savedLayoutIds.get(layout.id) ?? layout.id,
+ role: existingLayoutsById.get(layout.id)?.role ?? "custom",
+ }))
+ .toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint);
+ }),
+ resetLayout: protectedProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Reset a board's Mobile or custom layout from its Base layout while preserving the target layout settings. Requires modify permission. REQUIRED: boardId (board ID), layoutId (non-Base layout ID)",
+ },
+ })
+ .input(boardResetLayoutSchema)
+ .mutation(async ({ ctx, input }) => {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
- itemSectionLayoutsToInsert.push(...updatedBoardLayout.itemSectionLayouts);
- sectionLayoutsToInsert.push(...updatedBoardLayout.sectionLayouts);
- }
+ const board = await getFullBoardWithWhereAsync(ctx.db, eq(boards.id, input.boardId), ctx.session.user.id);
+ const targetLayout = board.layouts.find((layout) => layout.id === input.layoutId);
+ const baseLayout = board.layouts.find((layout) => layout.role === "base");
- if (layoutsToInsert.length > 0) {
- await ctx.db.insert(layouts).values(layoutsToInsert);
- }
-
- if (itemSectionLayoutsToInsert.length > 0) {
- await ctx.db.insert(itemLayouts).values(itemSectionLayoutsToInsert);
- }
-
- if (sectionLayoutsToInsert.length > 0) {
- await ctx.db.insert(sectionLayouts).values(sectionLayoutsToInsert);
- }
-
- const updatedLayouts = filterUpdatedItems(input.layouts, board.layouts);
- for (const updatedLayout of updatedLayouts) {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const dbLayout = board.layouts.find((layout) => layout.id === updatedLayout.id)!;
- let updatedBoardLayout: ReturnType | undefined;
-
- if (
- dbLayout.columnCount !== updatedLayout.columnCount ||
- dbLayout.leftGutterColumnCount !== updatedLayout.leftGutterColumnCount ||
- dbLayout.rightGutterColumnCount !== updatedLayout.rightGutterColumnCount
- ) {
- updatedBoardLayout = getUpdatedBoardLayout(board, {
- previous: {
- layoutId: dbLayout.id,
- columnCount: dbLayout.columnCount,
- leftGutterColumnCount: dbLayout.leftGutterColumnCount,
- rightGutterColumnCount: dbLayout.rightGutterColumnCount,
- },
- current: {
- layoutId: dbLayout.id,
- columnCount: updatedLayout.columnCount,
- leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
- rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
- },
- });
-
- for (const itemSectionLayout of updatedBoardLayout.itemSectionLayouts) {
- await ctx.db
- .update(itemLayouts)
- .set({
- height: itemSectionLayout.height,
- width: itemSectionLayout.width,
- xOffset: itemSectionLayout.xOffset,
- yOffset: itemSectionLayout.yOffset,
- sectionId: itemSectionLayout.sectionId,
- })
- .where(
- and(
- eq(itemLayouts.itemId, itemSectionLayout.itemId),
- eq(itemLayouts.layoutId, itemSectionLayout.layoutId),
- ),
- );
- }
-
- for (const sectionLayout of updatedBoardLayout.sectionLayouts) {
- await ctx.db
- .update(sectionLayouts)
- .set({
- height: sectionLayout.height,
- width: sectionLayout.width,
- xOffset: sectionLayout.xOffset,
- yOffset: sectionLayout.yOffset,
- parentSectionId: sectionLayout.parentSectionId,
- })
- .where(
- and(
- eq(sectionLayouts.sectionId, sectionLayout.sectionId),
- eq(sectionLayouts.layoutId, sectionLayout.layoutId),
- ),
- );
- }
+ if (!targetLayout) throw new TRPCError({ code: "NOT_FOUND", message: "Layout not found" });
+ if (!baseLayout) {
+ throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Board must have a Base layout" });
+ }
+ if (targetLayout.role === "base") {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "The Base layout cannot be reset" });
}
- await ctx.db
- .update(layouts)
- .set({
- name: updatedLayout.name,
- columnCount: updatedLayout.columnCount,
- leftGutterColumnCount: updatedLayout.leftGutterColumnCount,
- rightGutterColumnCount: updatedLayout.rightGutterColumnCount,
- breakpoint: updatedLayout.breakpoint,
- })
- .where(eq(layouts.id, updatedLayout.id));
- }
+ const projectedLayout = getUpdatedBoardLayout(board, {
+ previous: {
+ layoutId: baseLayout.id,
+ columnCount: baseLayout.columnCount,
+ leftGutterColumnCount: baseLayout.leftGutterColumnCount,
+ rightGutterColumnCount: baseLayout.rightGutterColumnCount,
+ },
+ current: {
+ layoutId: targetLayout.id,
+ columnCount: targetLayout.columnCount,
+ leftGutterColumnCount: targetLayout.leftGutterColumnCount,
+ rightGutterColumnCount: targetLayout.rightGutterColumnCount,
+ },
+ });
- const removedLayouts = filterRemovedItems(input.layouts, board.layouts);
- const removedLayoutIds = removedLayouts.map((layout) => layout.id);
- if (removedLayoutIds.length > 0) {
- await ctx.db.delete(layouts).where(inArray(layouts.id, removedLayoutIds));
- }
- }),
+ await handleTransactionsAsync(ctx.db, {
+ async handleAsync(db, schema) {
+ await db.transaction(async (transaction) => {
+ await transaction.delete(schema.itemLayouts).where(eq(schema.itemLayouts.layoutId, targetLayout.id));
+ await transaction.delete(schema.sectionLayouts).where(eq(schema.sectionLayouts.layoutId, targetLayout.id));
+ if (projectedLayout.itemSectionLayouts.length > 0) {
+ await transaction.insert(schema.itemLayouts).values(projectedLayout.itemSectionLayouts);
+ }
+ if (projectedLayout.sectionLayouts.length > 0) {
+ await transaction.insert(schema.sectionLayouts).values(projectedLayout.sectionLayouts);
+ }
+ });
+ },
+ handleSync(db) {
+ db.transaction((transaction) => {
+ transaction.delete(itemLayouts).where(eq(itemLayouts.layoutId, targetLayout.id)).run();
+ transaction.delete(sectionLayouts).where(eq(sectionLayouts.layoutId, targetLayout.id)).run();
+ if (projectedLayout.itemSectionLayouts.length > 0) {
+ transaction.insert(itemLayouts).values(projectedLayout.itemSectionLayouts).run();
+ }
+ if (projectedLayout.sectionLayouts.length > 0) {
+ transaction.insert(sectionLayouts).values(projectedLayout.sectionLayouts).run();
+ }
+ });
+ },
+ });
+ }),
savePartialBoardSettings: protectedProcedure
.meta({
openapi: { method: "PATCH", path: "/api/boards/{id}/settings", tags: ["boards"], protect: true },
@@ -1556,13 +1840,14 @@ export const boardRouter = createTRPCRouter({
}
const defaultSize = widgetDefaultSizes[input.kind as WidgetKind] ?? { width: 1, height: 1 };
+ const sizeForLayout = { ...defaultSize, width: Math.min(layout.columnCount, defaultSize.width) };
const fitsAt = (x: number, y: number) => {
- if (x + defaultSize.width > layout.columnCount) return false;
- for (let dy = 0; dy < defaultSize.height; dy++) {
+ if (x + sizeForLayout.width > layout.columnCount) return false;
+ for (let dy = 0; dy < sizeForLayout.height; dy++) {
const row = occupied[y + dy];
if (!row) continue;
- for (let dx = 0; dx < defaultSize.width; dx++) {
+ for (let dx = 0; dx < sizeForLayout.width; dx++) {
if (row[x + dx]) return false;
}
}
@@ -1580,8 +1865,8 @@ export const boardRouter = createTRPCRouter({
layoutId: layout.id,
xOffset: x,
yOffset: y,
- width: defaultSize.width,
- height: defaultSize.height,
+ width: sizeForLayout.width,
+ height: sizeForLayout.height,
});
placed = true;
}
@@ -1691,14 +1976,43 @@ const noBoardWithSimilarNameAsync = async (db: Database, name: string, ignoredId
}
};
+interface BoardForLayoutProjection {
+ id: string;
+ items: Array<{
+ id: string;
+ layouts: Array<{
+ layoutId: string;
+ sectionId: string;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+ sections: Array<{
+ id: string;
+ kind: string;
+ xOffset?: number | null;
+ yOffset?: number | null;
+ layouts?: Array<{
+ layoutId: string;
+ parentSectionId: string | null;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+}
+
const getUpdatedBoardLayout = (
- board: Awaited>,
+ board: BoardForLayoutProjection,
options: {
- previous: BoardLayoutGeometry;
+ previous: BoardLayoutGeometry & { elements?: GridAlgorithmItem[] };
current: BoardLayoutGeometry;
},
) => {
- const elements = getElementsForLayout(board, options.previous.layoutId);
+ const elements = options.previous.elements ?? getElementsForLayout(board, options.previous.layoutId);
const emptyRoots = board.sections
.filter((section) => section.kind === "empty")
.toSorted((first, second) => (first.yOffset ?? 0) - (second.yOffset ?? 0) || first.id.localeCompare(second.id));
@@ -1734,7 +2048,12 @@ const getUpdatedBoardLayout = (
const root = rootByLane.get(lane);
const width = getBoardLaneColumnCount(options.current, lane);
if (!root || width === 0) return [];
- const sourceLanes = boardLanes.filter((sourceLane) => targetLaneBySourceLane.get(sourceLane) === lane);
+ const sourceLanes = boardLanes.filter(
+ (sourceLane) =>
+ rootByLane.has(sourceLane) &&
+ getBoardLaneColumnCount(options.previous, sourceLane) > 0 &&
+ targetLaneBySourceLane.get(sourceLane) === lane,
+ );
const previousWidth =
sourceLanes.length === 1
? getBoardLaneColumnCount(options.previous, sourceLanes[0] ?? "main")
@@ -1793,6 +2112,35 @@ const getUpdatedBoardLayout = (
};
};
+const getElementsForProjectedLayout = (
+ projectedLayout: ReturnType,
+): GridAlgorithmItem[] => [
+ ...projectedLayout.itemSectionLayouts.map((layout) => ({
+ id: layout.itemId,
+ type: "item" as const,
+ height: layout.height,
+ width: layout.width,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.sectionId,
+ })),
+ ...projectedLayout.sectionLayouts.flatMap((layout) =>
+ layout.parentSectionId
+ ? [
+ {
+ id: layout.sectionId,
+ type: "section" as const,
+ height: layout.height,
+ width: layout.width,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.parentSectionId,
+ },
+ ]
+ : [],
+ ),
+];
+
interface BoardLayoutGeometry {
layoutId: string;
columnCount: number;
@@ -1873,43 +2221,54 @@ const ensureGutterRootSectionsAsync = async (db: Database, boardId: string, lane
});
};
-const getElementsForLayout = (board: Awaited>, layoutId: string) => {
+const getElementsForLayout = (board: BoardForLayoutProjection, layoutId: string) => {
const sectionElements = board.sections
.filter((section) => section.kind === "container")
- .map((section) => {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const clonedLayout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === layoutId)!;
+ .flatMap((section) => {
+ const clonedLayout = section.layouts?.find((sectionLayout) => sectionLayout.layoutId === layoutId);
+ if (!clonedLayout?.parentSectionId) return [];
- return {
- id: section.id,
- type: "section" as const,
+ return [
+ {
+ id: section.id,
+ type: "section" as const,
+ height: clonedLayout.height,
+ width: clonedLayout.width,
+ xOffset: clonedLayout.xOffset,
+ yOffset: clonedLayout.yOffset,
+ sectionId: clonedLayout.parentSectionId,
+ },
+ ];
+ });
+
+ const itemElements = board.items.flatMap((item) => {
+ const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId);
+ if (!clonedLayout) return [];
+
+ return [
+ {
+ id: item.id,
+ type: "item" as const,
height: clonedLayout.height,
width: clonedLayout.width,
xOffset: clonedLayout.xOffset,
yOffset: clonedLayout.yOffset,
- sectionId: clonedLayout.parentSectionId,
- };
- });
-
- const itemElements = board.items.map((item) => {
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const clonedLayout = item.layouts.find((itemLayout) => itemLayout.layoutId === layoutId)!;
-
- return {
- id: item.id,
- type: "item" as const,
- height: clonedLayout.height,
- width: clonedLayout.width,
- xOffset: clonedLayout.xOffset,
- yOffset: clonedLayout.yOffset,
- sectionId: clonedLayout.sectionId,
- };
+ sectionId: clonedLayout.sectionId,
+ },
+ ];
});
return [...itemElements, ...sectionElements];
};
-const getFullBoardWithWhereAsync = async (db: Database, where: SQL, userId: string | null) => {
+const protectedLayoutRepairPromises = new Map>();
+
+const getFullBoardWithWhereAsync = async (
+ db: Database,
+ where: SQL,
+ userId: string | null,
+ repairProtectedLayouts = true,
+) => {
const groupPermissionWhere = userId
? inArray(
boardGroupPermissions.groupId,
@@ -1965,6 +2324,18 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
});
}
+ if (repairProtectedLayouts && boardLayoutsNeedRepair(board.layouts)) {
+ let repairPromise = protectedLayoutRepairPromises.get(board.id);
+ if (!repairPromise) {
+ repairPromise = seedProtectedBoardLayoutsAsync(db, board.id).finally(() => {
+ protectedLayoutRepairPromises.delete(board.id);
+ });
+ protectedLayoutRepairPromises.set(board.id, repairPromise);
+ }
+ await repairPromise;
+ return getFullBoardWithWhereAsync(db, where, userId, false);
+ }
+
const { sections, items, layouts, ...otherBoardProperties } = board;
return {
@@ -2010,6 +2381,23 @@ const getFullBoardWithWhereAsync = async (db: Database, where: SQL, use
};
};
+export const boardLayoutsNeedRepair = (
+ boardLayouts: Array<{ id: string; breakpoint: number; role: "mobile" | "base" | "custom" }>,
+) => {
+ const mobileLayouts = boardLayouts.filter((layout) => layout.role === "mobile");
+ const baseLayouts = boardLayouts.filter((layout) => layout.role === "base");
+ const baseLayout = baseLayouts.at(0);
+
+ return (
+ mobileLayouts.length !== 1 ||
+ baseLayouts.length !== 1 ||
+ mobileLayouts.at(0)?.breakpoint !== 0 ||
+ !baseLayout ||
+ boardLayouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint) ||
+ new Set(boardLayouts.map((layout) => layout.breakpoint)).size !== boardLayouts.length
+ );
+};
+
const forKind = (kind: T) =>
z.object({
kind: z.literal(kind),
diff --git a/packages/api/src/router/board/grid-algorithm.ts b/packages/api/src/router/board/grid-algorithm.ts
deleted file mode 100644
index 78d7ab7fc..000000000
--- a/packages/api/src/router/board/grid-algorithm.ts
+++ /dev/null
@@ -1,186 +0,0 @@
-export interface GridAlgorithmItem {
- id: string;
- type: "item" | "section";
- width: number;
- height: number;
- xOffset: number;
- yOffset: number;
- sectionId: string;
-}
-
-interface GridAlgorithmInput {
- items: GridAlgorithmItem[];
- width: number;
- previousWidth: number;
- sectionId: string;
-}
-
-interface GridAlgorithmOutput {
- height: number;
- items: GridAlgorithmItem[];
-}
-
-export const generateResponsiveGridFor = ({
- items,
- previousWidth,
- width,
- sectionId,
-}: GridAlgorithmInput): GridAlgorithmOutput => {
- const itemsOfCurrentSection = items
- .filter((item) => item.sectionId === sectionId)
- .toSorted((itemA, itemB) =>
- itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
- );
- const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
-
- if (itemsOfCurrentSection.length === 0) {
- return {
- height: 0,
- items: [],
- };
- }
-
- const newItems: GridAlgorithmItem[] = [];
-
- // A container must remain tall enough for its recursively reflowed contents.
- const containerHeightMap = new Map();
- const containersOfCurrentSection = normalizedItems.filter((item) => item.type === "section");
- for (const container of containersOfCurrentSection) {
- const result = generateResponsiveGridFor({
- items,
- previousWidth: container.previousWidth,
- width: container.width,
- sectionId: container.id,
- });
- newItems.push(...result.items);
- containerHeightMap.set(container.id, result.height);
- }
-
- // Return same positions for items in the current section
- if (width >= previousWidth) {
- return {
- height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
- items: newItems.concat(normalizedItems),
- };
- }
-
- const occupied2d: boolean[][] = [];
-
- for (const item of normalizedItems) {
- const itemWithHeight = {
- ...item,
- height: item.type === "section" ? Math.max(containerHeightMap.get(item.id) ?? 1, item.height) : item.height,
- };
- const position = nextFreeSpot(occupied2d, itemWithHeight, width);
- if (!position) throw new Error("No free spot available");
-
- addItemToOccupied(occupied2d, itemWithHeight, position, width);
- newItems.push({
- ...itemWithHeight,
- xOffset: position.x,
- yOffset: position.y,
- });
- }
-
- return {
- height: occupied2d.length,
- items: newItems,
- };
-};
-
-/**
- * Reduces the width of the items to fit the new column count.
- * @param items items to normalize
- * @param columnCount new column count
- */
-const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) => {
- return items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
-};
-
-/**
- * Adds the item to the occupied spots.
- * @param occupied2d array of occupied spots
- * @param item item to place
- * @param position position to place the item
- */
-const addItemToOccupied = (
- occupied2d: boolean[][],
- item: GridAlgorithmItem,
- position: { x: number; y: number },
- columnCount: number,
-) => {
- for (let yOffset = 0; yOffset < item.height; yOffset++) {
- let row = occupied2d[position.y + yOffset];
- if (!row) {
- addRow(occupied2d, columnCount);
- // After adding it, it must exist
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- row = occupied2d[position.y + yOffset]!;
- }
-
- for (let xOffset = 0; xOffset < item.width; xOffset++) {
- row[position.x + xOffset] = true;
- }
- }
-};
-
-/**
- * Adds a new row to the grid.
- * @param occupied2d array of occupied spots
- * @param columnCount column count of section
- */
-const addRow = (occupied2d: boolean[][], columnCount: number) => {
- occupied2d.push(new Array(columnCount).fill(false));
-};
-
-/**
- * Searches for the next free spot in the grid.
- * @param occupied2d array of occupied spots
- * @param item item to place
- * @param columnCount column count of section
- * @returns the position of the next free spot or null if no spot is available
- */
-const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
- for (let offsetY = 0; offsetY < 99999; offsetY++) {
- for (let offsetX = 0; offsetX < columnCount; offsetX++) {
- if (hasHorizontalSpace(columnCount, item, offsetX) && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
- return { x: offsetX, y: offsetY };
- }
- }
- }
-
- return null;
-};
-
-/**
- * Check if the item fits into the grid horizontally.
- * @param columnCount available width
- * @param item item to place
- * @param offsetX current x position
- * @returns true if the item fits horizontally
- */
-const hasHorizontalSpace = (columnCount: number, item: GridAlgorithmItem, offsetX: number) => {
- return offsetX + item.width <= columnCount;
-};
-
-/**
- * Check if the spot is free.
- * @param occupied2d array of occupied spots
- * @param item item to place
- * @param position position to check
- * @returns true if the spot is free
- */
-const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
- for (let yOffset = 0; yOffset < item.height; yOffset++) {
- const row = occupied2d[position.y + yOffset];
- if (!row) return true; // Empty row is free
-
- for (let xOffset = 0; xOffset < item.width; xOffset++) {
- if (row[position.x + xOffset]) {
- return false;
- }
- }
- }
-
- return true;
-};
diff --git a/packages/api/src/router/board/test/grid-algorithm.spec.ts b/packages/api/src/router/board/test/grid-algorithm.spec.ts
index 13c66759c..ca028634b 100644
--- a/packages/api/src/router/board/test/grid-algorithm.spec.ts
+++ b/packages/api/src/router/board/test/grid-algorithm.spec.ts
@@ -1,8 +1,8 @@
import { createId } from "@paralleldrive/cuid2";
import { describe, expect, test } from "vitest";
-import type { GridAlgorithmItem } from "../grid-algorithm";
-import { generateResponsiveGridFor } from "../grid-algorithm";
+import { generateResponsiveGridFor } from "@homarr/common";
+import type { GridAlgorithmItem } from "@homarr/common";
const ROOT_SECTION_ID = "section";
@@ -78,6 +78,26 @@ describe("Grid Algorithm", () => {
);
},
);
+
+ test("stops projecting a corrupted recursive section graph", () => {
+ const recursiveSection = algoItem({ id: "recursive", type: "section", width: 3 });
+ const duplicateRecursiveLayout = algoItem({
+ id: "recursive",
+ type: "section",
+ width: 3,
+ sectionId: "recursive",
+ });
+
+ const result = generateResponsiveGridFor({
+ items: [recursiveSection, duplicateRecursiveLayout],
+ width: 3,
+ previousWidth: 12,
+ sectionId: ROOT_SECTION_ID,
+ });
+
+ expect(result.items).toContainEqual(expect.objectContaining({ id: "recursive", sectionId: ROOT_SECTION_ID }));
+ expect(result.items).toHaveLength(2);
+ });
});
const algoItem = (item: Partial): GridAlgorithmItem => ({
diff --git a/packages/api/src/router/onboard/onboard-router.ts b/packages/api/src/router/onboard/onboard-router.ts
index 4f43a9533..c195c9815 100644
--- a/packages/api/src/router/onboard/onboard-router.ts
+++ b/packages/api/src/router/onboard/onboard-router.ts
@@ -294,9 +294,7 @@ export const onboardRouter = createTRPCRouter({
}
const section = board.sections.find((boardSection) => boardSection.kind === "empty");
- const layout = board.layouts[0];
-
- if (section && layout) {
+ if (section && board.layouts.length > 0) {
const existingItems = await db.query.items.findMany({
where: eq(items.boardId, board.id),
});
@@ -310,12 +308,12 @@ export const onboardRouter = createTRPCRouter({
await placeAllWidgetsAsync(
db,
- {
+ board.layouts.map((layout) => ({
boardId: board.id,
sectionId: section.id,
layoutId: layout.id,
columnCount: layout.columnCount,
- },
+ })),
allIntegrations,
allApps,
);
diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts
index 5fdfd191e..625325dcf 100644
--- a/packages/api/src/router/test/board.spec.ts
+++ b/packages/api/src/router/test/board.spec.ts
@@ -4,7 +4,7 @@ import { describe, expect, it, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database, InferInsertModel } from "@homarr/db";
-import { and, eq, not } from "@homarr/db";
+import { and, eq } from "@homarr/db";
import {
boardGroupPermissions,
boards,
@@ -325,10 +325,13 @@ describe("createBoard should create a new board", () => {
expect(firstSection?.xOffset).toBe(0);
expect(firstSection?.yOffset).toBe(0);
- expect(dbBoard?.layouts.length).toBe(1);
- const firstLayout = dbBoard?.layouts.at(0);
- expect(firstLayout?.columnCount).toBe(24);
- expect(firstLayout?.breakpoint).toBe(0);
+ expect(dbBoard?.layouts.length).toBe(2);
+ expect(dbBoard?.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ name: "Mobile", role: "mobile", columnCount: 3, breakpoint: 0 }),
+ expect.objectContaining({ name: "Base", role: "base", columnCount: 24, breakpoint: 768 }),
+ ]),
+ );
});
test("should throw error when user has no board-create permission", async () => {
@@ -344,6 +347,36 @@ describe("createBoard should create a new board", () => {
});
});
+describe("duplicateBoard should preserve protected layout invariants", () => {
+ test("normalizes a legacy single-layout board while preserving Base positions", async () => {
+ const db = createDb();
+ const session = {
+ ...defaultSession,
+ user: {
+ ...defaultSession.user,
+ permissions: ["board-create"] satisfies GroupPermissionKey[],
+ },
+ };
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session });
+ const { boardId } = await createFullBoardAsync(db, "source");
+
+ const result = await caller.duplicateBoard({ id: boardId, name: "copy" });
+
+ const copiedBoard = await db.query.boards.findFirst({
+ where: eq(boards.id, result.boardId),
+ with: { layouts: true, items: { with: { layouts: true } } },
+ });
+ expect(copiedBoard?.layouts).toHaveLength(2);
+ expect(copiedBoard?.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
+ expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
+ ]),
+ );
+ expect(copiedBoard?.items.at(0)?.layouts).toHaveLength(2);
+ });
+});
+
describe("rename board should rename board", () => {
test("should rename board", async () => {
// Arrange
@@ -569,6 +602,13 @@ describe("getBoardByName should return board by name", () => {
name,
...fullBoardProps,
});
+ expect(result.layouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ role: "mobile", breakpoint: 0, columnCount: 3 }),
+ expect.objectContaining({ id: fullBoardProps.layoutId, role: "base", breakpoint: 768 }),
+ ]),
+ );
+ expect(result.items.at(0)?.layouts).toHaveLength(2);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view");
});
@@ -1330,12 +1370,12 @@ describe("saveBoard should save full board", () => {
expect(firstItem.id).toBe(itemId);
expect(firstItem.kind).toBe("clock");
expect(SuperJSON.parse<{ is24HourFormat: boolean }>(firstItem.options).is24HourFormat).toBe(false);
- const firstLayout = expectToBeDefined(firstItem.layouts[0]);
- expect(firstLayout.sectionId).toBe(sectionId);
- expect(firstLayout.height).toBe(3);
- expect(firstLayout.width).toBe(2);
- expect(firstLayout.xOffset).toBe(7);
- expect(firstLayout.yOffset).toBe(5);
+ const updatedLayout = expectToBeDefined(firstItem.layouts.find((layout) => layout.layoutId === layoutId));
+ expect(updatedLayout.sectionId).toBe(sectionId);
+ expect(updatedLayout.height).toBe(3);
+ expect(updatedLayout.width).toBe(2);
+ expect(updatedLayout.xOffset).toBe(7);
+ expect(updatedLayout.yOffset).toBe(5);
expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "modify");
});
it("should fail when board not found", async () => {
@@ -1522,7 +1562,17 @@ const createExistingLayout = (id: string) => ({
columnCount: 10,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
+ breakpoint: 768,
+ role: "base" as const,
+});
+const createMobileLayout = (id: string) => ({
+ id,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
breakpoint: 0,
+ role: "mobile" as const,
});
const createNewLayout = (columnCount: number) => ({
id: createId(),
@@ -1530,8 +1580,23 @@ const createNewLayout = (columnCount: number) => ({
columnCount,
leftGutterColumnCount: 0,
rightGutterColumnCount: 0,
- breakpoint: 1400,
+ breakpoint: 480,
+ role: "custom" as const,
});
+const addMobileLayoutAsync = async (db: Database, boardId: string) => {
+ const mobileLayoutId = createId();
+ await db.insert(layouts).values({
+ id: mobileLayoutId,
+ name: "Mobile",
+ columnCount: 3,
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: 0,
+ role: "mobile",
+ boardId,
+ });
+ return mobileLayoutId;
+};
describe("saveLayouts should save layout changes", () => {
test("should add layout when not present in database", async () => {
// Arrange
@@ -1539,23 +1604,26 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const newLayout = createNewLayout(12);
// Act
- await caller.saveLayouts({
+ const savedLayouts = await caller.saveLayouts({
id: boardId,
- layouts: [createExistingLayout(layoutId), newLayout],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
- where: not(eq(layouts.id, layoutId)),
+ where: eq(layouts.role, "custom"),
});
const definedLayout = expectToBeDefined(layout);
expect(definedLayout.name).toBe(newLayout.name);
expect(definedLayout.columnCount).toBe(newLayout.columnCount);
expect(definedLayout.breakpoint).toBe(newLayout.breakpoint);
+ expect(definedLayout.role).toBe("custom");
+ expect(savedLayouts.find((candidate) => candidate.role === "custom")?.id).toBe(definedLayout.id);
});
test("should add items and containers generated from grid-algorithm when new layout is added", async () => {
// Arrange
@@ -1563,6 +1631,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1573,12 +1642,12 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [createExistingLayout(layoutId), newLayout],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId), newLayout],
});
// Assert
const layout = await db.query.layouts.findFirst({
- where: not(eq(layouts.id, layoutId)),
+ where: eq(layouts.role, "custom"),
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expectLayoutForRootLayoutAsync(db, sectionId, layout!.id, {
@@ -1594,6 +1663,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const updatedLayout = createExistingLayout(layoutId);
updatedLayout.breakpoint = 1400;
updatedLayout.name = "Updated layout";
@@ -1601,7 +1671,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [updatedLayout],
+ layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1620,6 +1690,7 @@ describe("saveLayouts should save layout changes", () => {
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const assignments = await createItemsAndSectionsAsync(db, {
boardId,
layoutId,
@@ -1631,7 +1702,7 @@ describe("saveLayouts should save layout changes", () => {
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [updatedLayout],
+ layouts: [createMobileLayout(mobileLayoutId), updatedLayout],
});
// Assert
@@ -1645,6 +1716,7 @@ describe("saveLayouts should save layout changes", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "gutters");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
await db
.update(itemLayouts)
@@ -1656,6 +1728,7 @@ describe("saveLayouts should save layout changes", () => {
await caller.saveLayouts({
id: boardId,
layouts: [
+ createMobileLayout(mobileLayoutId),
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
@@ -1684,7 +1757,7 @@ describe("saveLayouts should save layout changes", () => {
});
await caller.saveLayouts({
id: boardId,
- layouts: [createExistingLayout(layoutId)],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
});
const returnedItem = expectToBeDefined(
@@ -1698,9 +1771,11 @@ describe("saveLayouts should save layout changes", () => {
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "concurrent-gutters");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
const input = {
id: boardId,
layouts: [
+ createMobileLayout(mobileLayoutId),
{
...createExistingLayout(layoutId),
leftGutterColumnCount: 2,
@@ -1715,22 +1790,73 @@ describe("saveLayouts should save layout changes", () => {
});
expect(leftRoots).toHaveLength(1);
});
+ test("should project a new custom layout from the resized Base layout", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
+ const secondItemId = createId();
+
+ await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId));
+ await db.insert(items).values({
+ id: secondItemId,
+ kind: "clock",
+ boardId,
+ options: SuperJSON.stringify({ is24HourFormat: true }),
+ });
+ await db.insert(itemLayouts).values({
+ itemId: secondItemId,
+ layoutId,
+ sectionId,
+ width: 4,
+ height: 1,
+ xOffset: 6,
+ yOffset: 0,
+ });
+
+ const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 };
+ const savedLayouts = await caller.saveLayouts({
+ id: boardId,
+ layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase],
+ });
+ const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id;
+
+ const basePosition = await db.query.itemLayouts.findFirst({
+ where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)),
+ });
+ const customPosition = await db.query.itemLayouts.findFirst({
+ where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)),
+ });
+
+ expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
+ expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId });
+ });
test("should remove layout when not present in input", async () => {
// Arrange
const db = createDb();
const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
+ const customLayoutId = createId();
+ await db.insert(layouts).values({
+ id: customLayoutId,
+ name: "Custom",
+ columnCount: 6,
+ breakpoint: 480,
+ role: "custom",
+ boardId,
+ });
// Act
await caller.saveLayouts({
id: boardId,
- layouts: [createNewLayout(12)],
+ layouts: [createMobileLayout(mobileLayoutId), createExistingLayout(layoutId)],
});
// Assert
const layout = await db.query.layouts.findFirst({
- where: eq(layouts.id, layoutId),
+ where: eq(layouts.id, customLayoutId),
});
expect(layout).toBeUndefined();
});
@@ -1745,12 +1871,73 @@ describe("saveLayouts should save layout changes", () => {
const actAsync = async () =>
await caller.saveLayouts({
id: createId(),
- layouts: [createExistingLayout(layoutId)],
+ layouts: [createMobileLayout(createId()), createExistingLayout(layoutId)],
});
// Assert
await expect(actAsync()).rejects.toThrowError("Board not found");
});
+ test("should reject changing protected layout roles", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+ const mobileLayoutId = await addMobileLayoutAsync(db, boardId);
+
+ const actAsync = async () =>
+ await caller.saveLayouts({
+ id: boardId,
+ layouts: [
+ { ...createExistingLayout(layoutId), role: "mobile", breakpoint: 0 },
+ { ...createMobileLayout(mobileLayoutId), role: "base", breakpoint: 768 },
+ ],
+ });
+
+ await expect(actAsync()).rejects.toThrowError("Layout roles cannot be changed");
+ });
+});
+
+describe("resetLayout should rebuild a layout from Base", () => {
+ test("should replace target item positions and preserve layout metadata", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, sectionId, itemId } = await createFullBoardAsync(db, "default");
+ await addMobileLayoutAsync(db, boardId);
+ const customLayoutId = createId();
+ await db.insert(layouts).values({
+ id: customLayoutId,
+ name: "Tablet",
+ columnCount: 6,
+ breakpoint: 480,
+ role: "custom",
+ boardId,
+ });
+ await db.insert(itemLayouts).values({
+ itemId,
+ sectionId,
+ layoutId: customLayoutId,
+ width: 2,
+ height: 2,
+ xOffset: 3,
+ yOffset: 4,
+ });
+
+ await caller.resetLayout({ boardId, layoutId: customLayoutId });
+
+ const resetPosition = await db.query.itemLayouts.findFirst({
+ where: and(eq(itemLayouts.itemId, itemId), eq(itemLayouts.layoutId, customLayoutId)),
+ });
+ expect(resetPosition).toMatchObject({ width: 1, height: 1, xOffset: 0, yOffset: 0, sectionId });
+ const customLayout = await db.query.layouts.findFirst({ where: eq(layouts.id, customLayoutId) });
+ expect(customLayout).toMatchObject({ name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" });
+ });
+
+ test("should reject resetting Base", async () => {
+ const db = createDb();
+ const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession });
+ const { boardId, layoutId } = await createFullBoardAsync(db, "default");
+
+ await expect(caller.resetLayout({ boardId, layoutId })).rejects.toThrowError("Base layout cannot be reset");
+ });
});
describe("Custom Widget placement permissions", () => {
@@ -1848,7 +2035,8 @@ const createFullBoardAsync = async (db: Database, name: string) => {
id: layoutId,
name: "Base",
columnCount: 10,
- breakpoint: 0,
+ breakpoint: 768,
+ role: "base",
boardId,
});
diff --git a/packages/api/src/test/mcp.spec.ts b/packages/api/src/test/mcp.spec.ts
index 5df3a9f06..09e6c077e 100644
--- a/packages/api/src/test/mcp.spec.ts
+++ b/packages/api/src/test/mcp.spec.ts
@@ -67,6 +67,7 @@ const MCP_TOOL_ALLOWLIST = {
"board_deleteBoard",
"board_duplicateBoard",
"board_renameBoard",
+ "board_resetLayout",
"board_savePartialBoardSettings",
"board_setHomeBoard",
"board_setMobileHomeBoard",
diff --git a/packages/boards/src/context.tsx b/packages/boards/src/context.tsx
index b6e1ea3a6..2e8137b5e 100644
--- a/packages/boards/src/context.tsx
+++ b/packages/boards/src/context.tsx
@@ -12,6 +12,7 @@ import { updateBoardName } from "./updater";
const BoardContext = createContext<{
board: RouterOutputs["board"]["getBoardByName"];
+ layoutOverrideId: string | null;
currentLayout: string;
initialViewportWidth: number;
} | null>(null);
@@ -41,10 +42,12 @@ export const BoardProvider = ({
initialBoard,
initialLayoutId,
initialViewportWidth,
+ layoutOverrideId = null,
}: PropsWithChildren<{
initialBoard: RouterOutputs["board"]["getBoardByName"];
initialLayoutId: string;
initialViewportWidth: number;
+ layoutOverrideId?: string | null;
}>) => {
const { data } = clientApi.board.getBoardByName.useQuery(
{ name: initialBoard.name },
@@ -58,8 +61,8 @@ export const BoardProvider = ({
);
const currentLayout = useSyncExternalStore(
subscribeToViewport,
- () => getCurrentLayout(data),
- () => initialLayoutId,
+ () => getCurrentLayout(data, layoutOverrideId),
+ () => layoutOverrideId ?? initialLayoutId,
);
// Update the board name so it can be used within updateBoard method
@@ -83,6 +86,7 @@ export const BoardProvider = ({
{
return context?.board ?? null;
};
-export const getCurrentLayout = (board: RouterOutputs["board"]["getBoardByName"]) => {
+export const useLayoutOverride = () => useContext(BoardContext)?.layoutOverrideId ?? null;
+
+export const getCurrentLayout = (
+ board: RouterOutputs["board"]["getBoardByName"],
+ layoutOverrideId: string | null = null,
+) => {
+ const layouts = board.layouts;
+ if (layoutOverrideId && layouts.some((layout) => layout.id === layoutOverrideId)) return layoutOverrideId;
+
const viewportWidth = typeof window === "undefined" ? 0 : getViewportWidth();
- return getLayoutIdForViewportWidth(board.layouts, viewportWidth);
+ return getLayoutIdForViewportWidth(layouts, viewportWidth);
};
export const useCurrentLayout = () => {
diff --git a/packages/boards/src/edit-mode.tsx b/packages/boards/src/edit-mode.tsx
index 9b813b032..58288ce2a 100644
--- a/packages/boards/src/edit-mode.tsx
+++ b/packages/boards/src/edit-mode.tsx
@@ -1,13 +1,18 @@
"use client";
import type { PropsWithChildren } from "react";
-import { createContext, useContext } from "react";
+import { createContext, useContext, useEffect } from "react";
import { useDisclosure } from "@mantine/hooks";
const EditModeContext = createContext | null>(null);
-export const EditModeProvider = ({ children }: PropsWithChildren) => {
- const editModeDisclosure = useDisclosure(false);
+export const EditModeProvider = ({ children, initialOpen = false }: PropsWithChildren<{ initialOpen?: boolean }>) => {
+ const editModeDisclosure = useDisclosure(initialOpen);
+ const [, { open }] = editModeDisclosure;
+
+ useEffect(() => {
+ if (initialOpen) open();
+ }, [initialOpen, open]);
return {children} ;
};
diff --git a/packages/common/src/grid-algorithm.ts b/packages/common/src/grid-algorithm.ts
new file mode 100644
index 000000000..83d165d70
--- /dev/null
+++ b/packages/common/src/grid-algorithm.ts
@@ -0,0 +1,125 @@
+export interface GridAlgorithmItem {
+ id: string;
+ type: "item" | "section";
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ sectionId: string;
+}
+
+interface GridAlgorithmInput {
+ items: GridAlgorithmItem[];
+ width: number;
+ previousWidth: number;
+ sectionId: string;
+}
+
+interface GridAlgorithmOutput {
+ height: number;
+ items: GridAlgorithmItem[];
+}
+
+export const generateResponsiveGridFor = ({
+ items,
+ previousWidth,
+ width,
+ sectionId,
+}: GridAlgorithmInput): GridAlgorithmOutput =>
+ generateResponsiveGridForSection({ items, previousWidth, width, sectionId }, new Set());
+
+const generateResponsiveGridForSection = (
+ { items, previousWidth, width, sectionId }: GridAlgorithmInput,
+ ancestorSectionIds: ReadonlySet,
+): GridAlgorithmOutput => {
+ if (ancestorSectionIds.has(sectionId)) return { height: 0, items: [] };
+
+ const nextAncestorSectionIds = new Set(ancestorSectionIds).add(sectionId);
+ const itemsOfCurrentSection = items
+ .filter((item) => item.sectionId === sectionId)
+ .toSorted((itemA, itemB) =>
+ itemA.yOffset === itemB.yOffset ? itemA.xOffset - itemB.xOffset : itemA.yOffset - itemB.yOffset,
+ );
+ const normalizedItems = normalizeItems(itemsOfCurrentSection, width);
+
+ if (itemsOfCurrentSection.length === 0) return { height: 0, items: [] };
+
+ const newItems: GridAlgorithmItem[] = [];
+ const dynamicSectionHeightMap = new Map();
+ for (const dynamicSection of normalizedItems.filter((item) => item.type === "section")) {
+ const result = generateResponsiveGridForSection(
+ {
+ items,
+ previousWidth: dynamicSection.previousWidth,
+ width: dynamicSection.width,
+ sectionId: dynamicSection.id,
+ },
+ nextAncestorSectionIds,
+ );
+ newItems.push(...result.items);
+ dynamicSectionHeightMap.set(dynamicSection.id, result.height);
+ }
+
+ if (width >= previousWidth) {
+ return {
+ height: Math.max(...itemsOfCurrentSection.map((item) => item.yOffset + item.height)),
+ items: newItems.concat(normalizedItems),
+ };
+ }
+
+ const occupied2d: boolean[][] = [];
+ for (const item of normalizedItems) {
+ const itemWithHeight = {
+ ...item,
+ height: item.type === "section" ? Math.max(dynamicSectionHeightMap.get(item.id) ?? 1, item.height) : item.height,
+ };
+ const position = nextFreeSpot(occupied2d, itemWithHeight, width);
+ if (!position) throw new Error("No free spot available");
+
+ addItemToOccupied(occupied2d, itemWithHeight, position, width);
+ newItems.push({ ...itemWithHeight, xOffset: position.x, yOffset: position.y });
+ }
+
+ return { height: occupied2d.length, items: newItems };
+};
+
+const normalizeItems = (items: GridAlgorithmItem[], columnCount: number) =>
+ items.map((item) => ({ ...item, previousWidth: item.width, width: Math.min(columnCount, item.width) }));
+
+const addItemToOccupied = (
+ occupied2d: boolean[][],
+ item: GridAlgorithmItem,
+ position: { x: number; y: number },
+ columnCount: number,
+) => {
+ for (let yOffset = 0; yOffset < item.height; yOffset++) {
+ let row = occupied2d[position.y + yOffset];
+ if (!row) {
+ row = Array.from({ length: columnCount }, () => false);
+ occupied2d.push(row);
+ }
+ for (let xOffset = 0; xOffset < item.width; xOffset++) row[position.x + xOffset] = true;
+ }
+};
+
+const nextFreeSpot = (occupied2d: boolean[][], item: GridAlgorithmItem, columnCount: number) => {
+ for (let offsetY = 0; offsetY < 99999; offsetY++) {
+ for (let offsetX = 0; offsetX < columnCount; offsetX++) {
+ if (offsetX + item.width <= columnCount && isFree(occupied2d, item, { x: offsetX, y: offsetY })) {
+ return { x: offsetX, y: offsetY };
+ }
+ }
+ }
+ return null;
+};
+
+const isFree = (occupied2d: boolean[][], item: GridAlgorithmItem, position: { x: number; y: number }) => {
+ for (let yOffset = 0; yOffset < item.height; yOffset++) {
+ const row = occupied2d[position.y + yOffset];
+ if (!row) return true;
+ for (let xOffset = 0; xOffset < item.width; xOffset++) {
+ if (row[position.x + xOffset]) return false;
+ }
+ }
+ return true;
+};
diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts
index 5f82ea6a3..f67a771ad 100644
--- a/packages/common/src/index.ts
+++ b/packages/common/src/index.ts
@@ -11,4 +11,5 @@ export * from "./number";
export * from "./error";
export * from "./theme";
export * from "./function";
+export * from "./grid-algorithm";
export * from "./id";
diff --git a/packages/custom-widgets/src/test/options.spec.ts b/packages/custom-widgets/src/test/options.spec.ts
index 078cfc62a..628246b7a 100644
--- a/packages/custom-widgets/src/test/options.spec.ts
+++ b/packages/custom-widgets/src/test/options.spec.ts
@@ -71,17 +71,16 @@ describe("lean Custom Widget options", () => {
});
it("replaces stale values with current defaults", () => {
- const options = customWidgetDefinitionSchema.parse({
- $schema: "homarr-custom-widget-v2",
- name: "Updated options",
- sources: {},
- requests: {},
- options: {
- limit: { label: "Limit", control: "slider", default: 5, min: 1, max: 10 },
- label: { label: "Label", control: "text", default: "Default" },
- },
- template: "{options.limit} ",
- }).options;
+ const options = {
+ limit: customWidgetOptionSchema.parse({
+ label: "Limit",
+ control: "slider",
+ default: 5,
+ min: 1,
+ max: 10,
+ }),
+ label: customWidgetOptionSchema.parse({ label: "Label", control: "text", default: "Default" }),
+ };
const normalized = normalizeCustomWidgetOptions(options, {
limit: "5",
diff --git a/packages/db/migrations/mysql/0046_add_layout_role.sql b/packages/db/migrations/mysql/0046_add_layout_role.sql
new file mode 100644
index 000000000..46e3c2b6b
--- /dev/null
+++ b/packages/db/migrations/mysql/0046_add_layout_role.sql
@@ -0,0 +1 @@
+ALTER TABLE `layout` ADD `role` varchar(16) DEFAULT 'custom' NOT NULL;
\ No newline at end of file
diff --git a/packages/db/migrations/mysql/meta/0046_snapshot.json b/packages/db/migrations/mysql/meta/0046_snapshot.json
new file mode 100644
index 000000000..726c1b491
--- /dev/null
+++ b/packages/db/migrations/mysql/meta/0046_snapshot.json
@@ -0,0 +1,2567 @@
+{
+ "version": "5",
+ "dialect": "mysql",
+ "id": "d1b4dec4-07ef-498d-9fe0-0c61e93c0664",
+ "prevId": "81b11351-4235-4a61-9a2f-68d01e089c42",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "name": "account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "apiKey_id": {
+ "name": "apiKey_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "app": {
+ "name": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "app_id": {
+ "name": "app_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "name": "boardGroupPermission_board_id_group_id_permission_pk",
+ "columns": ["board_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "boardUserPermission": {
+ "name": "boardUserPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "name": "boardUserPermission_board_id_user_id_permission_pk",
+ "columns": ["board_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('fixed')"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('no-repeat')"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('cover')"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('#fa5252')"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('#fd7e14')"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('lg')"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "board_id": {
+ "name": "board_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "columns": ["name"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "cron_job_configuration_name": {
+ "name": "cron_job_configuration_name",
+ "columns": ["name"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requests": {
+ "name": "requests",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_definition_id": {
+ "name": "custom_widget_v2_definition_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
+ "columns": ["definition_id", "source_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "groupMember": {
+ "name": "groupMember",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "name": "groupMember_group_id_user_id_pk",
+ "columns": ["group_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "groupPermission": {
+ "name": "groupPermission",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "group": {
+ "name": "group",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "group_id": {
+ "name": "group_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "columns": ["name"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "iconRepository": {
+ "name": "iconRepository",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "iconRepository_id": {
+ "name": "iconRepository_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "icon": {
+ "name": "icon",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(250)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "icon_id": {
+ "name": "icon_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_group_permission__pk": {
+ "name": "integration_group_permission__pk",
+ "columns": ["integration_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "name": "integration_item_item_id_integration_id_pk",
+ "columns": ["item_id", "integration_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "name": "integrationSecret_integration_id_kind_pk",
+ "columns": ["integration_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk",
+ "columns": ["integration_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_id": {
+ "name": "integration_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "invite_id": {
+ "name": "invite_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "columns": ["token"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "item_layout": {
+ "name": "item_layout",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "name": "item_layout_item_id_section_id_layout_id_pk",
+ "columns": ["item_id", "section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_id": {
+ "name": "item_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "layout": {
+ "name": "layout",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "left_gutter_column_count": {
+ "name": "left_gutter_column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "right_gutter_column_count": {
+ "name": "right_gutter_column_count",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'custom'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "layout_id": {
+ "name": "layout_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_definition_id": {
+ "name": "custom_widget_definition_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "cw_secret_definition_id_cw_definition_id_fk": {
+ "name": "cw_secret_definition_id_cw_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "name": "custom_widget_secret_definition_id_kind_pk",
+ "columns": ["definition_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "media": {
+ "name": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "LONGBLOB",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(now())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "media_id": {
+ "name": "media_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "onboarding": {
+ "name": "onboarding",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "step": {
+ "name": "step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "onboarding_id": {
+ "name": "onboarding_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "search_engine": {
+ "name": "search_engine",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short": {
+ "name": "short",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "search_engine_id": {
+ "name": "search_engine_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "columns": ["short"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "section_collapse_state": {
+ "name": "section_collapse_state",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "name": "section_collapse_state_user_id_section_id_pk",
+ "columns": ["user_id", "section_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "section_layout": {
+ "name": "section_layout",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "name": "section_layout_section_id_layout_id_pk",
+ "columns": ["section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "int",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_id": {
+ "name": "section_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "serverSetting": {
+ "name": "serverSetting",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "('{\"json\": {}}')"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "serverSetting_setting_key": {
+ "name": "serverSetting_setting_key",
+ "columns": ["setting_key"]
+ }
+ },
+ "uniqueConstraints": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "columns": ["setting_key"]
+ }
+ },
+ "checkConstraint": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "session_session_token": {
+ "name": "session_session_token",
+ "columns": ["session_token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk",
+ "columns": ["hostname", "thumbprint"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "tinyint",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "enable_right_click_on_widgets": {
+ "name": "enable_right_click_on_widgets",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_id": {
+ "name": "user_id",
+ "columns": ["id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "name": "verificationToken_identifier_token_pk",
+ "columns": ["identifier", "token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ },
+ "widget_secret": {
+ "name": "widget_secret",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "widget_secret_item_id_item_id_fk": {
+ "name": "widget_secret_item_id_item_id_fk",
+ "tableFrom": "widget_secret",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "widget_secret_item_id_kind_pk": {
+ "name": "widget_secret_item_id_kind_pk",
+ "columns": ["item_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraint": {}
+ }
+ },
+ "views": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "tables": {},
+ "indexes": {}
+ }
+}
diff --git a/packages/db/migrations/mysql/meta/_journal.json b/packages/db/migrations/mysql/meta/_journal.json
index 57f1c8ac5..82f35c2e3 100644
--- a/packages/db/migrations/mysql/meta/_journal.json
+++ b/packages/db/migrations/mysql/meta/_journal.json
@@ -323,6 +323,13 @@
"when": 1786212549933,
"tag": "0045_add_layout_gutters",
"breakpoints": true
+ },
+ {
+ "idx": 46,
+ "version": "5",
+ "when": 1786215199760,
+ "tag": "0046_add_layout_role",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/migrations/postgresql/0012_add_layout_role.sql b/packages/db/migrations/postgresql/0012_add_layout_role.sql
new file mode 100644
index 000000000..20c7625be
--- /dev/null
+++ b/packages/db/migrations/postgresql/0012_add_layout_role.sql
@@ -0,0 +1 @@
+ALTER TABLE "layout" ADD COLUMN "role" varchar(16) DEFAULT 'custom' NOT NULL;
\ No newline at end of file
diff --git a/packages/db/migrations/postgresql/meta/0012_snapshot.json b/packages/db/migrations/postgresql/meta/0012_snapshot.json
new file mode 100644
index 000000000..6611b879c
--- /dev/null
+++ b/packages/db/migrations/postgresql/meta/0012_snapshot.json
@@ -0,0 +1,2424 @@
+{
+ "id": "f1d7a91a-bf16-4f26-a0ae-ce77eab2a40e",
+ "prevId": "c0fb2530-8de7-47b6-8229-8e715dc57f09",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.account": {
+ "name": "account",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "name": "account_provider_provider_account_id_pk",
+ "columns": ["provider", "provider_account_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.apiKey": {
+ "name": "apiKey",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.app": {
+ "name": "app",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "schema": "",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "name": "boardGroupPermission_board_id_group_id_permission_pk",
+ "columns": ["board_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.boardUserPermission": {
+ "name": "boardUserPermission",
+ "schema": "",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "name": "boardUserPermission_board_id_user_id_permission_pk",
+ "columns": ["board_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.board": {
+ "name": "board",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#fa5252'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'#fd7e14'"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'lg'"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "requests": {
+ "name": "requests",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
+ "schema": "",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk",
+ "columns": ["definition_id", "source_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.groupMember": {
+ "name": "groupMember",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "name": "groupMember_group_id_user_id_pk",
+ "columns": ["group_id", "user_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.groupPermission": {
+ "name": "groupPermission",
+ "schema": "",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.group": {
+ "name": "group",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "position": {
+ "name": "position",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "nullsNotDistinct": false,
+ "columns": ["name"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.iconRepository": {
+ "name": "iconRepository",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "slug": {
+ "name": "slug",
+ "type": "varchar(150)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.icon": {
+ "name": "icon",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(250)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "schema": "",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_group_permission__pk": {
+ "name": "integration_group_permission__pk",
+ "columns": ["integration_id", "group_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integration_item": {
+ "name": "integration_item",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "name": "integration_item_item_id_integration_id_pk",
+ "columns": ["item_id", "integration_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationSecret": {
+ "name": "integrationSecret",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": [
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "name": "integrationSecret_integration_id_kind_pk",
+ "columns": ["integration_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "schema": "",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "permission": {
+ "name": "permission",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk",
+ "columns": ["integration_id", "user_id", "permission"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.integration": {
+ "name": "integration",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.invite": {
+ "name": "invite",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "nullsNotDistinct": false,
+ "columns": ["token"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item_layout": {
+ "name": "item_layout",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "name": "item_layout_item_id_section_id_layout_id_pk",
+ "columns": ["item_id", "section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.item": {
+ "name": "item",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.layout": {
+ "name": "layout",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "left_gutter_column_count": {
+ "name": "left_gutter_column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "right_gutter_column_count": {
+ "name": "right_gutter_column_count",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'custom'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "method": {
+ "name": "method",
+ "type": "varchar(16)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "varchar(32)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
+ "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "name": "custom_widget_secret_definition_id_kind_pk",
+ "columns": ["definition_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.media": {
+ "name": "media",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content": {
+ "name": "content",
+ "type": "bytea",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.onboarding": {
+ "name": "onboarding",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "step": {
+ "name": "step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.search_engine": {
+ "name": "search_engine",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "short": {
+ "name": "short",
+ "type": "varchar(8)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "type": {
+ "name": "type",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "nullsNotDistinct": false,
+ "columns": ["short"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section_collapse_state": {
+ "name": "section_collapse_state",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "name": "section_collapse_state_user_id_section_id_pk",
+ "columns": ["user_id", "section_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section_layout": {
+ "name": "section_layout",
+ "schema": "",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "name": "section_layout_section_id_layout_id_pk",
+ "columns": ["section_id", "layout_id"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.section": {
+ "name": "section",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.serverSetting": {
+ "name": "serverSetting",
+ "schema": "",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "nullsNotDistinct": false,
+ "columns": ["setting_key"]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.session": {
+ "name": "session",
+ "schema": "",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "varchar(512)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "schema": "",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "varchar(256)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "varchar(128)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk",
+ "columns": ["hostname", "thumbprint"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user": {
+ "name": "user",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "varchar(64)",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "varchar(5)",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "smallint",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "enable_right_click_on_widgets": {
+ "name": "enable_right_click_on_widgets",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verificationToken": {
+ "name": "verificationToken",
+ "schema": "",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "varchar(512)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires": {
+ "name": "expires",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "name": "verificationToken_identifier_token_pk",
+ "columns": ["identifier", "token"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.widget_secret": {
+ "name": "widget_secret",
+ "schema": "",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "varchar(64)",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "widget_secret_item_id_item_id_fk": {
+ "name": "widget_secret_item_id_item_id_fk",
+ "tableFrom": "widget_secret",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "widget_secret_item_id_kind_pk": {
+ "name": "widget_secret_item_id_kind_pk",
+ "columns": ["item_id", "kind"]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {},
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
diff --git a/packages/db/migrations/postgresql/meta/_journal.json b/packages/db/migrations/postgresql/meta/_journal.json
index c3dacd7f3..36189d05b 100644
--- a/packages/db/migrations/postgresql/meta/_journal.json
+++ b/packages/db/migrations/postgresql/meta/_journal.json
@@ -85,6 +85,13 @@
"when": 1786212457549,
"tag": "0011_add_layout_gutters",
"breakpoints": true
+ },
+ {
+ "idx": 12,
+ "version": "7",
+ "when": 1786215201393,
+ "tag": "0012_add_layout_role",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/migrations/seed.spec.ts b/packages/db/migrations/seed.spec.ts
new file mode 100644
index 000000000..375849e29
--- /dev/null
+++ b/packages/db/migrations/seed.spec.ts
@@ -0,0 +1,97 @@
+import { describe, expect, test } from "vitest";
+
+import { createId } from "@homarr/common";
+import { eq } from "@homarr/db";
+import { boards, itemLayouts, items, layouts, sections } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+import { emptySuperJSON } from "@homarr/definitions";
+
+import { seedProtectedBoardLayoutsAsync } from "./seed";
+
+describe("protected board layout migration", () => {
+ test("turns a single saved layout into Base and generates Mobile", async () => {
+ const db = createDb();
+ const boardId = createId();
+ const baseLayoutId = createId();
+ const sectionId = createId();
+ const itemId = createId();
+ await db.insert(boards).values({ id: boardId, name: "single" });
+ await db.insert(layouts).values({
+ id: baseLayoutId,
+ boardId,
+ name: "Wide",
+ columnCount: 12,
+ breakpoint: 0,
+ });
+ await db.insert(sections).values({ id: sectionId, boardId, kind: "empty", xOffset: 0, yOffset: 0 });
+ await db.insert(items).values({ id: itemId, boardId, kind: "clock", options: emptySuperJSON });
+ await db.insert(itemLayouts).values({
+ itemId,
+ sectionId,
+ layoutId: baseLayoutId,
+ width: 5,
+ height: 2,
+ xOffset: 6,
+ yOffset: 1,
+ });
+
+ await seedProtectedBoardLayoutsAsync(db);
+ await seedProtectedBoardLayoutsAsync(db);
+
+ const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
+ expect(savedLayouts).toHaveLength(2);
+ expect(savedLayouts.find((layout) => layout.role === "base")).toMatchObject({
+ id: baseLayoutId,
+ name: "Wide",
+ columnCount: 12,
+ breakpoint: 768,
+ });
+ const mobileLayout = savedLayouts.find((layout) => layout.role === "mobile");
+ expect(mobileLayout).toMatchObject({ name: "Mobile", columnCount: 3, breakpoint: 0 });
+ const mobileItemPosition = await db.query.itemLayouts.findFirst({
+ where: eq(itemLayouts.layoutId, mobileLayout?.id ?? ""),
+ });
+ expect(mobileItemPosition).toMatchObject({ width: 3, height: 2, xOffset: 0, yOffset: 0 });
+ });
+
+ test("assigns roles and resolves duplicate breakpoints without rebuilding existing layouts", async () => {
+ const db = createDb();
+ const boardId = createId();
+ await db.insert(boards).values({ id: boardId, name: "responsive" });
+ await db.insert(layouts).values([
+ { id: "layout-mobile", boardId, name: "Phone", columnCount: 3, breakpoint: 0 },
+ { id: "layout-tablet-a", boardId, name: "Tablet A", columnCount: 6, breakpoint: 480 },
+ { id: "layout-tablet-b", boardId, name: "Tablet B", columnCount: 8, breakpoint: 480 },
+ { id: "layout-wide", boardId, name: "Wide", columnCount: 12, breakpoint: 1200 },
+ ]);
+
+ await seedProtectedBoardLayoutsAsync(db);
+ await seedProtectedBoardLayoutsAsync(db);
+
+ const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
+ expect(savedLayouts.toSorted((layoutA, layoutB) => layoutA.breakpoint - layoutB.breakpoint)).toMatchObject([
+ { id: "layout-mobile", name: "Phone", columnCount: 3, breakpoint: 0, role: "mobile" },
+ { id: "layout-tablet-a", name: "Tablet A", columnCount: 6, breakpoint: 480, role: "custom" },
+ { id: "layout-tablet-b", name: "Tablet B", columnCount: 8, breakpoint: 481, role: "custom" },
+ { id: "layout-wide", name: "Wide", columnCount: 12, breakpoint: 1200, role: "base" },
+ ]);
+ });
+
+ test("creates both protected layouts for a board with no layouts", async () => {
+ const db = createDb();
+ const boardId = createId();
+ await db.insert(boards).values({ id: boardId, name: "empty" });
+
+ await seedProtectedBoardLayoutsAsync(db);
+ await seedProtectedBoardLayoutsAsync(db);
+
+ const savedLayouts = await db.query.layouts.findMany({ where: eq(layouts.boardId, boardId) });
+ expect(savedLayouts).toHaveLength(2);
+ expect(savedLayouts).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ role: "mobile", columnCount: 3, breakpoint: 0 }),
+ expect.objectContaining({ role: "base", columnCount: 10, breakpoint: 768 }),
+ ]),
+ );
+ });
+});
diff --git a/packages/db/migrations/seed.ts b/packages/db/migrations/seed.ts
index 412c09e1e..3042ca522 100644
--- a/packages/db/migrations/seed.ts
+++ b/packages/db/migrations/seed.ts
@@ -1,6 +1,6 @@
import SuperJSON from "superjson";
-import { createId, objectKeys } from "@homarr/common";
+import { createId, generateResponsiveGridFor, objectKeys } from "@homarr/common";
import { BUNDLED_CUSTOM_WIDGETS, customWidgetDefinitionSchema } from "@homarr/custom-widgets/core";
import {
createDocumentationLink,
@@ -11,11 +11,12 @@ import {
getIntegrationName,
integrationDefs,
integrationKinds,
+ normalizeBoardLayoutRoles,
} from "@homarr/definitions";
import type { WidgetKind } from "@homarr/definitions";
import { defaultServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
-import type { Database } from "..";
+import type { Database, InferInsertModel } from "..";
import { eq, inArray } from "..";
import { getMaxGroupPositionAsync, placeAllWidgetsAsync } from "../queries";
import {
@@ -39,6 +40,7 @@ import {
onboarding,
searchEngines,
sections,
+ sectionLayouts,
users,
} from "../schema";
import type { Integration } from "../schema";
@@ -65,6 +67,215 @@ export const seedDataAsync = async (db: Database) => {
if (isTruthyEnv(process.env.DEMO_MODE)) {
await seedDemoUserAsync(db);
}
+
+ await seedProtectedBoardLayoutsAsync(db);
+};
+
+export const seedProtectedBoardLayoutsAsync = async (db: Database, boardId?: string) => {
+ const dbBoards = await db.query.boards.findMany({
+ where: boardId ? eq(boards.id, boardId) : undefined,
+ with: {
+ layouts: true,
+ items: { with: { layouts: true } },
+ sections: { with: { layouts: true } },
+ },
+ });
+
+ for (const board of dbBoards) {
+ if (board.layouts.length === 0) {
+ await db.insert(layouts).values([
+ {
+ id: createId(),
+ name: "Mobile",
+ columnCount: 3,
+ breakpoint: 0,
+ role: "mobile",
+ boardId: board.id,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: 10,
+ breakpoint: 768,
+ role: "base",
+ boardId: board.id,
+ },
+ ]);
+ continue;
+ }
+
+ if (board.layouts.length === 1) {
+ const [baseLayout] = board.layouts;
+ if (!baseLayout) continue;
+
+ const mobileLayout = {
+ id: createId(),
+ name: "Mobile",
+ columnCount: 3,
+ breakpoint: 0,
+ role: "mobile" as const,
+ boardId: board.id,
+ };
+
+ await db.update(layouts).set({ role: "base", breakpoint: 768 }).where(eq(layouts.id, baseLayout.id));
+ await db.insert(layouts).values(mobileLayout);
+ await insertMissingProjectedPositionsAsync(
+ db,
+ board,
+ { ...baseLayout, role: "base", breakpoint: 768 },
+ mobileLayout,
+ );
+ continue;
+ }
+
+ const normalizedLayouts = normalizeBoardLayoutRoles(board.layouts);
+ const alreadyNormalized = normalizedLayouts.every((layout) => {
+ const previousLayout = board.layouts.find((candidate) => candidate.id === layout.id);
+ return layout.breakpoint === previousLayout?.breakpoint && previousLayout.role === layout.role;
+ });
+
+ if (!alreadyNormalized) {
+ for (const layout of normalizedLayouts) {
+ await db
+ .update(layouts)
+ .set({ breakpoint: layout.breakpoint, role: layout.role })
+ .where(eq(layouts.id, layout.id));
+ }
+ }
+
+ const mobileLayout = normalizedLayouts[0];
+ const baseLayout = normalizedLayouts.at(-1);
+ if (mobileLayout && baseLayout) {
+ await insertMissingProjectedPositionsAsync(
+ db,
+ board,
+ { ...baseLayout, role: "base" },
+ { ...mobileLayout, role: "mobile" },
+ );
+ }
+ }
+};
+
+interface BoardWithLayoutPositions {
+ items: Array<{
+ id: string;
+ layouts: Array<{
+ layoutId: string;
+ sectionId: string;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+ sections: Array<{
+ id: string;
+ kind: string;
+ layouts: Array<{
+ layoutId: string;
+ parentSectionId: string | null;
+ width: number;
+ height: number;
+ xOffset: number;
+ yOffset: number;
+ }>;
+ }>;
+}
+
+const insertMissingProjectedPositionsAsync = async (
+ db: Database,
+ board: BoardWithLayoutPositions,
+ sourceLayout: InferInsertModel,
+ targetLayout: InferInsertModel,
+) => {
+ const elements = [
+ ...board.items.flatMap((item) => {
+ const layout = item.layouts.find((itemLayout) => itemLayout.layoutId === sourceLayout.id);
+ return layout
+ ? [
+ {
+ id: item.id,
+ type: "item" as const,
+ width: layout.width,
+ height: layout.height,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.sectionId,
+ },
+ ]
+ : [];
+ }),
+ ...board.sections.flatMap((section) => {
+ if (section.kind !== "dynamic") return [];
+ const layout = section.layouts.find((sectionLayout) => sectionLayout.layoutId === sourceLayout.id);
+ return layout?.parentSectionId
+ ? [
+ {
+ id: section.id,
+ type: "section" as const,
+ width: layout.width,
+ height: layout.height,
+ xOffset: layout.xOffset,
+ yOffset: layout.yOffset,
+ sectionId: layout.parentSectionId,
+ },
+ ]
+ : [];
+ }),
+ ];
+
+ const projectedElements = board.sections
+ .filter((section) => section.kind !== "dynamic")
+ .flatMap(
+ (section) =>
+ generateResponsiveGridFor({
+ items: elements,
+ previousWidth: sourceLayout.columnCount,
+ width: targetLayout.columnCount,
+ sectionId: section.id,
+ }).items,
+ );
+
+ const existingItemIds = new Set(
+ board.items
+ .filter((item) => item.layouts.some((layout) => layout.layoutId === targetLayout.id))
+ .map((item) => item.id),
+ );
+ const itemPositions = projectedElements
+ .filter((element) => element.type === "item" && !existingItemIds.has(element.id))
+ .map(
+ (element): InferInsertModel => ({
+ itemId: element.id,
+ layoutId: targetLayout.id,
+ sectionId: element.sectionId,
+ width: element.width,
+ height: element.height,
+ xOffset: element.xOffset,
+ yOffset: element.yOffset,
+ }),
+ );
+
+ const existingSectionIds = new Set(
+ board.sections
+ .filter((section) => section.layouts.some((layout) => layout.layoutId === targetLayout.id))
+ .map((section) => section.id),
+ );
+ const sectionPositions = projectedElements
+ .filter((element) => element.type === "section" && !existingSectionIds.has(element.id))
+ .map(
+ (element): InferInsertModel => ({
+ sectionId: element.id,
+ layoutId: targetLayout.id,
+ parentSectionId: element.sectionId,
+ width: element.width,
+ height: element.height,
+ xOffset: element.xOffset,
+ yOffset: element.yOffset,
+ }),
+ );
+
+ if (itemPositions.length > 0) await db.insert(itemLayouts).values(itemPositions);
+ if (sectionPositions.length > 0) await db.insert(sectionLayouts).values(sectionPositions);
};
const seedEveryoneGroupAsync = async (db: Database) => {
@@ -271,13 +482,24 @@ const seedDefaultBoardAsync = async (db: Database) => {
yOffset: 0,
boardId,
});
- await db.insert(layouts).values({
- id: createId(),
- name: "Base",
- columnCount: 10,
- breakpoint: 0,
- boardId,
- });
+ await db.insert(layouts).values([
+ {
+ id: createId(),
+ name: "Mobile",
+ columnCount: 3,
+ breakpoint: 0,
+ role: "mobile",
+ boardId,
+ },
+ {
+ id: createId(),
+ name: "Base",
+ columnCount: 10,
+ breakpoint: 768,
+ role: "base",
+ boardId,
+ },
+ ]);
const everyoneGroupRow = await db.query.groups.findFirst({
where: eq(groups.name, everyoneGroup),
@@ -508,7 +730,8 @@ const seedDemoUserAsync = async (db: Database) => {
id: layoutId,
name: "Base",
columnCount: 12,
- breakpoint: 0,
+ breakpoint: 768,
+ role: "base",
boardId,
});
@@ -598,7 +821,7 @@ const seedBoardWidgetsAsync = async (db: Database) => {
if (!board) return;
const section = board.sections.find((sec) => sec.kind === "empty");
- const layout = board.layouts[0];
+ const layout = board.layouts.find((candidate) => candidate.role === "base") ?? board.layouts[0];
if (!section || !layout) return;
const allIntegrations = await db.query.integrations.findMany();
diff --git a/packages/db/migrations/sqlite/0044_add_layout_role.sql b/packages/db/migrations/sqlite/0044_add_layout_role.sql
new file mode 100644
index 000000000..3c9fd79c9
--- /dev/null
+++ b/packages/db/migrations/sqlite/0044_add_layout_role.sql
@@ -0,0 +1 @@
+ALTER TABLE `layout` ADD `role` text DEFAULT 'custom' NOT NULL;
\ No newline at end of file
diff --git a/packages/db/migrations/sqlite/meta/0044_snapshot.json b/packages/db/migrations/sqlite/meta/0044_snapshot.json
new file mode 100644
index 000000000..c916baf01
--- /dev/null
+++ b/packages/db/migrations/sqlite/meta/0044_snapshot.json
@@ -0,0 +1,2472 @@
+{
+ "version": "6",
+ "dialect": "sqlite",
+ "id": "ba1d8f76-d167-405c-97a6-ade428e51df1",
+ "prevId": "e868162b-3757-4d9e-940c-facc749256d6",
+ "tables": {
+ "account": {
+ "name": "account",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "provider_account_id": {
+ "name": "provider_account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "token_type": {
+ "name": "token_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "session_state": {
+ "name": "session_state",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "userId_idx": {
+ "name": "userId_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "account_user_id_user_id_fk": {
+ "name": "account_user_id_user_id_fk",
+ "tableFrom": "account",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "account_provider_provider_account_id_pk": {
+ "columns": ["provider", "provider_account_id"],
+ "name": "account_provider_provider_account_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "apiKey": {
+ "name": "apiKey",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "api_key": {
+ "name": "api_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "apiKey_user_id_user_id_fk": {
+ "name": "apiKey_user_id_user_id_fk",
+ "tableFrom": "apiKey",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "app": {
+ "name": "app",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "href": {
+ "name": "href",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "ping_url": {
+ "name": "ping_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "boardGroupPermission": {
+ "name": "boardGroupPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardGroupPermission_board_id_board_id_fk": {
+ "name": "boardGroupPermission_board_id_board_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardGroupPermission_group_id_group_id_fk": {
+ "name": "boardGroupPermission_group_id_group_id_fk",
+ "tableFrom": "boardGroupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardGroupPermission_board_id_group_id_permission_pk": {
+ "columns": ["board_id", "group_id", "permission"],
+ "name": "boardGroupPermission_board_id_group_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "boardUserPermission": {
+ "name": "boardUserPermission",
+ "columns": {
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "boardUserPermission_board_id_board_id_fk": {
+ "name": "boardUserPermission_board_id_board_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "boardUserPermission_user_id_user_id_fk": {
+ "name": "boardUserPermission_user_id_user_id_fk",
+ "tableFrom": "boardUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "boardUserPermission_board_id_user_id_permission_pk": {
+ "columns": ["board_id", "user_id", "permission"],
+ "name": "boardUserPermission_board_id_user_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "board": {
+ "name": "board",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "page_title": {
+ "name": "page_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "meta_title": {
+ "name": "meta_title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "logo_image_url": {
+ "name": "logo_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "favicon_image_url": {
+ "name": "favicon_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_url": {
+ "name": "background_image_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "background_image_attachment": {
+ "name": "background_image_attachment",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'fixed'"
+ },
+ "background_image_repeat": {
+ "name": "background_image_repeat",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'no-repeat'"
+ },
+ "background_image_size": {
+ "name": "background_image_size",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'cover'"
+ },
+ "primary_color": {
+ "name": "primary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fa5252'"
+ },
+ "secondary_color": {
+ "name": "secondary_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'#fd7e14'"
+ },
+ "opacity": {
+ "name": "opacity",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 100
+ },
+ "custom_css": {
+ "name": "custom_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_color": {
+ "name": "icon_color",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "item_radius": {
+ "name": "item_radius",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'lg'"
+ },
+ "disable_status": {
+ "name": "disable_status",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {
+ "board_name_unique": {
+ "name": "board_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "board_creator_id_user_id_fk": {
+ "name": "board_creator_id_user_id_fk",
+ "tableFrom": "board",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "cron_job_configuration": {
+ "name": "cron_job_configuration",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "cron_expression": {
+ "name": "cron_expression",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "is_enabled": {
+ "name": "is_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_v2_definition": {
+ "name": "custom_widget_v2_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "sources": {
+ "name": "sources",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "requests": {
+ "name": "requests",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "template": {
+ "name": "template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_v2_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_v2_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_v2_secret": {
+ "name": "custom_widget_v2_secret",
+ "columns": {
+ "source_id": {
+ "name": "source_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_v2_secret_definition_id_fk": {
+ "name": "custom_widget_v2_secret_definition_id_fk",
+ "tableFrom": "custom_widget_v2_secret",
+ "tableTo": "custom_widget_v2_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_v2_secret_definition_id_source_id_kind_pk": {
+ "columns": ["definition_id", "source_id", "kind"],
+ "name": "custom_widget_v2_secret_definition_id_source_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groupMember": {
+ "name": "groupMember",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupMember_group_id_group_id_fk": {
+ "name": "groupMember_group_id_group_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "groupMember_user_id_user_id_fk": {
+ "name": "groupMember_user_id_user_id_fk",
+ "tableFrom": "groupMember",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "groupMember_group_id_user_id_pk": {
+ "columns": ["group_id", "user_id"],
+ "name": "groupMember_group_id_user_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "groupPermission": {
+ "name": "groupPermission",
+ "columns": {
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "groupPermission_group_id_group_id_fk": {
+ "name": "groupPermission_group_id_group_id_fk",
+ "tableFrom": "groupPermission",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "group": {
+ "name": "group",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "owner_id": {
+ "name": "owner_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "group_name_unique": {
+ "name": "group_name_unique",
+ "columns": ["name"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "group_owner_id_user_id_fk": {
+ "name": "group_owner_id_user_id_fk",
+ "tableFrom": "group",
+ "tableTo": "user",
+ "columnsFrom": ["owner_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_home_board_id_board_id_fk": {
+ "name": "group_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "group_mobile_home_board_id_board_id_fk": {
+ "name": "group_mobile_home_board_id_board_id_fk",
+ "tableFrom": "group",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "iconRepository": {
+ "name": "iconRepository",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "icon": {
+ "name": "icon",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_repository_id": {
+ "name": "icon_repository_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "icon_icon_repository_id_iconRepository_id_fk": {
+ "name": "icon_icon_repository_id_iconRepository_id_fk",
+ "tableFrom": "icon",
+ "tableTo": "iconRepository",
+ "columnsFrom": ["icon_repository_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationGroupPermissions": {
+ "name": "integrationGroupPermissions",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "group_id": {
+ "name": "group_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationGroupPermissions_integration_id_integration_id_fk": {
+ "name": "integrationGroupPermissions_integration_id_integration_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationGroupPermissions_group_id_group_id_fk": {
+ "name": "integrationGroupPermissions_group_id_group_id_fk",
+ "tableFrom": "integrationGroupPermissions",
+ "tableTo": "group",
+ "columnsFrom": ["group_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationGroupPermissions_integration_id_group_id_permission_pk": {
+ "columns": ["integration_id", "group_id", "permission"],
+ "name": "integrationGroupPermissions_integration_id_group_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration_item": {
+ "name": "integration_item",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integration_item_item_id_item_id_fk": {
+ "name": "integration_item_item_id_item_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integration_item_integration_id_integration_id_fk": {
+ "name": "integration_item_integration_id_integration_id_fk",
+ "tableFrom": "integration_item",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integration_item_item_id_integration_id_pk": {
+ "columns": ["item_id", "integration_id"],
+ "name": "integration_item_item_id_integration_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationSecret": {
+ "name": "integrationSecret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration_secret__kind_idx": {
+ "name": "integration_secret__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ },
+ "integration_secret__updated_at_idx": {
+ "name": "integration_secret__updated_at_idx",
+ "columns": ["updated_at"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integrationSecret_integration_id_integration_id_fk": {
+ "name": "integrationSecret_integration_id_integration_id_fk",
+ "tableFrom": "integrationSecret",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationSecret_integration_id_kind_pk": {
+ "columns": ["integration_id", "kind"],
+ "name": "integrationSecret_integration_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integrationUserPermission": {
+ "name": "integrationUserPermission",
+ "columns": {
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "permission": {
+ "name": "permission",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "integrationUserPermission_integration_id_integration_id_fk": {
+ "name": "integrationUserPermission_integration_id_integration_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "integrationUserPermission_user_id_user_id_fk": {
+ "name": "integrationUserPermission_user_id_user_id_fk",
+ "tableFrom": "integrationUserPermission",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "integrationUserPermission_integration_id_user_id_permission_pk": {
+ "columns": ["integration_id", "user_id", "permission"],
+ "name": "integrationUserPermission_integration_id_user_id_permission_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "integration": {
+ "name": "integration",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "app_id": {
+ "name": "app_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "integration__kind_idx": {
+ "name": "integration__kind_idx",
+ "columns": ["kind"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "integration_app_id_app_id_fk": {
+ "name": "integration_app_id_app_id_fk",
+ "tableFrom": "integration",
+ "tableTo": "app",
+ "columnsFrom": ["app_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "invite": {
+ "name": "invite",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expiration_date": {
+ "name": "expiration_date",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "invite_token_unique": {
+ "name": "invite_token_unique",
+ "columns": ["token"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "invite_creator_id_user_id_fk": {
+ "name": "invite_creator_id_user_id_fk",
+ "tableFrom": "invite",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "item_layout": {
+ "name": "item_layout",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_layout_item_id_item_id_fk": {
+ "name": "item_layout_item_id_item_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_section_id_section_id_fk": {
+ "name": "item_layout_section_id_section_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "item_layout_layout_id_layout_id_fk": {
+ "name": "item_layout_layout_id_layout_id_fk",
+ "tableFrom": "item_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "item_layout_item_id_section_id_layout_id_pk": {
+ "columns": ["item_id", "section_id", "layout_id"],
+ "name": "item_layout_item_id_section_id_layout_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "item": {
+ "name": "item",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ },
+ "advanced_options": {
+ "name": "advanced_options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "item_board_id_board_id_fk": {
+ "name": "item_board_id_board_id_fk",
+ "tableFrom": "item",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "layout": {
+ "name": "layout",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "column_count": {
+ "name": "column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "left_gutter_column_count": {
+ "name": "left_gutter_column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "right_gutter_column_count": {
+ "name": "right_gutter_column_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "breakpoint": {
+ "name": "breakpoint",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 0
+ },
+ "role": {
+ "name": "role",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'custom'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "layout_board_id_board_id_fk": {
+ "name": "layout_board_id_board_id_fk",
+ "tableFrom": "layout",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_definition": {
+ "name": "custom_widget_definition",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "auth_type": {
+ "name": "auth_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'none'"
+ },
+ "header_name": {
+ "name": "header_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "method": {
+ "name": "method",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'GET'"
+ },
+ "request_body": {
+ "name": "request_body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "display_type": {
+ "name": "display_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'singleValue'"
+ },
+ "display_config": {
+ "name": "display_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_definition_creator_id_user_id_fk": {
+ "name": "custom_widget_definition_creator_id_user_id_fk",
+ "tableFrom": "custom_widget_definition",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "custom_widget_secret": {
+ "name": "custom_widget_secret",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "definition_id": {
+ "name": "definition_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "custom_widget_secret_definition_id_custom_widget_definition_id_fk": {
+ "name": "custom_widget_secret_definition_id_custom_widget_definition_id_fk",
+ "tableFrom": "custom_widget_secret",
+ "tableTo": "custom_widget_definition",
+ "columnsFrom": ["definition_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "custom_widget_secret_definition_id_kind_pk": {
+ "columns": ["definition_id", "kind"],
+ "name": "custom_widget_secret_definition_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "media": {
+ "name": "media",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content": {
+ "name": "content",
+ "type": "blob",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "size": {
+ "name": "size",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "(unixepoch())"
+ },
+ "creator_id": {
+ "name": "creator_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "media_creator_id_user_id_fk": {
+ "name": "media_creator_id_user_id_fk",
+ "tableFrom": "media",
+ "tableTo": "user",
+ "columnsFrom": ["creator_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "onboarding": {
+ "name": "onboarding",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "step": {
+ "name": "step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "previous_step": {
+ "name": "previous_step",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "search_engine": {
+ "name": "search_engine",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "icon_url": {
+ "name": "icon_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "short": {
+ "name": "short",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "url_template": {
+ "name": "url_template",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'generic'"
+ },
+ "integration_id": {
+ "name": "integration_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "search_engine_short_unique": {
+ "name": "search_engine_short_unique",
+ "columns": ["short"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {
+ "search_engine_integration_id_integration_id_fk": {
+ "name": "search_engine_integration_id_integration_id_fk",
+ "tableFrom": "search_engine",
+ "tableTo": "integration",
+ "columnsFrom": ["integration_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section_collapse_state": {
+ "name": "section_collapse_state",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "collapsed": {
+ "name": "collapsed",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_collapse_state_user_id_user_id_fk": {
+ "name": "section_collapse_state_user_id_user_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_collapse_state_section_id_section_id_fk": {
+ "name": "section_collapse_state_section_id_section_id_fk",
+ "tableFrom": "section_collapse_state",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_collapse_state_user_id_section_id_pk": {
+ "columns": ["user_id", "section_id"],
+ "name": "section_collapse_state_user_id_section_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section_layout": {
+ "name": "section_layout",
+ "columns": {
+ "section_id": {
+ "name": "section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "layout_id": {
+ "name": "layout_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "parent_section_id": {
+ "name": "parent_section_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "width": {
+ "name": "width",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "height": {
+ "name": "height",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_layout_section_id_section_id_fk": {
+ "name": "section_layout_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_layout_id_layout_id_fk": {
+ "name": "section_layout_layout_id_layout_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "layout",
+ "columnsFrom": ["layout_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "section_layout_parent_section_id_section_id_fk": {
+ "name": "section_layout_parent_section_id_section_id_fk",
+ "tableFrom": "section_layout",
+ "tableTo": "section",
+ "columnsFrom": ["parent_section_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "section_layout_section_id_layout_id_pk": {
+ "columns": ["section_id", "layout_id"],
+ "name": "section_layout_section_id_layout_id_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "section": {
+ "name": "section",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "board_id": {
+ "name": "board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "x_offset": {
+ "name": "x_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "y_offset": {
+ "name": "y_offset",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "options": {
+ "name": "options",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "section_board_id_board_id_fk": {
+ "name": "section_board_id_board_id_fk",
+ "tableFrom": "section",
+ "tableTo": "board",
+ "columnsFrom": ["board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "serverSetting": {
+ "name": "serverSetting",
+ "columns": {
+ "setting_key": {
+ "name": "setting_key",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'{\"json\": {}}'"
+ }
+ },
+ "indexes": {
+ "serverSetting_settingKey_unique": {
+ "name": "serverSetting_settingKey_unique",
+ "columns": ["setting_key"],
+ "isUnique": true
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "session": {
+ "name": "session",
+ "columns": {
+ "session_token": {
+ "name": "session_token",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {
+ "user_id_idx": {
+ "name": "user_id_idx",
+ "columns": ["user_id"],
+ "isUnique": false
+ }
+ },
+ "foreignKeys": {
+ "session_user_id_user_id_fk": {
+ "name": "session_user_id_user_id_fk",
+ "tableFrom": "session",
+ "tableTo": "user",
+ "columnsFrom": ["user_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "trusted_certificate_hostname": {
+ "name": "trusted_certificate_hostname",
+ "columns": {
+ "hostname": {
+ "name": "hostname",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "thumbprint": {
+ "name": "thumbprint",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "certificate": {
+ "name": "certificate",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "trusted_certificate_hostname_hostname_thumbprint_pk": {
+ "columns": ["hostname", "thumbprint"],
+ "name": "trusted_certificate_hostname_hostname_thumbprint_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "user": {
+ "name": "user",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'credentials'"
+ },
+ "home_board_id": {
+ "name": "home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "mobile_home_board_id": {
+ "name": "mobile_home_board_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "default_search_engine_id": {
+ "name": "default_search_engine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false,
+ "autoincrement": false
+ },
+ "open_search_in_new_tab": {
+ "name": "open_search_in_new_tab",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "ddg_bangs": {
+ "name": "ddg_bangs",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "color_scheme": {
+ "name": "color_scheme",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": "'dark'"
+ },
+ "first_day_of_week": {
+ "name": "first_day_of_week",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": 1
+ },
+ "ping_icons_enabled": {
+ "name": "ping_icons_enabled",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "enable_right_click_on_widgets": {
+ "name": "enable_right_click_on_widgets",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": true
+ },
+ "completed_manage_tour": {
+ "name": "completed_manage_tour",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ },
+ "completed_board_tour": {
+ "name": "completed_board_tour",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false,
+ "default": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_home_board_id_board_id_fk": {
+ "name": "user_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_mobile_home_board_id_board_id_fk": {
+ "name": "user_mobile_home_board_id_board_id_fk",
+ "tableFrom": "user",
+ "tableTo": "board",
+ "columnsFrom": ["mobile_home_board_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "user_default_search_engine_id_search_engine_id_fk": {
+ "name": "user_default_search_engine_id_search_engine_id_fk",
+ "tableFrom": "user",
+ "tableTo": "search_engine",
+ "columnsFrom": ["default_search_engine_id"],
+ "columnsTo": ["id"],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "verificationToken": {
+ "name": "verificationToken",
+ "columns": {
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "expires": {
+ "name": "expires",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "verificationToken_identifier_token_pk": {
+ "columns": ["identifier", "token"],
+ "name": "verificationToken_identifier_token_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ },
+ "widget_secret": {
+ "name": "widget_secret",
+ "columns": {
+ "item_id": {
+ "name": "item_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "autoincrement": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "widget_secret_item_id_item_id_fk": {
+ "name": "widget_secret_item_id_item_id_fk",
+ "tableFrom": "widget_secret",
+ "tableTo": "item",
+ "columnsFrom": ["item_id"],
+ "columnsTo": ["id"],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "widget_secret_item_id_kind_pk": {
+ "columns": ["item_id", "kind"],
+ "name": "widget_secret_item_id_kind_pk"
+ }
+ },
+ "uniqueConstraints": {},
+ "checkConstraints": {}
+ }
+ },
+ "views": {},
+ "enums": {},
+ "_meta": {
+ "schemas": {},
+ "tables": {},
+ "columns": {}
+ },
+ "internal": {
+ "indexes": {}
+ }
+}
diff --git a/packages/db/migrations/sqlite/meta/_journal.json b/packages/db/migrations/sqlite/meta/_journal.json
index ed48c0b57..394339b64 100644
--- a/packages/db/migrations/sqlite/meta/_journal.json
+++ b/packages/db/migrations/sqlite/meta/_journal.json
@@ -309,6 +309,13 @@
"when": 1786212459480,
"tag": "0043_add_layout_gutters",
"breakpoints": true
+ },
+ {
+ "idx": 44,
+ "version": "6",
+ "when": 1786215203092,
+ "tag": "0044_add_layout_role",
+ "breakpoints": true
}
]
}
diff --git a/packages/db/package.json b/packages/db/package.json
index 3c269b572..01d0d8810 100644
--- a/packages/db/package.json
+++ b/packages/db/package.json
@@ -9,6 +9,7 @@
"exports": {
".": "./index.ts",
"./collection": "./collection.ts",
+ "./migrations/seed": "./migrations/seed.ts",
"./proxy-reader": "./proxy-reader.ts",
"./schema": "./schema/index.ts",
"./test": "./test/index.ts",
diff --git a/packages/db/queries/widget-placement.spec.ts b/packages/db/queries/widget-placement.spec.ts
new file mode 100644
index 000000000..b8acdc3b3
--- /dev/null
+++ b/packages/db/queries/widget-placement.spec.ts
@@ -0,0 +1,57 @@
+import { describe, expect, test } from "vitest";
+
+import { createId } from "@homarr/common";
+import { boards, layouts, sections } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { placeAllWidgetsAsync } from "./widget-placement";
+
+describe("placeAllWidgetsAsync", () => {
+ test("creates independent positions for every target layout", async () => {
+ const db = createDb();
+ const boardId = createId();
+ const sectionId = createId();
+ const mobileLayoutId = createId();
+ const baseLayoutId = createId();
+ await db.insert(boards).values({ id: boardId, name: "dashboard" });
+ await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId });
+ await db.insert(layouts).values([
+ {
+ id: mobileLayoutId,
+ name: "Mobile",
+ role: "mobile",
+ columnCount: 3,
+ breakpoint: 0,
+ boardId,
+ },
+ {
+ id: baseLayoutId,
+ name: "Base",
+ role: "base",
+ columnCount: 10,
+ breakpoint: 768,
+ boardId,
+ },
+ ]);
+
+ await placeAllWidgetsAsync(
+ db,
+ [
+ { boardId, sectionId, layoutId: mobileLayoutId, columnCount: 3 },
+ { boardId, sectionId, layoutId: baseLayoutId, columnCount: 10 },
+ ],
+ [],
+ [{ id: createId(), name: "Homarr" }],
+ );
+
+ const placedItems = await db.query.items.findMany({ with: { layouts: true } });
+ expect(placedItems.length).toBeGreaterThan(0);
+ expect(placedItems.every((item) => item.layouts.length === 2)).toBe(true);
+ expect(
+ placedItems.every((item) =>
+ item.layouts.some((layout) => layout.layoutId === mobileLayoutId && layout.width <= 3),
+ ),
+ ).toBe(true);
+ expect(placedItems.every((item) => item.layouts.some((layout) => layout.layoutId === baseLayoutId))).toBe(true);
+ });
+});
diff --git a/packages/db/queries/widget-placement.ts b/packages/db/queries/widget-placement.ts
index 1d0b529f9..c3a246b2f 100644
--- a/packages/db/queries/widget-placement.ts
+++ b/packages/db/queries/widget-placement.ts
@@ -30,46 +30,54 @@ interface PlacementContext extends BoardTarget {
const placeWidgetAsync = async (
db: Database,
- ctx: PlacementContext,
+ contexts: PlacementContext[],
kind: WidgetKind,
linkedIntegrationIds: string[],
options?: string,
size?: { width: number; height: number },
) => {
- const itemWidth = size?.width ?? 2;
- const itemHeight = size?.height ?? 2;
+ const boardId = contexts.at(0)?.boardId;
+ if (!boardId) return;
- if (ctx.xOffset + itemWidth > ctx.columnCount) {
- ctx.xOffset = 0;
- ctx.yOffset += ctx.rowMaxHeight;
- ctx.rowMaxHeight = 0;
- }
+ const desiredItemWidth = size?.width ?? 2;
+ const itemHeight = size?.height ?? 2;
const itemId = createId();
await db.insert(items).values({
id: itemId,
- boardId: ctx.boardId,
+ boardId,
kind,
options: options ?? emptySuperJSON,
advancedOptions: emptySuperJSON,
});
- await db.insert(itemLayouts).values({
- itemId,
- sectionId: ctx.sectionId,
- layoutId: ctx.layoutId,
- xOffset: ctx.xOffset,
- yOffset: ctx.yOffset,
- width: itemWidth,
- height: itemHeight,
- });
+ await db.insert(itemLayouts).values(
+ contexts.map((ctx) => {
+ const itemWidth = Math.min(ctx.columnCount, desiredItemWidth);
+ if (ctx.xOffset + itemWidth > ctx.columnCount) {
+ ctx.xOffset = 0;
+ ctx.yOffset += ctx.rowMaxHeight;
+ ctx.rowMaxHeight = 0;
+ }
+
+ const placement = {
+ itemId,
+ sectionId: ctx.sectionId,
+ layoutId: ctx.layoutId,
+ xOffset: ctx.xOffset,
+ yOffset: ctx.yOffset,
+ width: itemWidth,
+ height: itemHeight,
+ };
+ ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
+ ctx.xOffset += itemWidth;
+ return placement;
+ }),
+ );
for (const integrationId of linkedIntegrationIds) {
await db.insert(integrationItems).values({ itemId, integrationId });
}
-
- ctx.rowMaxHeight = Math.max(ctx.rowMaxHeight, itemHeight);
- ctx.xOffset += itemWidth;
};
interface App {
@@ -79,16 +87,17 @@ interface App {
export const placeAllWidgetsAsync = async (
db: Database,
- target: BoardTarget,
+ target: BoardTarget | BoardTarget[],
allIntegrations: Integration[],
allApps: App[],
) => {
- const ctx: PlacementContext = {
- ...target,
+ const targets = Array.isArray(target) ? target : [target];
+ const contexts: PlacementContext[] = targets.map((currentTarget) => ({
+ ...currentTarget,
xOffset: 0,
yOffset: 0,
rowMaxHeight: 0,
- };
+ }));
const placedWidgets = new Set();
@@ -106,7 +115,7 @@ export const placeAllWidgetsAsync = async (
const options = config?.options ? superjson.stringify(config.options) : undefined;
await placeWidgetAsync(
db,
- ctx,
+ contexts,
widgetKind,
matchingIds,
options,
@@ -118,7 +127,7 @@ export const placeAllWidgetsAsync = async (
for (const app of allApps) {
await placeWidgetAsync(
db,
- ctx,
+ contexts,
"app",
[],
superjson.stringify({ appId: app.id, openInNewTab: true, showTitle: true }),
@@ -138,7 +147,7 @@ export const placeAllWidgetsAsync = async (
options = { ...options, items: bookmarkAppIds };
}
- await placeWidgetAsync(db, ctx, config.kind, [], options ? superjson.stringify(options) : undefined, {
+ await placeWidgetAsync(db, contexts, config.kind, [], options ? superjson.stringify(options) : undefined, {
width: config.width,
height: config.height,
});
diff --git a/packages/db/schema/mysql.ts b/packages/db/schema/mysql.ts
index ee891635c..1d9637a3b 100644
--- a/packages/db/schema/mysql.ts
+++ b/packages/db/schema/mysql.ts
@@ -34,6 +34,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
+ LayoutRole,
OnboardingStep,
SearchEngineType,
SectionKind,
@@ -343,6 +344,7 @@ export const layouts = mysqlTable("layout", {
leftGutterColumnCount: tinyint().notNull().default(0),
rightGutterColumnCount: tinyint().notNull().default(0),
breakpoint: smallint().notNull().default(0),
+ role: varchar({ length: 16 }).$type().notNull().default("custom"),
});
export const itemLayouts = mysqlTable(
diff --git a/packages/db/schema/postgresql.ts b/packages/db/schema/postgresql.ts
index 559fbd9eb..622686935 100644
--- a/packages/db/schema/postgresql.ts
+++ b/packages/db/schema/postgresql.ts
@@ -33,6 +33,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
+ LayoutRole,
OnboardingStep,
SearchEngineType,
SectionKind,
@@ -342,6 +343,7 @@ export const layouts = pgTable("layout", {
leftGutterColumnCount: smallint().notNull().default(0),
rightGutterColumnCount: smallint().notNull().default(0),
breakpoint: smallint().notNull().default(0),
+ role: varchar({ length: 16 }).$type().notNull().default("custom"),
});
export const itemLayouts = pgTable(
diff --git a/packages/db/schema/sqlite.ts b/packages/db/schema/sqlite.ts
index 59c5e1197..54f5f92bf 100644
--- a/packages/db/schema/sqlite.ts
+++ b/packages/db/schema/sqlite.ts
@@ -21,6 +21,7 @@ import type {
IntegrationKind,
IntegrationPermission,
IntegrationSecretKind,
+ LayoutRole,
OnboardingStep,
SearchEngineType,
SectionKind,
@@ -327,6 +328,7 @@ export const layouts = sqliteTable("layout", {
leftGutterColumnCount: int().notNull().default(0),
rightGutterColumnCount: int().notNull().default(0),
breakpoint: int().notNull().default(0),
+ role: text().$type().notNull().default("custom"),
});
export const itemLayouts = sqliteTable(
diff --git a/packages/definitions/src/board.ts b/packages/definitions/src/board.ts
index 302008550..3bd5ffdd9 100644
--- a/packages/definitions/src/board.ts
+++ b/packages/definitions/src/board.ts
@@ -8,7 +8,49 @@ export const backgroundImageRepeats = createDefinition(["repeat", "repeat-x", "r
export const backgroundImageSizes = createDefinition(["cover", "contain"], {
defaultValue: "cover",
});
+export const layoutRoles = createDefinition(["mobile", "base", "custom"], { defaultValue: "custom" });
export type BackgroundImageAttachment = inferDefinitionType;
export type BackgroundImageRepeat = inferDefinitionType;
export type BackgroundImageSize = inferDefinitionType;
+export type LayoutRole = inferDefinitionType;
+
+const MAX_LAYOUT_BREAKPOINT = 32767;
+const compareBoardLayouts = (
+ layoutA: { id: string; breakpoint: number; columnCount: number },
+ layoutB: { id: string; breakpoint: number; columnCount: number },
+) =>
+ layoutA.breakpoint - layoutB.breakpoint ||
+ layoutA.columnCount - layoutB.columnCount ||
+ layoutA.id.localeCompare(layoutB.id);
+
+export const normalizeBoardLayoutRoles = <
+ TLayout extends { id: string; breakpoint: number; columnCount: number; role?: LayoutRole },
+>(
+ sourceLayouts: TLayout[],
+): Array => {
+ const mobileLayouts = sourceLayouts.filter((layout) => layout.role === "mobile");
+ const baseLayouts = sourceLayouts.filter((layout) => layout.role === "base");
+ const mobileLayout = mobileLayouts.length === 1 ? mobileLayouts[0] : undefined;
+ const baseLayout = baseLayouts.length === 1 ? baseLayouts[0] : undefined;
+ const orderedLayouts =
+ mobileLayout && baseLayout
+ ? [
+ mobileLayout,
+ ...sourceLayouts.filter((layout) => layout.role === "custom").toSorted(compareBoardLayouts),
+ baseLayout,
+ ]
+ : sourceLayouts.toSorted(compareBoardLayouts);
+
+ let previousBreakpoint = -1;
+ return orderedLayouts.map((layout, index) => {
+ const remainingLayouts = orderedLayouts.length - index - 1;
+ const breakpoint =
+ index === 0
+ ? 0
+ : Math.min(MAX_LAYOUT_BREAKPOINT - remainingLayouts, Math.max(previousBreakpoint + 1, layout.breakpoint));
+ const role: LayoutRole = index === 0 ? "mobile" : index === orderedLayouts.length - 1 ? "base" : "custom";
+ previousBreakpoint = breakpoint;
+ return { ...layout, breakpoint, role };
+ });
+};
diff --git a/packages/definitions/src/test/integration.spec.ts b/packages/definitions/src/test/integration.spec.ts
index bdb5194fd..3372a450d 100644
--- a/packages/definitions/src/test/integration.spec.ts
+++ b/packages/definitions/src/test/integration.spec.ts
@@ -6,7 +6,7 @@ import { integrationDefs } from "../integration";
describe("Icon url's of integrations should be valid and return 200", () => {
objectEntries(integrationDefs).forEach(([integration, { iconUrl }]) => {
- it.concurrent(`should return 200 for ${integration}`, async () => {
+ it(`should return 200 for ${integration}`, { concurrent: true, retry: 2, timeout: 15_000 }, async () => {
const res = await fetch(iconUrl);
expect(res.status).toBe(200);
});
diff --git a/packages/old-import/src/import/collections/board-collection.ts b/packages/old-import/src/import/collections/board-collection.ts
index bbf9b68dd..b7b116ac6 100644
--- a/packages/old-import/src/import/collections/board-collection.ts
+++ b/packages/old-import/src/import/collections/board-collection.ts
@@ -1,6 +1,7 @@
import { createId } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createDbInsertCollectionForTransaction } from "@homarr/db/collection";
+import { normalizeBoardLayoutRoles } from "@homarr/definitions";
import type { BoardSize, OldmarrConfig } from "@homarr/old-schema";
import { boardSizes, getBoardSizeName } from "@homarr/old-schema";
@@ -88,14 +89,19 @@ export const createBoardInsertCollection = (
{} as Record,
);
- const preparedLayouts = boardSizes.map((size) => ({
- id: layoutMapping[size],
- boardId: mappedBoard.id,
- columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
- breakpoint: mapBreakpoint(size),
- name: getBoardSizeName(size),
- }));
- insertCollection.layouts.push(...preparedLayouts);
+ const importedLayouts = normalizeBoardLayoutRoles(
+ boardSizes.map((size) => ({
+ id: layoutMapping[size],
+ boardId: mappedBoard.id,
+ columnCount: mapColumnCount(board.config.settings.customization.gridstack, size),
+ leftGutterColumnCount: 0,
+ rightGutterColumnCount: 0,
+ breakpoint: mapBreakpoint(size),
+ name: getBoardSizeName(size),
+ })),
+ );
+
+ insertCollection.layouts.push(...importedLayouts);
const preparedSections = prepareSections(mappedBoard.id, { wrappers, categories });
@@ -113,7 +119,7 @@ export const createBoardInsertCollection = (
layoutMapping,
mappedBoard.id,
);
- insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, preparedLayouts));
+ insertCollection.sectionLayouts.push(...placePreparedSections(preparedSections, preparedItems, importedLayouts));
preparedItems.forEach(({ layouts, ...item }) => {
insertCollection.items.push(item);
insertCollection.itemLayouts.push(...layouts);
diff --git a/packages/old-import/src/prepare/prepare-items.ts b/packages/old-import/src/prepare/prepare-items.ts
index 0f32d6b00..61e2b5793 100644
--- a/packages/old-import/src/prepare/prepare-items.ts
+++ b/packages/old-import/src/prepare/prepare-items.ts
@@ -1,9 +1,9 @@
+import { generateResponsiveGridFor } from "@homarr/common";
+import type { GridAlgorithmItem } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import type { BoardSize, OldmarrApp, OldmarrConfig, OldmarrWidget, SizedShape } from "@homarr/old-schema";
import { boardSizes } from "@homarr/old-schema";
-import type { GridAlgorithmItem } from "../../../api/src/router/board/grid-algorithm";
-import { generateResponsiveGridFor } from "../../../api/src/router/board/grid-algorithm";
import { mapColumnCount } from "../mappers/map-column-count";
import { mapApp, mapWidget } from "../mappers/map-item";
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 770e9ec07..94e110c3a 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -5197,9 +5197,44 @@
"title": "Layout",
"responsive": {
"title": "Responsive layouts",
+ "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.",
"action": {
"add": "Add layout"
}
+ },
+ "role": {
+ "mobile": "Mobile",
+ "base": "Base",
+ "custom": "Custom"
+ },
+ "custom": {
+ "defaultName": "Custom layout"
+ },
+ "mobile": {
+ "breakpointDescription": "Mobile is always used from 0px until the next layout breakpoint."
+ },
+ "preview": {
+ "columns": "columns",
+ "empty": "This layout is empty",
+ "dynamicSection": "Dynamic section",
+ "editorWidthLabel": "{layoutName} · {width}px"
+ },
+ "reset": {
+ "action": "Reset from Base",
+ "confirm": {
+ "title": "Reset {layoutName}?",
+ "message": "This replaces every saved position and size in this layout with a new layout generated from Base. This cannot be undone."
+ }
+ },
+ "edit": {
+ "action": "Edit layout",
+ "saveAndEdit": "Save and edit",
+ "save": "Save layout",
+ "cancel": {
+ "action": "Cancel layout editing",
+ "title": "Discard layout changes?",
+ "message": "Your unsaved changes to this layout will be discarded."
+ }
}
},
"background": {
diff --git a/packages/validation/src/board-layout.spec.ts b/packages/validation/src/board-layout.spec.ts
index 3ac54b16b..5f73132f9 100644
--- a/packages/validation/src/board-layout.spec.ts
+++ b/packages/validation/src/board-layout.spec.ts
@@ -10,6 +10,7 @@ describe("board layout gutters", () => {
name: "Base",
columnCount: 12,
breakpoint: 0,
+ role: "base",
}),
).toMatchObject({
leftGutterColumnCount: 0,
@@ -26,6 +27,7 @@ describe("board layout gutters", () => {
leftGutterColumnCount: 3,
rightGutterColumnCount: 2,
breakpoint: 0,
+ role: "base",
}).success,
).toBe(true);
expect(
@@ -36,6 +38,7 @@ describe("board layout gutters", () => {
leftGutterColumnCount: 3,
rightGutterColumnCount: 3,
breakpoint: 0,
+ role: "base",
}).success,
).toBe(false);
});
diff --git a/packages/validation/src/board.spec.ts b/packages/validation/src/board.spec.ts
index c8220f445..d7e1b6ff7 100644
--- a/packages/validation/src/board.spec.ts
+++ b/packages/validation/src/board.spec.ts
@@ -1,9 +1,53 @@
-import { describe, expect, it } from "vitest";
+import { describe, expect, test } from "vitest";
+import { responsiveBoardLayoutsSchema } from "./board";
import { containerSectionOptionsDefaults, sectionSchema } from "./shared";
+const mobile = {
+ id: "mobile",
+ name: "Mobile",
+ columnCount: 3,
+ breakpoint: 0,
+ role: "mobile" as const,
+};
+const base = {
+ id: "base",
+ name: "Base",
+ columnCount: 12,
+ breakpoint: 768,
+ role: "base" as const,
+};
+
+describe("responsiveBoardLayoutsSchema", () => {
+ test("accepts protected layouts and custom layouts strictly between them", () => {
+ expect(
+ responsiveBoardLayoutsSchema.safeParse([
+ mobile,
+ { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" },
+ base,
+ ]).success,
+ ).toBe(true);
+ });
+
+ test.each([
+ { name: "missing Mobile", layouts: [{ ...mobile, role: "custom" }, base] },
+ { name: "removable Base replacement", layouts: [mobile, { ...base, role: "custom" }] },
+ { name: "non-zero Mobile breakpoint", layouts: [{ ...mobile, breakpoint: 320 }, base] },
+ {
+ name: "custom layout at Base breakpoint",
+ layouts: [mobile, { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 768, role: "custom" }, base],
+ },
+ {
+ name: "duplicate layout ID",
+ layouts: [mobile, { ...base, id: mobile.id }],
+ },
+ ])("rejects $name", ({ layouts }) => {
+ expect(responsiveBoardLayoutsSchema.safeParse(layouts).success).toBe(false);
+ });
+});
+
describe("section behavior validation", () => {
- it("normalizes a container to the current behavior defaults", () => {
+ test("normalizes a container to the current behavior defaults", () => {
const result = sectionSchema.parse({
id: "container",
kind: "container",
@@ -16,7 +60,7 @@ describe("section behavior validation", () => {
});
});
- it("preserves configured container behavior", () => {
+ test("preserves configured container behavior", () => {
const result = sectionSchema.parse({
id: "container",
kind: "container",
@@ -44,11 +88,19 @@ describe("section behavior validation", () => {
});
});
- it.each(["category", "dynamic"])("rejects the legacy %s kind", (kind) => {
- expect(() => sectionSchema.parse({ id: "legacy", kind, layouts: [] })).toThrow();
+ test("normalizes the legacy dynamic kind to a container", () => {
+ expect(sectionSchema.parse({ id: "legacy", kind: "dynamic", layouts: [] })).toMatchObject({
+ id: "legacy",
+ kind: "container",
+ layouts: [],
+ });
});
- it.each([
+ test("rejects the legacy category kind", () => {
+ expect(() => sectionSchema.parse({ id: "legacy", kind: "category", layouts: [] })).toThrow();
+ });
+
+ test.each([
{ xOffset: 0.5, yOffset: 0, width: 1, height: 1 },
{ xOffset: -1, yOffset: 0, width: 1, height: 1 },
{ xOffset: 0, yOffset: 0, width: 0, height: 1 },
diff --git a/packages/validation/src/board.ts b/packages/validation/src/board.ts
index 5bb77540a..3604ad49a 100644
--- a/packages/validation/src/board.ts
+++ b/packages/validation/src/board.ts
@@ -5,6 +5,7 @@ import {
backgroundImageRepeats,
backgroundImageSizes,
boardPermissions,
+ layoutRoles,
widgetKinds,
} from "@homarr/definitions";
@@ -78,16 +79,52 @@ export const boardLayoutSchema = z
columnCount: boardColumnCountSchema,
leftGutterColumnCount: boardGutterColumnCountSchema.default(0),
rightGutterColumnCount: boardGutterColumnCountSchema.default(0),
- breakpoint: z.number().min(0).max(32767),
+ breakpoint: z.number().int().min(0).max(32767),
+ role: z.enum(layoutRoles.values),
})
.refine((layout) => layout.leftGutterColumnCount + layout.rightGutterColumnCount < layout.columnCount, {
message: "Gutters must leave at least one dashboard column",
path: ["columnCount"],
});
+export const responsiveBoardLayoutsSchema = z
+ .array(boardLayoutSchema)
+ .min(2)
+ .superRefine((layouts, ctx) => {
+ const mobileLayouts = layouts.filter((layout) => layout.role === "mobile");
+ const baseLayouts = layouts.filter((layout) => layout.role === "base");
+ const mobileLayout = mobileLayouts.at(0);
+ const baseLayout = baseLayouts.at(0);
+ if (mobileLayouts.length !== 1 || baseLayouts.length !== 1 || !mobileLayout || !baseLayout) {
+ ctx.addIssue({ code: "custom", message: "Boards require exactly one Mobile and one Base layout" });
+ return;
+ }
+
+ if (mobileLayout.breakpoint !== 0) {
+ ctx.addIssue({ code: "custom", message: "The Mobile layout breakpoint must be 0" });
+ }
+
+ if (layouts.some((layout) => layout.id !== baseLayout.id && layout.breakpoint >= baseLayout.breakpoint)) {
+ ctx.addIssue({ code: "custom", message: "The Base layout must have the highest breakpoint" });
+ }
+
+ if (new Set(layouts.map((layout) => layout.breakpoint)).size !== layouts.length) {
+ ctx.addIssue({ code: "custom", message: "Layout breakpoints must be unique" });
+ }
+
+ if (new Set(layouts.map((layout) => layout.id)).size !== layouts.length) {
+ ctx.addIssue({ code: "custom", message: "Layout IDs must be unique" });
+ }
+ });
+
export const boardSaveLayoutsSchema = z.object({
id: z.string(),
- layouts: z.array(boardLayoutSchema),
+ layouts: responsiveBoardLayoutsSchema,
+});
+
+export const boardResetLayoutSchema = z.object({
+ boardId: z.string(),
+ layoutId: z.string(),
});
export const boardSaveSchema = z.object({
diff --git a/packages/validation/src/shared.ts b/packages/validation/src/shared.ts
index cd7ec8ac3..6a6a3e92d 100644
--- a/packages/validation/src/shared.ts
+++ b/packages/validation/src/shared.ts
@@ -93,4 +93,8 @@ const containerSectionSchema = z.object({
),
});
-export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema]);
+const legacyDynamicSectionSchema = containerSectionSchema
+ .extend({ kind: z.literal("dynamic") })
+ .transform((section) => ({ ...section, kind: "container" as const }));
+
+export const sectionSchema = z.union([emptySectionSchema, containerSectionSchema, legacyDynamicSectionSchema]);
diff --git a/packages/widgets/src/advanced-focus-support.spec.ts b/packages/widgets/src/advanced-focus-support.spec.ts
index 5e69c39fb..44eb51a45 100644
--- a/packages/widgets/src/advanced-focus-support.spec.ts
+++ b/packages/widgets/src/advanced-focus-support.spec.ts
@@ -10,7 +10,6 @@ const advancedFocusWidgetKinds = [
"bookmarks",
"calendar",
"clock",
- "dockerContainers",
"downloads",
"firewall",
"healthMonitoring",
diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts
index 2ca930913..bc229fed5 100644
--- a/packages/widgets/src/docker/index.ts
+++ b/packages/widgets/src/docker/index.ts
@@ -25,7 +25,6 @@ const columnTranslationKeyMap = {
export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", {
icon: IconBrandDocker,
- supportsAdvancedFocus: true,
queryKey: [["docker", "getContainers"]],
refetchInterval: 30,
createOptions() {