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:
@@ -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[];
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 } });
|
||||
}
|
||||
@@ -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 }
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user