feat(mvp): phase 2 - database schema & services layer

Define full Prisma schema (10 models), run initial migration, build core
services (auth, user, group, app, board, permission), Zod validators,
type definitions, API response envelope, constants, and seed script.
This commit is contained in:
2026-03-24 20:00:21 +03:00
parent cf6bde238c
commit f1b1aa5975
28 changed files with 2936 additions and 28 deletions
@@ -0,0 +1,187 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password" TEXT,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"authProvider" TEXT NOT NULL DEFAULT 'local',
"role" TEXT NOT NULL DEFAULT 'user',
"refreshToken" TEXT,
"refreshTokenExpiresAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Group" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "UserGroup" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
CONSTRAINT "UserGroup_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserGroup_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "App" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"icon" TEXT,
"iconType" TEXT NOT NULL DEFAULT 'lucide',
"description" TEXT,
"category" TEXT,
"tags" TEXT NOT NULL DEFAULT '',
"healthcheckEnabled" BOOLEAN NOT NULL DEFAULT false,
"healthcheckInterval" INTEGER NOT NULL DEFAULT 300,
"healthcheckMethod" TEXT NOT NULL DEFAULT 'GET',
"healthcheckExpectedStatus" INTEGER NOT NULL DEFAULT 200,
"healthcheckTimeout" INTEGER NOT NULL DEFAULT 5000,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "App_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AppStatus" (
"id" TEXT NOT NULL PRIMARY KEY,
"appId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'unknown',
"responseTime" INTEGER,
"checkedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AppStatus_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Board" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"icon" TEXT,
"description" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"isGuestAccessible" BOOLEAN NOT NULL DEFAULT false,
"backgroundConfig" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Board_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Section" (
"id" TEXT NOT NULL PRIMARY KEY,
"boardId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"icon" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"isExpandedByDefault" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Section_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Widget" (
"id" TEXT NOT NULL PRIMARY KEY,
"sectionId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"config" TEXT NOT NULL DEFAULT '{}',
"appId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Widget_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Widget_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL PRIMARY KEY,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"level" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "SystemSettings" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'singleton',
"authMode" TEXT NOT NULL DEFAULT 'local',
"registrationEnabled" BOOLEAN NOT NULL DEFAULT true,
"oauthClientId" TEXT,
"oauthClientSecret" TEXT,
"oauthDiscoveryUrl" TEXT,
"defaultTheme" TEXT NOT NULL DEFAULT 'dark',
"defaultPrimaryColor" TEXT NOT NULL DEFAULT '#6366f1',
"healthcheckDefaults" TEXT NOT NULL DEFAULT '{}',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Group_name_key" ON "Group"("name");
-- CreateIndex
CREATE INDEX "UserGroup_userId_idx" ON "UserGroup"("userId");
-- CreateIndex
CREATE INDEX "UserGroup_groupId_idx" ON "UserGroup"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "UserGroup_userId_groupId_key" ON "UserGroup"("userId", "groupId");
-- CreateIndex
CREATE INDEX "App_name_idx" ON "App"("name");
-- CreateIndex
CREATE INDEX "App_category_idx" ON "App"("category");
-- CreateIndex
CREATE INDEX "App_createdById_idx" ON "App"("createdById");
-- CreateIndex
CREATE INDEX "AppStatus_appId_idx" ON "AppStatus"("appId");
-- CreateIndex
CREATE INDEX "AppStatus_checkedAt_idx" ON "AppStatus"("checkedAt");
-- CreateIndex
CREATE INDEX "Board_createdById_idx" ON "Board"("createdById");
-- CreateIndex
CREATE INDEX "Section_boardId_idx" ON "Section"("boardId");
-- CreateIndex
CREATE INDEX "Widget_sectionId_idx" ON "Widget"("sectionId");
-- CreateIndex
CREATE INDEX "Widget_appId_idx" ON "Widget"("appId");
-- CreateIndex
CREATE INDEX "Permission_entityType_entityId_idx" ON "Permission"("entityType", "entityId");
-- CreateIndex
CREATE INDEX "Permission_targetType_targetId_idx" ON "Permission"("targetType", "targetId");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_entityType_entityId_targetType_targetId_key" ON "Permission"("entityType", "entityId", "targetType", "targetId");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+164 -2
View File
@@ -1,5 +1,3 @@
// Prisma schema — models added in Phase 2
generator client {
provider = "prisma-client-js"
}
@@ -8,3 +6,167 @@ datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String?
displayName String
avatarUrl String?
authProvider String @default("local") // local | oauth
role String @default("user") // admin | user
refreshToken String?
refreshTokenExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
groups UserGroup[]
createdApps App[]
boards Board[]
@@index([email])
}
model Group {
id String @id @default(cuid())
name String @unique
description String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users UserGroup[]
}
model UserGroup {
id String @id @default(cuid())
userId String
groupId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId])
@@index([userId])
@@index([groupId])
}
model App {
id String @id @default(cuid())
name String
url String
icon String?
iconType String @default("lucide") // lucide | simple | url | emoji
description String?
category String?
tags String @default("") // comma-separated
healthcheckEnabled Boolean @default(false)
healthcheckInterval Int @default(300) // seconds
healthcheckMethod String @default("GET")
healthcheckExpectedStatus Int @default(200)
healthcheckTimeout Int @default(5000) // milliseconds
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
statuses AppStatus[]
widgets Widget[]
@@index([name])
@@index([category])
@@index([createdById])
}
model AppStatus {
id String @id @default(cuid())
appId String
status String @default("unknown") // online | offline | degraded | unknown
responseTime Int? // milliseconds
checkedAt DateTime @default(now())
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
@@index([appId])
@@index([checkedAt])
}
model Board {
id String @id @default(cuid())
name String
icon String?
description String?
isDefault Boolean @default(false)
isGuestAccessible Boolean @default(false)
backgroundConfig String? // JSON stored as string for SQLite
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
sections Section[]
@@index([createdById])
}
model Section {
id String @id @default(cuid())
boardId String
title String
icon String?
order Int @default(0)
isExpandedByDefault Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
widgets Widget[]
@@index([boardId])
}
model Widget {
id String @id @default(cuid())
sectionId String
type String // app | bookmark | note | embed | status
order Int @default(0)
config String @default("{}") // JSON stored as string for SQLite
appId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
app App? @relation(fields: [appId], references: [id], onDelete: SetNull)
@@index([sectionId])
@@index([appId])
}
model Permission {
id String @id @default(cuid())
entityType String // board | app
entityId String
targetType String // user | group
targetId String
level String // view | edit | admin
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([entityType, entityId, targetType, targetId])
@@index([entityType, entityId])
@@index([targetType, targetId])
}
model SystemSettings {
id String @id @default("singleton")
authMode String @default("local") // local | oauth | both
registrationEnabled Boolean @default(true)
oauthClientId String?
oauthClientSecret String?
oauthDiscoveryUrl String?
defaultTheme String @default("dark")
defaultPrimaryColor String @default("#6366f1")
healthcheckDefaults String @default("{}") // JSON stored as string for SQLite
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+275
View File
@@ -0,0 +1,275 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
// --- System Settings ---
const settings = await prisma.systemSettings.upsert({
where: { id: 'singleton' },
update: {},
create: {
id: 'singleton',
authMode: 'local',
registrationEnabled: true,
defaultTheme: 'dark',
defaultPrimaryColor: '#6366f1',
healthcheckDefaults: JSON.stringify({
interval: 300,
timeout: 5000,
method: 'GET',
expectedStatus: 200
})
}
});
console.log(' Created system settings:', settings.id);
// --- Admin User ---
const adminPassword = await bcrypt.hash('admin123', 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@localhost' },
update: {},
create: {
email: 'admin@localhost',
password: adminPassword,
displayName: 'Administrator',
role: 'admin',
authProvider: 'local'
}
});
console.log(' Created admin user:', admin.email);
// --- Groups ---
const adminGroup = await prisma.group.upsert({
where: { name: 'admin' },
update: {},
create: {
name: 'admin',
description: 'Administrators with full system access',
isDefault: false
}
});
console.log(' Created group:', adminGroup.name);
const userGroup = await prisma.group.upsert({
where: { name: 'user' },
update: {},
create: {
name: 'user',
description: 'Default group for all registered users',
isDefault: true
}
});
console.log(' Created group:', userGroup.name);
// --- User-Group memberships ---
await prisma.userGroup.upsert({
where: { userId_groupId: { userId: admin.id, groupId: adminGroup.id } },
update: {},
create: { userId: admin.id, groupId: adminGroup.id }
});
await prisma.userGroup.upsert({
where: { userId_groupId: { userId: admin.id, groupId: userGroup.id } },
update: {},
create: { userId: admin.id, groupId: userGroup.id }
});
console.log(' Added admin to groups');
// --- Sample Apps ---
const apps = [
{
name: 'Plex',
url: 'http://plex.local:32400',
icon: 'plex',
iconType: 'simple',
description: 'Media server for streaming movies, TV shows, and music',
category: 'Media',
tags: 'media,streaming,movies,tv',
healthcheckEnabled: true
},
{
name: 'Nextcloud',
url: 'http://nextcloud.local',
icon: 'nextcloud',
iconType: 'simple',
description: 'Self-hosted file sync, sharing, and collaboration platform',
category: 'Productivity',
tags: 'files,sync,cloud,office',
healthcheckEnabled: true
},
{
name: 'Gitea',
url: 'http://gitea.local:3000',
icon: 'gitea',
iconType: 'simple',
description: 'Lightweight self-hosted Git service',
category: 'Development',
tags: 'git,code,development,ci',
healthcheckEnabled: true
},
{
name: 'Home Assistant',
url: 'http://homeassistant.local:8123',
icon: 'homeassistant',
iconType: 'simple',
description: 'Open-source home automation platform',
category: 'Home Automation',
tags: 'home,automation,iot,smart-home',
healthcheckEnabled: true
},
{
name: 'Grafana',
url: 'http://grafana.local:3000',
icon: 'grafana',
iconType: 'simple',
description: 'Analytics and monitoring dashboards',
category: 'Monitoring',
tags: 'monitoring,analytics,dashboards,metrics',
healthcheckEnabled: true
}
];
const createdApps = [];
for (const appData of apps) {
const app = await prisma.app.upsert({
where: { id: appData.name.toLowerCase().replace(/\s+/g, '-') },
update: {},
create: {
...appData,
createdById: admin.id
}
});
createdApps.push(app);
console.log(' Created app:', app.name);
}
// --- Default Board ---
const board = await prisma.board.upsert({
where: { id: 'default-board' },
update: {},
create: {
id: 'default-board',
name: 'Dashboard',
icon: 'layout-dashboard',
description: 'Default application dashboard',
isDefault: true,
isGuestAccessible: true,
createdById: admin.id
}
});
console.log(' Created board:', board.name);
// --- Sections ---
const mediaSection = await prisma.section.upsert({
where: { id: 'section-media' },
update: {},
create: {
id: 'section-media',
boardId: board.id,
title: 'Media & Entertainment',
icon: 'tv',
order: 0,
isExpandedByDefault: true
}
});
console.log(' Created section:', mediaSection.title);
const infraSection = await prisma.section.upsert({
where: { id: 'section-infra' },
update: {},
create: {
id: 'section-infra',
boardId: board.id,
title: 'Infrastructure & Tools',
icon: 'server',
order: 1,
isExpandedByDefault: true
}
});
console.log(' Created section:', infraSection.title);
// --- Widgets ---
// Plex widget in media section
await prisma.widget.upsert({
where: { id: 'widget-plex' },
update: {},
create: {
id: 'widget-plex',
sectionId: mediaSection.id,
type: 'app',
order: 0,
appId: createdApps[0].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
// Nextcloud widget in infra section
await prisma.widget.upsert({
where: { id: 'widget-nextcloud' },
update: {},
create: {
id: 'widget-nextcloud',
sectionId: infraSection.id,
type: 'app',
order: 0,
appId: createdApps[1].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
// Gitea widget in infra section
await prisma.widget.upsert({
where: { id: 'widget-gitea' },
update: {},
create: {
id: 'widget-gitea',
sectionId: infraSection.id,
type: 'app',
order: 1,
appId: createdApps[2].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
// Home Assistant widget in infra section
await prisma.widget.upsert({
where: { id: 'widget-homeassistant' },
update: {},
create: {
id: 'widget-homeassistant',
sectionId: infraSection.id,
type: 'app',
order: 2,
appId: createdApps[3].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
// Grafana widget in infra section
await prisma.widget.upsert({
where: { id: 'widget-grafana' },
update: {},
create: {
id: 'widget-grafana',
sectionId: infraSection.id,
type: 'app',
order: 3,
appId: createdApps[4].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
console.log(' Created widgets for all apps');
console.log('Seeding complete!');
}
main()
.catch((e) => {
console.error('Seed error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});