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
+3 -2
View File
@@ -10,8 +10,9 @@ declare global {
interface Locals {
user: {
id: string;
username: string;
role: 'admin' | 'user' | 'guest';
email: string;
displayName: string;
role: 'admin' | 'user';
} | null;
session: {
id: string;
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
+148
View File
@@ -0,0 +1,148 @@
import { prisma } from '../prisma.js';
import type { CreateAppInput, UpdateAppInput } from '$lib/types/app.js';
export async function findAll(options?: { category?: string; search?: string }) {
const where: Record<string, unknown> = {};
if (options?.category) {
where.category = options.category;
}
if (options?.search) {
where.OR = [
{ name: { contains: options.search } },
{ description: { contains: options.search } },
{ tags: { contains: options.search } }
];
}
return prisma.app.findMany({
where,
orderBy: { name: 'asc' },
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
});
}
export async function findById(id: string) {
const app = await prisma.app.findUnique({
where: { id },
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
},
createdBy: {
select: { id: true, displayName: true }
}
}
});
if (!app) {
throw new Error(`App not found: ${id}`);
}
return app;
}
export async function create(input: CreateAppInput) {
return prisma.app.create({
data: {
name: input.name,
url: input.url,
icon: input.icon ?? null,
iconType: input.iconType ?? 'lucide',
description: input.description ?? null,
category: input.category ?? null,
tags: input.tags ?? '',
healthcheckEnabled: input.healthcheckEnabled ?? false,
healthcheckInterval: input.healthcheckInterval ?? 300,
healthcheckMethod: input.healthcheckMethod ?? 'GET',
healthcheckExpectedStatus: input.healthcheckExpectedStatus ?? 200,
healthcheckTimeout: input.healthcheckTimeout ?? 5000,
createdById: input.createdById ?? null
}
});
}
export async function update(id: string, input: UpdateAppInput) {
await findById(id);
const data: Record<string, unknown> = {};
if (input.name !== undefined) data.name = input.name;
if (input.url !== undefined) data.url = input.url;
if (input.icon !== undefined) data.icon = input.icon;
if (input.iconType !== undefined) data.iconType = input.iconType;
if (input.description !== undefined) data.description = input.description;
if (input.category !== undefined) data.category = input.category;
if (input.tags !== undefined) data.tags = input.tags;
if (input.healthcheckEnabled !== undefined) data.healthcheckEnabled = input.healthcheckEnabled;
if (input.healthcheckInterval !== undefined) data.healthcheckInterval = input.healthcheckInterval;
if (input.healthcheckMethod !== undefined) data.healthcheckMethod = input.healthcheckMethod;
if (input.healthcheckExpectedStatus !== undefined) data.healthcheckExpectedStatus = input.healthcheckExpectedStatus;
if (input.healthcheckTimeout !== undefined) data.healthcheckTimeout = input.healthcheckTimeout;
return prisma.app.update({
where: { id },
data
});
}
export async function remove(id: string) {
await findById(id);
await prisma.app.delete({ where: { id } });
}
export async function recordStatus(
appId: string,
status: string,
responseTime: number | null
) {
return prisma.appStatus.create({
data: {
appId,
status,
responseTime
}
});
}
export async function getLatestStatus(appId: string) {
return prisma.appStatus.findFirst({
where: { appId },
orderBy: { checkedAt: 'desc' }
});
}
export async function getStatusHistory(appId: string, limit: number = 50) {
return prisma.appStatus.findMany({
where: { appId },
orderBy: { checkedAt: 'desc' },
take: limit
});
}
export async function getHealthcheckTargets() {
return prisma.app.findMany({
where: { healthcheckEnabled: true },
select: {
id: true,
name: true,
url: true,
healthcheckMethod: true,
healthcheckExpectedStatus: true,
healthcheckTimeout: true
}
});
}
export async function getCategories() {
const apps = await prisma.app.findMany({
where: { category: { not: null } },
select: { category: true },
distinct: ['category']
});
return apps.map((a) => a.category).filter(Boolean) as string[];
}
+117
View File
@@ -0,0 +1,117 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import type { JwtPayload, TokenPair } from '$lib/types/auth.js';
const SALT_ROUNDS = 12;
function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error('JWT_SECRET environment variable is not set');
}
return secret;
}
function getJwtExpiry(): string {
return process.env.JWT_EXPIRY || DEFAULTS.JWT_EXPIRY;
}
function getRefreshTokenExpiryDays(): number {
const envValue = process.env.REFRESH_TOKEN_EXPIRY;
if (envValue) {
const days = parseInt(envValue.replace('d', ''), 10);
if (!isNaN(days)) return days;
}
return DEFAULTS.REFRESH_TOKEN_EXPIRY_DAYS;
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export function signAccessToken(payload: JwtPayload): string {
return jwt.sign(payload, getJwtSecret(), {
expiresIn: getJwtExpiry()
});
}
export function verifyAccessToken(token: string): JwtPayload {
try {
const decoded = jwt.verify(token, getJwtSecret()) as JwtPayload & jwt.JwtPayload;
return {
userId: decoded.userId,
email: decoded.email,
role: decoded.role
};
} catch {
throw new Error('Invalid or expired access token');
}
}
export function generateRefreshToken(): string {
const bytes = new Uint8Array(48);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
export function getRefreshTokenExpiry(): Date {
const days = getRefreshTokenExpiryDays();
const expiry = new Date();
expiry.setDate(expiry.getDate() + days);
return expiry;
}
export async function saveRefreshToken(userId: string, refreshToken: string): Promise<void> {
const hashedToken = await bcrypt.hash(refreshToken, SALT_ROUNDS);
await prisma.user.update({
where: { id: userId },
data: {
refreshToken: hashedToken,
refreshTokenExpiresAt: getRefreshTokenExpiry()
}
});
}
export async function validateRefreshToken(
userId: string,
refreshToken: string
): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { refreshToken: true, refreshTokenExpiresAt: true }
});
if (!user?.refreshToken || !user.refreshTokenExpiresAt) {
return false;
}
if (new Date() > user.refreshTokenExpiresAt) {
return false;
}
return bcrypt.compare(refreshToken, user.refreshToken);
}
export async function revokeRefreshToken(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
refreshToken: null,
refreshTokenExpiresAt: null
}
});
}
export async function rotateTokens(userId: string, email: string, role: string): Promise<TokenPair> {
const accessToken = signAccessToken({ userId, email, role });
const refreshToken = generateRefreshToken();
await saveRefreshToken(userId, refreshToken);
return { accessToken, refreshToken };
}
+263
View File
@@ -0,0 +1,263 @@
import { prisma } from '../prisma.js';
import type { CreateBoardInput, UpdateBoardInput, CreateSectionInput, UpdateSectionInput } from '$lib/types/board.js';
import type { CreateWidgetInput, UpdateWidgetInput } from '$lib/types/widget.js';
// --- Board ---
export async function findAllBoards() {
return prisma.board.findMany({
orderBy: { createdAt: 'asc' },
include: {
_count: { select: { sections: true } }
}
});
}
export async function findBoardById(id: string) {
const board = await prisma.board.findUnique({
where: { id },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
}
}
}
}
});
if (!board) {
throw new Error(`Board not found: ${id}`);
}
return board;
}
export async function findDefaultBoard() {
return prisma.board.findFirst({
where: { isDefault: true },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
}
}
}
}
});
}
export async function findGuestAccessibleBoards() {
return prisma.board.findMany({
where: { isGuestAccessible: true },
orderBy: { createdAt: 'asc' },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
}
}
}
}
});
}
export async function createBoard(input: CreateBoardInput) {
// If this board is default, unset other defaults
if (input.isDefault) {
await prisma.board.updateMany({
where: { isDefault: true },
data: { isDefault: false }
});
}
return prisma.board.create({
data: {
name: input.name,
icon: input.icon ?? null,
description: input.description ?? null,
isDefault: input.isDefault ?? false,
isGuestAccessible: input.isGuestAccessible ?? false,
backgroundConfig: input.backgroundConfig ?? null,
createdById: input.createdById ?? null
}
});
}
export async function updateBoard(id: string, input: UpdateBoardInput) {
await findBoardById(id);
if (input.isDefault) {
await prisma.board.updateMany({
where: { isDefault: true, NOT: { id } },
data: { isDefault: false }
});
}
const data: Record<string, unknown> = {};
if (input.name !== undefined) data.name = input.name;
if (input.icon !== undefined) data.icon = input.icon;
if (input.description !== undefined) data.description = input.description;
if (input.isDefault !== undefined) data.isDefault = input.isDefault;
if (input.isGuestAccessible !== undefined) data.isGuestAccessible = input.isGuestAccessible;
if (input.backgroundConfig !== undefined) data.backgroundConfig = input.backgroundConfig;
return prisma.board.update({
where: { id },
data
});
}
export async function removeBoard(id: string) {
await findBoardById(id);
await prisma.board.delete({ where: { id } });
}
// --- Section ---
export async function findSectionById(id: string) {
const section = await prisma.section.findUnique({
where: { id },
include: {
widgets: {
orderBy: { order: 'asc' }
}
}
});
if (!section) {
throw new Error(`Section not found: ${id}`);
}
return section;
}
export async function createSection(input: CreateSectionInput) {
// Auto-calculate order if not provided
let order = input.order;
if (order === undefined) {
const maxSection = await prisma.section.findFirst({
where: { boardId: input.boardId },
orderBy: { order: 'desc' },
select: { order: true }
});
order = (maxSection?.order ?? -1) + 1;
}
return prisma.section.create({
data: {
boardId: input.boardId,
title: input.title,
icon: input.icon ?? null,
order,
isExpandedByDefault: input.isExpandedByDefault ?? true
}
});
}
export async function updateSection(id: string, input: UpdateSectionInput) {
await findSectionById(id);
const data: Record<string, unknown> = {};
if (input.title !== undefined) data.title = input.title;
if (input.icon !== undefined) data.icon = input.icon;
if (input.order !== undefined) data.order = input.order;
if (input.isExpandedByDefault !== undefined) data.isExpandedByDefault = input.isExpandedByDefault;
return prisma.section.update({
where: { id },
data
});
}
export async function removeSection(id: string) {
await findSectionById(id);
await prisma.section.delete({ where: { id } });
}
// --- Widget ---
export async function findWidgetById(id: string) {
const widget = await prisma.widget.findUnique({
where: { id },
include: { app: true }
});
if (!widget) {
throw new Error(`Widget not found: ${id}`);
}
return widget;
}
export async function createWidget(input: CreateWidgetInput) {
let order = input.order;
if (order === undefined) {
const maxWidget = await prisma.widget.findFirst({
where: { sectionId: input.sectionId },
orderBy: { order: 'desc' },
select: { order: true }
});
order = (maxWidget?.order ?? -1) + 1;
}
return prisma.widget.create({
data: {
sectionId: input.sectionId,
type: input.type,
order,
config: input.config ?? '{}',
appId: input.appId ?? null
}
});
}
export async function updateWidget(id: string, input: UpdateWidgetInput) {
await findWidgetById(id);
const data: Record<string, unknown> = {};
if (input.type !== undefined) data.type = input.type;
if (input.order !== undefined) data.order = input.order;
if (input.config !== undefined) data.config = input.config;
if (input.appId !== undefined) data.appId = input.appId;
return prisma.widget.update({
where: { id },
data
});
}
export async function removeWidget(id: string) {
await findWidgetById(id);
await prisma.widget.delete({ where: { id } });
}
+125
View File
@@ -0,0 +1,125 @@
import { prisma } from '../prisma.js';
import type { CreateGroupInput, UpdateGroupInput } from '$lib/types/group.js';
export async function findAll() {
return prisma.group.findMany({
orderBy: { name: 'asc' },
include: {
_count: { select: { users: true } }
}
});
}
export async function findById(id: string) {
const group = await prisma.group.findUnique({
where: { id },
include: {
_count: { select: { users: true } }
}
});
if (!group) {
throw new Error(`Group not found: ${id}`);
}
return group;
}
export async function findByName(name: string) {
return prisma.group.findUnique({
where: { name }
});
}
export async function findDefaultGroups() {
return prisma.group.findMany({
where: { isDefault: true }
});
}
export async function create(input: CreateGroupInput) {
const existing = await prisma.group.findUnique({
where: { name: input.name }
});
if (existing) {
throw new Error(`Group with name "${input.name}" already exists`);
}
return prisma.group.create({
data: {
name: input.name,
description: input.description ?? null,
isDefault: input.isDefault ?? false
}
});
}
export async function update(id: string, input: UpdateGroupInput) {
await findById(id);
if (input.name) {
const existing = await prisma.group.findFirst({
where: { name: input.name, NOT: { id } }
});
if (existing) {
throw new Error(`Group with name "${input.name}" already exists`);
}
}
return prisma.group.update({
where: { id },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {})
}
});
}
export async function remove(id: string) {
await findById(id);
await prisma.group.delete({ where: { id } });
}
export async function addUser(groupId: string, userId: string) {
const existing = await prisma.userGroup.findUnique({
where: { userId_groupId: { userId, groupId } }
});
if (existing) {
return existing;
}
return prisma.userGroup.create({
data: { userId, groupId }
});
}
export async function removeUser(groupId: string, userId: string) {
await prisma.userGroup.deleteMany({
where: { userId, groupId }
});
}
export async function getGroupMembers(groupId: string) {
const memberships = await prisma.userGroup.findMany({
where: { groupId },
include: {
user: {
select: {
id: true,
email: true,
displayName: true,
role: true,
avatarUrl: true
}
}
}
});
return memberships.map((m) => m.user);
}
export async function addUserToDefaultGroups(userId: string) {
const defaultGroups = await findDefaultGroups();
const results = await Promise.all(
defaultGroups.map((group) => addUser(group.id, userId))
);
return results;
}
@@ -0,0 +1,157 @@
import { prisma } from '../prisma.js';
import {
UserRole,
PermissionLevel,
PERMISSION_HIERARCHY,
TargetType,
type EntityType,
type TargetType as TargetTypeType
} from '$lib/utils/constants.js';
import type { CreatePermissionInput, PermissionCheckResult } from '$lib/types/permission.js';
export async function checkPermission(
entityType: EntityType,
entityId: string,
userId: string,
requiredLevel: string
): Promise<PermissionCheckResult> {
// Admins always have full access
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true }
});
if (user?.role === UserRole.ADMIN) {
return {
hasPermission: true,
effectiveLevel: PermissionLevel.ADMIN,
source: 'admin'
};
}
// Check direct user permission
const userPermission = await prisma.permission.findFirst({
where: {
entityType,
entityId,
targetType: TargetType.USER,
targetId: userId
}
});
if (userPermission) {
const hasAccess =
PERMISSION_HIERARCHY[userPermission.level] >= PERMISSION_HIERARCHY[requiredLevel];
return {
hasPermission: hasAccess,
effectiveLevel: userPermission.level as PermissionCheckResult['effectiveLevel'],
source: 'user'
};
}
// Check group permissions
const userGroups = await prisma.userGroup.findMany({
where: { userId },
select: { groupId: true }
});
if (userGroups.length > 0) {
const groupIds = userGroups.map((ug) => ug.groupId);
const groupPermissions = await prisma.permission.findMany({
where: {
entityType,
entityId,
targetType: TargetType.GROUP,
targetId: { in: groupIds }
}
});
if (groupPermissions.length > 0) {
// Use the highest group permission
const highestLevel = groupPermissions.reduce((highest, perm) => {
const permLevel = PERMISSION_HIERARCHY[perm.level] ?? 0;
const highestScore = PERMISSION_HIERARCHY[highest] ?? 0;
return permLevel > highestScore ? perm.level : highest;
}, groupPermissions[0].level);
const hasAccess =
PERMISSION_HIERARCHY[highestLevel] >= PERMISSION_HIERARCHY[requiredLevel];
return {
hasPermission: hasAccess,
effectiveLevel: highestLevel as PermissionCheckResult['effectiveLevel'],
source: 'group'
};
}
}
return {
hasPermission: false,
effectiveLevel: null,
source: null
};
}
export async function grantPermission(input: CreatePermissionInput) {
return prisma.permission.upsert({
where: {
entityType_entityId_targetType_targetId: {
entityType: input.entityType,
entityId: input.entityId,
targetType: input.targetType,
targetId: input.targetId
}
},
update: {
level: input.level
},
create: {
entityType: input.entityType,
entityId: input.entityId,
targetType: input.targetType,
targetId: input.targetId,
level: input.level
}
});
}
export async function revokePermission(
entityType: EntityType,
entityId: string,
targetType: TargetTypeType,
targetId: string
) {
await prisma.permission.deleteMany({
where: {
entityType,
entityId,
targetType,
targetId
}
});
}
export async function getPermissionsForEntity(entityType: EntityType, entityId: string) {
return prisma.permission.findMany({
where: { entityType, entityId },
orderBy: { createdAt: 'asc' }
});
}
export async function getPermissionsForTarget(
targetType: TargetTypeType,
targetId: string
) {
return prisma.permission.findMany({
where: { targetType, targetId },
orderBy: { createdAt: 'asc' }
});
}
export async function removeAllPermissionsForEntity(
entityType: EntityType,
entityId: string
) {
await prisma.permission.deleteMany({
where: { entityType, entityId }
});
}
+104
View File
@@ -0,0 +1,104 @@
import { prisma } from '../prisma.js';
import { hashPassword } from './authService.js';
import type { CreateUserInput, UpdateUserInput } from '$lib/types/user.js';
const USER_SELECT = {
id: true,
email: true,
displayName: true,
avatarUrl: true,
authProvider: true,
role: true,
createdAt: true,
updatedAt: true
} as const;
export async function findAll() {
return prisma.user.findMany({
select: USER_SELECT,
orderBy: { createdAt: 'desc' }
});
}
export async function findById(id: string) {
const user = await prisma.user.findUnique({
where: { id },
select: USER_SELECT
});
if (!user) {
throw new Error(`User not found: ${id}`);
}
return user;
}
export async function findByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
select: {
...USER_SELECT,
password: true
}
});
}
export async function create(input: CreateUserInput) {
const existing = await prisma.user.findUnique({
where: { email: input.email }
});
if (existing) {
throw new Error(`User with email ${input.email} already exists`);
}
const hashedPassword = input.password ? await hashPassword(input.password) : null;
return prisma.user.create({
data: {
email: input.email,
password: hashedPassword,
displayName: input.displayName,
avatarUrl: input.avatarUrl ?? null,
authProvider: input.authProvider ?? 'local',
role: input.role ?? 'user'
},
select: USER_SELECT
});
}
export async function update(id: string, input: UpdateUserInput) {
await findById(id); // Ensure user exists
return prisma.user.update({
where: { id },
data: {
...(input.displayName !== undefined ? { displayName: input.displayName } : {}),
...(input.avatarUrl !== undefined ? { avatarUrl: input.avatarUrl } : {}),
...(input.role !== undefined ? { role: input.role } : {})
},
select: USER_SELECT
});
}
export async function remove(id: string) {
await findById(id); // Ensure user exists
await prisma.user.delete({ where: { id } });
}
export async function updateRole(id: string, role: string) {
return prisma.user.update({
where: { id },
data: { role },
select: USER_SELECT
});
}
export async function getUserGroups(userId: string) {
const memberships = await prisma.userGroup.findMany({
where: { userId },
include: { group: true }
});
return memberships.map((m) => m.group);
}
export async function count() {
return prisma.user.count();
}
+41
View File
@@ -0,0 +1,41 @@
export interface ApiResponse<T = unknown> {
readonly success: boolean;
readonly data: T | null;
readonly error: string | null;
readonly meta?: {
readonly total?: number;
readonly page?: number;
readonly limit?: number;
};
}
export function success<T>(data: T, meta?: ApiResponse['meta']): ApiResponse<T> {
return {
success: true,
data,
error: null,
...(meta ? { meta } : {})
};
}
export function error(message: string): ApiResponse<null> {
return {
success: false,
data: null,
error: message
};
}
export function paginated<T>(
data: T,
total: number,
page: number,
limit: number
): ApiResponse<T> {
return {
success: true,
data,
error: null,
meta: { total, page, limit }
};
}
+59
View File
@@ -0,0 +1,59 @@
import type { IconType, HealthcheckMethod, AppStatusValue } from '$lib/utils/constants';
export interface AppRecord {
readonly id: string;
readonly name: string;
readonly url: string;
readonly icon: string | null;
readonly iconType: IconType;
readonly description: string | null;
readonly category: string | null;
readonly tags: string;
readonly healthcheckEnabled: boolean;
readonly healthcheckInterval: number;
readonly healthcheckMethod: string;
readonly healthcheckExpectedStatus: number;
readonly healthcheckTimeout: number;
readonly createdById: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateAppInput {
readonly name: string;
readonly url: string;
readonly icon?: string;
readonly iconType?: IconType;
readonly description?: string;
readonly category?: string;
readonly tags?: string;
readonly healthcheckEnabled?: boolean;
readonly healthcheckInterval?: number;
readonly healthcheckMethod?: HealthcheckMethod;
readonly healthcheckExpectedStatus?: number;
readonly healthcheckTimeout?: number;
readonly createdById?: string;
}
export interface UpdateAppInput {
readonly name?: string;
readonly url?: string;
readonly icon?: string | null;
readonly iconType?: IconType;
readonly description?: string | null;
readonly category?: string | null;
readonly tags?: string;
readonly healthcheckEnabled?: boolean;
readonly healthcheckInterval?: number;
readonly healthcheckMethod?: HealthcheckMethod;
readonly healthcheckExpectedStatus?: number;
readonly healthcheckTimeout?: number;
}
export interface AppStatusRecord {
readonly id: string;
readonly appId: string;
readonly status: AppStatusValue;
readonly responseTime: number | null;
readonly checkedAt: Date;
}
+28
View File
@@ -0,0 +1,28 @@
export interface JwtPayload {
readonly userId: string;
readonly email: string;
readonly role: string;
}
export interface TokenPair {
readonly accessToken: string;
readonly refreshToken: string;
}
export interface LoginRequest {
readonly email: string;
readonly password: string;
}
export interface RegisterRequest {
readonly email: string;
readonly password: string;
readonly displayName: string;
}
export interface AuthSession {
readonly userId: string;
readonly email: string;
readonly role: string;
readonly expiresAt: Date;
}
+57
View File
@@ -0,0 +1,57 @@
export interface BoardRecord {
readonly id: string;
readonly name: string;
readonly icon: string | null;
readonly description: string | null;
readonly isDefault: boolean;
readonly isGuestAccessible: boolean;
readonly backgroundConfig: string | null;
readonly createdById: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateBoardInput {
readonly name: string;
readonly icon?: string;
readonly description?: string;
readonly isDefault?: boolean;
readonly isGuestAccessible?: boolean;
readonly backgroundConfig?: string;
readonly createdById?: string;
}
export interface UpdateBoardInput {
readonly name?: string;
readonly icon?: string | null;
readonly description?: string | null;
readonly isDefault?: boolean;
readonly isGuestAccessible?: boolean;
readonly backgroundConfig?: string | null;
}
export interface SectionRecord {
readonly id: string;
readonly boardId: string;
readonly title: string;
readonly icon: string | null;
readonly order: number;
readonly isExpandedByDefault: boolean;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateSectionInput {
readonly boardId: string;
readonly title: string;
readonly icon?: string;
readonly order?: number;
readonly isExpandedByDefault?: boolean;
}
export interface UpdateSectionInput {
readonly title?: string;
readonly icon?: string | null;
readonly order?: number;
readonly isExpandedByDefault?: boolean;
}
+20
View File
@@ -0,0 +1,20 @@
export interface GroupRecord {
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly isDefault: boolean;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateGroupInput {
readonly name: string;
readonly description?: string;
readonly isDefault?: boolean;
}
export interface UpdateGroupInput {
readonly name?: string;
readonly description?: string | null;
readonly isDefault?: boolean;
}
+7
View File
@@ -0,0 +1,7 @@
export type * from './auth.js';
export type * from './user.js';
export type * from './group.js';
export type * from './app.js';
export type * from './board.js';
export type * from './widget.js';
export type * from './permission.js';
+26
View File
@@ -0,0 +1,26 @@
import type { EntityType, TargetType, PermissionLevel } from '$lib/utils/constants';
export interface PermissionRecord {
readonly id: string;
readonly entityType: EntityType;
readonly entityId: string;
readonly targetType: TargetType;
readonly targetId: string;
readonly level: PermissionLevel;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreatePermissionInput {
readonly entityType: EntityType;
readonly entityId: string;
readonly targetType: TargetType;
readonly targetId: string;
readonly level: PermissionLevel;
}
export interface PermissionCheckResult {
readonly hasPermission: boolean;
readonly effectiveLevel: PermissionLevel | null;
readonly source: 'user' | 'group' | 'admin' | null;
}
+27
View File
@@ -0,0 +1,27 @@
import type { UserRole, AuthProvider } from '$lib/utils/constants';
export interface UserRecord {
readonly id: string;
readonly email: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly authProvider: AuthProvider;
readonly role: UserRole;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateUserInput {
readonly email: string;
readonly password?: string;
readonly displayName: string;
readonly avatarUrl?: string;
readonly authProvider?: AuthProvider;
readonly role?: UserRole;
}
export interface UpdateUserInput {
readonly displayName?: string;
readonly avatarUrl?: string | null;
readonly role?: UserRole;
}
+55
View File
@@ -0,0 +1,55 @@
import type { WidgetType } from '$lib/utils/constants';
export interface WidgetRecord {
readonly id: string;
readonly sectionId: string;
readonly type: WidgetType;
readonly order: number;
readonly config: string;
readonly appId: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateWidgetInput {
readonly sectionId: string;
readonly type: WidgetType;
readonly order?: number;
readonly config?: string;
readonly appId?: string;
}
export interface UpdateWidgetInput {
readonly type?: WidgetType;
readonly order?: number;
readonly config?: string;
readonly appId?: string | null;
}
// Typed config shapes for different widget types
export interface AppWidgetConfig {
readonly appId: string;
readonly showStatus?: boolean;
readonly openInNewTab?: boolean;
}
export interface BookmarkWidgetConfig {
readonly url: string;
readonly title: string;
readonly icon?: string;
readonly openInNewTab?: boolean;
}
export interface NoteWidgetConfig {
readonly content: string;
}
export interface EmbedWidgetConfig {
readonly url: string;
readonly height?: number;
}
export interface StatusWidgetConfig {
readonly appIds: readonly string[];
readonly layout?: 'grid' | 'list';
}
+98
View File
@@ -0,0 +1,98 @@
// User roles
export const UserRole = {
ADMIN: 'admin',
USER: 'user'
} as const;
export type UserRole = (typeof UserRole)[keyof typeof UserRole];
// Authentication modes
export const AuthMode = {
LOCAL: 'local',
OAUTH: 'oauth',
BOTH: 'both'
} as const;
export type AuthMode = (typeof AuthMode)[keyof typeof AuthMode];
// Auth providers
export const AuthProvider = {
LOCAL: 'local',
OAUTH: 'oauth'
} as const;
export type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider];
// App status values
export const AppStatusValue = {
ONLINE: 'online',
OFFLINE: 'offline',
DEGRADED: 'degraded',
UNKNOWN: 'unknown'
} as const;
export type AppStatusValue = (typeof AppStatusValue)[keyof typeof AppStatusValue];
// Widget types
export const WidgetType = {
APP: 'app',
BOOKMARK: 'bookmark',
NOTE: 'note',
EMBED: 'embed',
STATUS: 'status'
} as const;
export type WidgetType = (typeof WidgetType)[keyof typeof WidgetType];
// Icon types
export const IconType = {
LUCIDE: 'lucide',
SIMPLE: 'simple',
URL: 'url',
EMOJI: 'emoji'
} as const;
export type IconType = (typeof IconType)[keyof typeof IconType];
// Permission levels (ordered by privilege)
export const PermissionLevel = {
VIEW: 'view',
EDIT: 'edit',
ADMIN: 'admin'
} as const;
export type PermissionLevel = (typeof PermissionLevel)[keyof typeof PermissionLevel];
// Permission hierarchy for comparison
export const PERMISSION_HIERARCHY: Record<string, number> = {
[PermissionLevel.VIEW]: 1,
[PermissionLevel.EDIT]: 2,
[PermissionLevel.ADMIN]: 3
};
// Entity types for permissions
export const EntityType = {
BOARD: 'board',
APP: 'app'
} as const;
export type EntityType = (typeof EntityType)[keyof typeof EntityType];
// Target types for permissions
export const TargetType = {
USER: 'user',
GROUP: 'group'
} as const;
export type TargetType = (typeof TargetType)[keyof typeof TargetType];
// Healthcheck method
export const HealthcheckMethod = {
GET: 'GET',
HEAD: 'HEAD'
} as const;
export type HealthcheckMethod = (typeof HealthcheckMethod)[keyof typeof HealthcheckMethod];
// Defaults
export const DEFAULTS = {
HEALTHCHECK_INTERVAL: 300,
HEALTHCHECK_TIMEOUT: 5000,
HEALTHCHECK_EXPECTED_STATUS: 200,
HEALTHCHECK_METHOD: 'GET',
JWT_EXPIRY: '15m',
REFRESH_TOKEN_EXPIRY_DAYS: 7,
DEFAULT_THEME: 'dark',
DEFAULT_PRIMARY_COLOR: '#6366f1',
SYSTEM_SETTINGS_ID: 'singleton'
} as const;
+169
View File
@@ -0,0 +1,169 @@
import { z } from 'zod';
import {
UserRole,
AuthMode,
WidgetType,
IconType,
PermissionLevel,
EntityType,
TargetType,
HealthcheckMethod
} from './constants.js';
// --- Auth ---
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required')
});
export const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
displayName: z.string().min(1, 'Display name is required').max(100)
});
// --- User ---
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6).optional(),
displayName: z.string().min(1).max(100),
avatarUrl: z.string().url().optional(),
authProvider: z.enum([AuthMode.LOCAL, AuthMode.OAUTH]).optional(),
role: z.enum([UserRole.ADMIN, UserRole.USER]).optional()
});
export const updateUserSchema = z.object({
displayName: z.string().min(1).max(100).optional(),
avatarUrl: z.string().url().nullable().optional(),
role: z.enum([UserRole.ADMIN, UserRole.USER]).optional()
});
// --- Group ---
export const createGroupSchema = z.object({
name: z.string().min(1, 'Group name is required').max(100),
description: z.string().max(500).optional(),
isDefault: z.boolean().optional()
});
export const updateGroupSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
isDefault: z.boolean().optional()
});
// --- App ---
export const createAppSchema = z.object({
name: z.string().min(1, 'App name is required').max(200),
url: z.string().url('Invalid URL'),
icon: z.string().max(500).optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).optional(),
category: z.string().max(100).optional(),
tags: z.string().max(500).optional(),
healthcheckEnabled: z.boolean().optional(),
healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(),
healthcheckTimeout: z.number().int().min(1000).max(30000).optional()
});
export const updateAppSchema = z.object({
name: z.string().min(1).max(200).optional(),
url: z.string().url().optional(),
icon: z.string().max(500).nullable().optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).nullable().optional(),
category: z.string().max(100).nullable().optional(),
tags: z.string().max(500).optional(),
healthcheckEnabled: z.boolean().optional(),
healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(),
healthcheckTimeout: z.number().int().min(1000).max(30000).optional()
});
// --- Board ---
export const createBoardSchema = z.object({
name: z.string().min(1, 'Board name is required').max(200),
icon: z.string().max(500).optional(),
description: z.string().max(1000).optional(),
isDefault: z.boolean().optional(),
isGuestAccessible: z.boolean().optional(),
backgroundConfig: z.string().optional()
});
export const updateBoardSchema = z.object({
name: z.string().min(1).max(200).optional(),
icon: z.string().max(500).nullable().optional(),
description: z.string().max(1000).nullable().optional(),
isDefault: z.boolean().optional(),
isGuestAccessible: z.boolean().optional(),
backgroundConfig: z.string().nullable().optional()
});
// --- Section ---
export const createSectionSchema = z.object({
boardId: z.string().cuid(),
title: z.string().min(1, 'Section title is required').max(200),
icon: z.string().max(500).optional(),
order: z.number().int().min(0).optional(),
isExpandedByDefault: z.boolean().optional()
});
export const updateSectionSchema = z.object({
title: z.string().min(1).max(200).optional(),
icon: z.string().max(500).nullable().optional(),
order: z.number().int().min(0).optional(),
isExpandedByDefault: z.boolean().optional()
});
// --- Widget ---
export const createWidgetSchema = z.object({
sectionId: z.string().cuid(),
type: z.enum([WidgetType.APP, WidgetType.BOOKMARK, WidgetType.NOTE, WidgetType.EMBED, WidgetType.STATUS]),
order: z.number().int().min(0).optional(),
config: z.string().optional(),
appId: z.string().cuid().optional()
});
export const updateWidgetSchema = z.object({
type: z
.enum([WidgetType.APP, WidgetType.BOOKMARK, WidgetType.NOTE, WidgetType.EMBED, WidgetType.STATUS])
.optional(),
order: z.number().int().min(0).optional(),
config: z.string().optional(),
appId: z.string().cuid().nullable().optional()
});
// --- Permission ---
export const createPermissionSchema = z.object({
entityType: z.enum([EntityType.BOARD, EntityType.APP]),
entityId: z.string().cuid(),
targetType: z.enum([TargetType.USER, TargetType.GROUP]),
targetId: z.string().cuid(),
level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN])
});
// --- System Settings ---
export const updateSystemSettingsSchema = z.object({
authMode: z.enum([AuthMode.LOCAL, AuthMode.OAUTH, AuthMode.BOTH]).optional(),
registrationEnabled: z.boolean().optional(),
oauthClientId: z.string().nullable().optional(),
oauthClientSecret: z.string().nullable().optional(),
oauthDiscoveryUrl: z.string().url().nullable().optional(),
defaultTheme: z.enum(['dark', 'light']).optional(),
defaultPrimaryColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.optional(),
healthcheckDefaults: z.string().optional()
});