From f1b1aa5975a54081776a7965dcfd21d4fad8cb11 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Tue, 24 Mar 2026 20:00:21 +0300 Subject: [PATCH] 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. --- package-lock.json | 742 ++++++++++++++++++ package.json | 6 +- plans/mvp-web-app-launcher/CONTEXT.md | 14 +- plans/mvp-web-app-launcher/PLAN.md | 4 +- .../phase-2-database-services.md | 52 +- .../20260324165855_init/migration.sql | 187 +++++ prisma/migrations/migration_lock.toml | 3 + prisma/schema.prisma | 166 +++- prisma/seed.ts | 275 +++++++ src/app.d.ts | 5 +- src/lib/server/prisma.ts | 9 + src/lib/server/services/appService.ts | 148 ++++ src/lib/server/services/authService.ts | 117 +++ src/lib/server/services/boardService.ts | 263 +++++++ src/lib/server/services/groupService.ts | 125 +++ src/lib/server/services/permissionService.ts | 157 ++++ src/lib/server/services/userService.ts | 104 +++ src/lib/server/utils/response.ts | 41 + src/lib/types/app.ts | 59 ++ src/lib/types/auth.ts | 28 + src/lib/types/board.ts | 57 ++ src/lib/types/group.ts | 20 + src/lib/types/index.ts | 7 + src/lib/types/permission.ts | 26 + src/lib/types/user.ts | 27 + src/lib/types/widget.ts | 55 ++ src/lib/utils/constants.ts | 98 +++ src/lib/utils/validators.ts | 169 ++++ 28 files changed, 2936 insertions(+), 28 deletions(-) create mode 100644 prisma/migrations/20260324165855_init/migration.sql create mode 100644 prisma/migrations/migration_lock.toml create mode 100644 prisma/seed.ts create mode 100644 src/lib/server/prisma.ts create mode 100644 src/lib/server/services/appService.ts create mode 100644 src/lib/server/services/authService.ts create mode 100644 src/lib/server/services/boardService.ts create mode 100644 src/lib/server/services/groupService.ts create mode 100644 src/lib/server/services/permissionService.ts create mode 100644 src/lib/server/services/userService.ts create mode 100644 src/lib/server/utils/response.ts create mode 100644 src/lib/types/app.ts create mode 100644 src/lib/types/auth.ts create mode 100644 src/lib/types/board.ts create mode 100644 src/lib/types/group.ts create mode 100644 src/lib/types/index.ts create mode 100644 src/lib/types/permission.ts create mode 100644 src/lib/types/user.ts create mode 100644 src/lib/types/widget.ts create mode 100644 src/lib/utils/constants.ts create mode 100644 src/lib/utils/validators.ts diff --git a/package-lock.json b/package-lock.json index c7aba6b..b89909e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -42,6 +42,7 @@ "prisma": "^6.2.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", + "tsx": "^4.21.0", "tw-animate-css": "^1.2.0", "typescript": "^5.7.0", "typescript-eslint": "^8.20.0", @@ -3244,6 +3245,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "devOptional": true, + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -4535,6 +4548,15 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true, + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", @@ -5201,6 +5223,482 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/tsx/node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/tsx/node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "devOptional": true, + "hasInstallScript": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, "node_modules/tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", @@ -7660,6 +8158,15 @@ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" }, + "get-tsconfig": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz", + "integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==", + "devOptional": true, + "requires": { + "resolve-pkg-maps": "^1.0.0" + } + }, "giget": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/giget/-/giget-2.0.0.tgz", @@ -8431,6 +8938,12 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, + "resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "devOptional": true + }, "rollup": { "version": "4.60.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.0.tgz", @@ -8855,6 +9368,235 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" }, + "tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "devOptional": true, + "requires": { + "esbuild": "~0.27.0", + "fsevents": "~2.3.3", + "get-tsconfig": "^4.7.5" + }, + "dependencies": { + "@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "dev": true, + "optional": true + }, + "@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "dev": true, + "optional": true + }, + "@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "dev": true, + "optional": true + }, + "@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "dev": true, + "optional": true + }, + "@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "dev": true, + "optional": true + }, + "@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "dev": true, + "optional": true + }, + "@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "dev": true, + "optional": true + }, + "@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "dev": true, + "optional": true + }, + "@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "dev": true, + "optional": true + }, + "@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "dev": true, + "optional": true + }, + "@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "dev": true, + "optional": true + }, + "@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "dev": true, + "optional": true + }, + "@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "dev": true, + "optional": true + }, + "@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "dev": true, + "optional": true + }, + "@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "dev": true, + "optional": true + }, + "esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "devOptional": true, + "requires": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + } + } + }, "tw-animate-css": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", diff --git a/package.json b/package.json index 62e8019..39725ed 100644 --- a/package.json +++ b/package.json @@ -31,11 +31,14 @@ "lucide-svelte": "^0.469.0", "node-cron": "^3.0.3", "simple-icons": "^13.0.0", - "sveltekit-superforms": "^2.22.0", "svelte": "^5.0.0", + "sveltekit-superforms": "^2.22.0", "tailwind-merge": "^2.6.0", "zod": "^3.24.0" }, + "prisma": { + "seed": "npx tsx prisma/seed.ts" + }, "devDependencies": { "@eslint/js": "^9.18.0", "@prisma/client": "^6.2.0", @@ -55,6 +58,7 @@ "prisma": "^6.2.0", "svelte-check": "^4.0.0", "tailwindcss": "^4.0.0", + "tsx": "^4.21.0", "tw-animate-css": "^1.2.0", "typescript": "^5.7.0", "typescript-eslint": "^8.20.0", diff --git a/plans/mvp-web-app-launcher/CONTEXT.md b/plans/mvp-web-app-launcher/CONTEXT.md index ba4f686..7a93f7c 100644 --- a/plans/mvp-web-app-launcher/CONTEXT.md +++ b/plans/mvp-web-app-launcher/CONTEXT.md @@ -1,12 +1,17 @@ # Feature Context: Web App Launcher — MVP ## Current State -Phase 1 (Project Scaffolding & Tooling) is complete. The SvelteKit project is initialized with all dependencies installed (`npm install` succeeds). Config files in place: `svelte.config.js` (adapter-node), `vite.config.ts` (Tailwind v4 + Vitest), `tsconfig.json` (strict), `eslint.config.js`, `.prettierrc`, `components.json` (shadcn-svelte), `prisma/schema.prisma` (SQLite). Docker and CI configs created. Build does not pass yet (Big Bang strategy — expected). + +Phase 2 (Database Schema & Services Layer) is complete. The Prisma schema defines 10 models (User, Group, UserGroup, App, AppStatus, Board, Section, Widget, Permission, SystemSettings). Initial migration has been applied and the SQLite database created at `data/launcher.db`. Seed data includes an admin user, default groups, 5 sample apps, and a default board with 2 sections. Six server-side services provide full CRUD operations. Zod validators, TypeScript type definitions, shared constants, and an API response envelope utility are all in place. Build does not pass yet (Big Bang strategy — expected). ## Temporary Workarounds -- None yet + +- Permission model uses polymorphic pattern (entityType/targetType strings) without FK relations to avoid SQLite dual-FK constraint issues. Queries are done manually in `permissionService.ts`. +- JSON fields (backgroundConfig, config, healthcheckDefaults) are stored as String in SQLite and parsed at the application layer. +- `package.json` `prisma.seed` config triggers a deprecation warning — migrate to `prisma.config.ts` when upgrading to Prisma 7. ## Cross-Phase Dependencies + - Phase 2 depends on Phase 1 (project scaffolding, Prisma setup) - Phase 3 depends on Phase 2 (user/group models, auth service) - Phase 4 depends on Phase 2 (app model, services layer) @@ -16,8 +21,13 @@ Phase 1 (Project Scaffolding & Tooling) is complete. The SvelteKit project is in - Phase 8 depends on all prior phases ## Implementation Notes + - Big Bang strategy: intermediate phases may not build/pass tests. Only Phase 8 must result in a fully working build. - SQLite with Prisma — single file DB at `data/launcher.db` - All env config via environment variables; `.env.example` provided as template - Svelte 5 runes mode: use `$state`, `$derived`, `$effect` — NOT legacy stores for component state - shadcn-svelte uses Bits UI primitives — each component is a local file, not a library import +- `App.Locals` uses `email` + `displayName` fields (aligned with User model, updated in Phase 2) +- Prisma client singleton at `src/lib/server/prisma.ts` — use this for all DB access +- Services export pure async functions (not classes), use immutable patterns +- `tsx` devDependency added for running the seed script diff --git a/plans/mvp-web-app-launcher/PLAN.md b/plans/mvp-web-app-launcher/PLAN.md index eb59ff4..8f99c0f 100644 --- a/plans/mvp-web-app-launcher/PLAN.md +++ b/plans/mvp-web-app-launcher/PLAN.md @@ -28,7 +28,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi ## Phases - [x] Phase 1: Project Scaffolding & Tooling [backend] → [subplan](./phase-1-scaffolding.md) -- [ ] Phase 2: Database Schema & Services Layer [backend] → [subplan](./phase-2-database-services.md) +- [x] Phase 2: Database Schema & Services Layer [backend] → [subplan](./phase-2-database-services.md) - [ ] Phase 3: Authentication System [fullstack] → [subplan](./phase-3-authentication.md) - [ ] Phase 4: App Registry & Healthcheck [fullstack] → [subplan](./phase-4-app-healthcheck.md) - [ ] Phase 5: Board, Section & Widget System [fullstack] → [subplan](./phase-5-board-widgets.md) @@ -41,7 +41,7 @@ Build a self-hosted web application launcher/dashboard for a TrueNAS server envi | Phase | Domain | Status | Review | Build | Committed | |-------|--------|--------|--------|-------|-----------| | Phase 1: Scaffolding | backend | ✅ Complete | ✅ | ⬜ | ⬜ | -| Phase 2: Database & Services | backend | ⬜ Not Started | ⬜ | ⬜ | ⬜ | +| Phase 2: Database & Services | backend | ✅ Complete | ⬜ | ⬜ | ⬜ | | Phase 3: Authentication | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 4: App & Healthcheck | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | | Phase 5: Board & Widgets | fullstack | ⬜ Not Started | ⬜ | ⬜ | ⬜ | diff --git a/plans/mvp-web-app-launcher/phase-2-database-services.md b/plans/mvp-web-app-launcher/phase-2-database-services.md index f008bb3..22dd27d 100644 --- a/plans/mvp-web-app-launcher/phase-2-database-services.md +++ b/plans/mvp-web-app-launcher/phase-2-database-services.md @@ -1,6 +1,6 @@ # Phase 2: Database Schema & Services Layer -**Status:** ⬜ Not Started +**Status:** ✅ Complete **Parent plan:** [PLAN.md](./PLAN.md) **Domain:** backend @@ -9,19 +9,19 @@ Define the full Prisma database schema, run migrations, and build the core serve ## Tasks -- [ ] Task 1: Define Prisma schema with all models: User, Group, UserGroup, App, AppStatus, Board, Section, Widget, Permission, SystemSettings -- [ ] Task 2: Run `prisma migrate dev` to create initial migration -- [ ] Task 3: Create TypeScript type definitions in `src/lib/types/` (auth, app, board, widget, user, group, permission) -- [ ] Task 4: Create shared Zod validation schemas in `src/lib/utils/validators.ts` -- [ ] Task 5: Create API response envelope utility in `src/lib/server/utils/response.ts` -- [ ] Task 6: Implement `authService.ts` — password hashing, JWT sign/verify, refresh token management -- [ ] Task 7: Implement `userService.ts` — CRUD, findByEmail, role management -- [ ] Task 8: Implement `groupService.ts` — CRUD, user-group membership -- [ ] Task 9: Implement `appService.ts` — CRUD, search, status updates -- [ ] Task 10: Implement `boardService.ts` — CRUD with sections and widgets, default board -- [ ] Task 11: Implement `permissionService.ts` — check/grant/revoke permissions, hierarchical resolution -- [ ] Task 12: Create `src/lib/utils/constants.ts` — shared constants (roles, status values, defaults) -- [ ] Task 13: Create `prisma/seed.ts` — seed admin user, default groups, default board, sample apps +- [x] Task 1: Define Prisma schema with all models: User, Group, UserGroup, App, AppStatus, Board, Section, Widget, Permission, SystemSettings +- [x] Task 2: Run `prisma migrate dev` to create initial migration +- [x] Task 3: Create TypeScript type definitions in `src/lib/types/` (auth, app, board, widget, user, group, permission) +- [x] Task 4: Create shared Zod validation schemas in `src/lib/utils/validators.ts` +- [x] Task 5: Create API response envelope utility in `src/lib/server/utils/response.ts` +- [x] Task 6: Implement `authService.ts` — password hashing, JWT sign/verify, refresh token management +- [x] Task 7: Implement `userService.ts` — CRUD, findByEmail, role management +- [x] Task 8: Implement `groupService.ts` — CRUD, user-group membership +- [x] Task 9: Implement `appService.ts` — CRUD, search, status updates +- [x] Task 10: Implement `boardService.ts` — CRUD with sections and widgets, default board +- [x] Task 11: Implement `permissionService.ts` — check/grant/revoke permissions, hierarchical resolution +- [x] Task 12: Create `src/lib/utils/constants.ts` — shared constants (roles, status values, defaults) +- [x] Task 13: Create `prisma/seed.ts` — seed admin user, default groups, default board, sample apps ## Files to Modify/Create - `prisma/schema.prisma` — full schema definition @@ -47,16 +47,30 @@ Define the full Prisma database schema, run migrations, and build the core serve ## Notes - SystemSettings is a singleton row — use upsert pattern - Permission resolution: User-level > Group-level > Default -- Widget config is JSON — use Prisma `Json` type +- Widget config is JSON — stored as String in SQLite, parsed at application layer - OAuth fields in SystemSettings should be encrypted at rest (handle in Phase 3) +- Permission model uses polymorphic pattern (entityType/targetType) without FK relations to avoid SQLite constraints - ⚠️ Big Bang: services won't be wired to routes yet ## Review Checklist -- [ ] All tasks completed -- [ ] Code follows project conventions -- [ ] No unintended side effects +- [x] All tasks completed +- [x] Code follows project conventions +- [x] No unintended side effects - [ ] Build passes - [ ] Tests pass (new + existing) ## Handoff to Next Phase - + +**What's ready for Phase 3:** +- Prisma schema is defined and migrated. SQLite DB created at `data/launcher.db`. +- Prisma client is generated and available via `src/lib/server/prisma.ts` singleton. +- `authService.ts` provides: `hashPassword`, `verifyPassword`, `signAccessToken`, `verifyAccessToken`, `generateRefreshToken`, `saveRefreshToken`, `validateRefreshToken`, `revokeRefreshToken`, `rotateTokens`. +- `userService.ts` provides: `findAll`, `findById`, `findByEmail`, `create`, `update`, `remove`, `updateRole`, `getUserGroups`, `count`. +- `groupService.ts` provides: `findAll`, `findById`, `findByName`, `findDefaultGroups`, `create`, `update`, `remove`, `addUser`, `removeUser`, `getGroupMembers`, `addUserToDefaultGroups`. +- `App.Locals` updated to use `email` + `displayName` (aligned with User model). +- Zod validators available for all form/API input validation. +- API response envelope (`success`, `error`, `paginated`) in `src/lib/server/utils/response.ts`. +- Seed data includes: admin user (admin@localhost / admin123), admin + user groups, 5 sample apps, default board with 2 sections and widgets. +- Constants exported from `src/lib/utils/constants.ts` for roles, statuses, widget types, permission levels. +- `tsx` added as devDependency for running seed script. +- `package.json` has `prisma.seed` config (deprecated warning — migrate to `prisma.config.ts` in future). diff --git a/prisma/migrations/20260324165855_init/migration.sql b/prisma/migrations/20260324165855_init/migration.sql new file mode 100644 index 0000000..4cb084b --- /dev/null +++ b/prisma/migrations/20260324165855_init/migration.sql @@ -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"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 79184d3..10d0ddb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/prisma/seed.ts b/prisma/seed.ts new file mode 100644 index 0000000..1b6d227 --- /dev/null +++ b/prisma/seed.ts @@ -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(); + }); diff --git a/src/app.d.ts b/src/app.d.ts index 1c944f4..a3a50f7 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -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; diff --git a/src/lib/server/prisma.ts b/src/lib/server/prisma.ts new file mode 100644 index 0000000..ee2b5c5 --- /dev/null +++ b/src/lib/server/prisma.ts @@ -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; +} diff --git a/src/lib/server/services/appService.ts b/src/lib/server/services/appService.ts new file mode 100644 index 0000000..15294d2 --- /dev/null +++ b/src/lib/server/services/appService.ts @@ -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 = {}; + + 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 = {}; + 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[]; +} diff --git a/src/lib/server/services/authService.ts b/src/lib/server/services/authService.ts new file mode 100644 index 0000000..a7db7f6 --- /dev/null +++ b/src/lib/server/services/authService.ts @@ -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 { + return bcrypt.hash(password, SALT_ROUNDS); +} + +export async function verifyPassword(password: string, hash: string): Promise { + 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 { + 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 { + 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 { + await prisma.user.update({ + where: { id: userId }, + data: { + refreshToken: null, + refreshTokenExpiresAt: null + } + }); +} + +export async function rotateTokens(userId: string, email: string, role: string): Promise { + const accessToken = signAccessToken({ userId, email, role }); + const refreshToken = generateRefreshToken(); + await saveRefreshToken(userId, refreshToken); + + return { accessToken, refreshToken }; +} diff --git a/src/lib/server/services/boardService.ts b/src/lib/server/services/boardService.ts new file mode 100644 index 0000000..c76e662 --- /dev/null +++ b/src/lib/server/services/boardService.ts @@ -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 = {}; + 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 = {}; + 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 = {}; + 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 } }); +} diff --git a/src/lib/server/services/groupService.ts b/src/lib/server/services/groupService.ts new file mode 100644 index 0000000..6ef51e3 --- /dev/null +++ b/src/lib/server/services/groupService.ts @@ -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; +} diff --git a/src/lib/server/services/permissionService.ts b/src/lib/server/services/permissionService.ts new file mode 100644 index 0000000..2b39de3 --- /dev/null +++ b/src/lib/server/services/permissionService.ts @@ -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 { + // 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 } + }); +} diff --git a/src/lib/server/services/userService.ts b/src/lib/server/services/userService.ts new file mode 100644 index 0000000..f46c467 --- /dev/null +++ b/src/lib/server/services/userService.ts @@ -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(); +} diff --git a/src/lib/server/utils/response.ts b/src/lib/server/utils/response.ts new file mode 100644 index 0000000..287a585 --- /dev/null +++ b/src/lib/server/utils/response.ts @@ -0,0 +1,41 @@ +export interface ApiResponse { + 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(data: T, meta?: ApiResponse['meta']): ApiResponse { + return { + success: true, + data, + error: null, + ...(meta ? { meta } : {}) + }; +} + +export function error(message: string): ApiResponse { + return { + success: false, + data: null, + error: message + }; +} + +export function paginated( + data: T, + total: number, + page: number, + limit: number +): ApiResponse { + return { + success: true, + data, + error: null, + meta: { total, page, limit } + }; +} diff --git a/src/lib/types/app.ts b/src/lib/types/app.ts new file mode 100644 index 0000000..26bbf7d --- /dev/null +++ b/src/lib/types/app.ts @@ -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; +} diff --git a/src/lib/types/auth.ts b/src/lib/types/auth.ts new file mode 100644 index 0000000..10a5840 --- /dev/null +++ b/src/lib/types/auth.ts @@ -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; +} diff --git a/src/lib/types/board.ts b/src/lib/types/board.ts new file mode 100644 index 0000000..b5b2338 --- /dev/null +++ b/src/lib/types/board.ts @@ -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; +} diff --git a/src/lib/types/group.ts b/src/lib/types/group.ts new file mode 100644 index 0000000..103b644 --- /dev/null +++ b/src/lib/types/group.ts @@ -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; +} diff --git a/src/lib/types/index.ts b/src/lib/types/index.ts new file mode 100644 index 0000000..5458733 --- /dev/null +++ b/src/lib/types/index.ts @@ -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'; diff --git a/src/lib/types/permission.ts b/src/lib/types/permission.ts new file mode 100644 index 0000000..6c7a26f --- /dev/null +++ b/src/lib/types/permission.ts @@ -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; +} diff --git a/src/lib/types/user.ts b/src/lib/types/user.ts new file mode 100644 index 0000000..3b106c1 --- /dev/null +++ b/src/lib/types/user.ts @@ -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; +} diff --git a/src/lib/types/widget.ts b/src/lib/types/widget.ts new file mode 100644 index 0000000..4b22035 --- /dev/null +++ b/src/lib/types/widget.ts @@ -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'; +} diff --git a/src/lib/utils/constants.ts b/src/lib/utils/constants.ts new file mode 100644 index 0000000..f5e68ff --- /dev/null +++ b/src/lib/utils/constants.ts @@ -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 = { + [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; diff --git a/src/lib/utils/validators.ts b/src/lib/utils/validators.ts new file mode 100644 index 0000000..2686743 --- /dev/null +++ b/src/lib/utils/validators.ts @@ -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() +});