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

Define full Prisma schema (10 models), run initial migration, build core
services (auth, user, group, app, board, permission), Zod validators,
type definitions, API response envelope, constants, and seed script.
This commit is contained in:
2026-03-24 20:00:21 +03:00
parent cf6bde238c
commit f1b1aa5975
28 changed files with 2936 additions and 28 deletions
+742
View File
@@ -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",
+5 -1
View File
@@ -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",
+12 -2
View File
@@ -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
+2 -2
View File
@@ -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 | ⬜ | ⬜ | ⬜ |
@@ -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
<!-- Filled in by the implementation agent after completing this 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).
@@ -0,0 +1,187 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"password" TEXT,
"displayName" TEXT NOT NULL,
"avatarUrl" TEXT,
"authProvider" TEXT NOT NULL DEFAULT 'local',
"role" TEXT NOT NULL DEFAULT 'user',
"refreshToken" TEXT,
"refreshTokenExpiresAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Group" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"description" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "UserGroup" (
"id" TEXT NOT NULL PRIMARY KEY,
"userId" TEXT NOT NULL,
"groupId" TEXT NOT NULL,
CONSTRAINT "UserGroup_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "UserGroup_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "Group" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "App" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"url" TEXT NOT NULL,
"icon" TEXT,
"iconType" TEXT NOT NULL DEFAULT 'lucide',
"description" TEXT,
"category" TEXT,
"tags" TEXT NOT NULL DEFAULT '',
"healthcheckEnabled" BOOLEAN NOT NULL DEFAULT false,
"healthcheckInterval" INTEGER NOT NULL DEFAULT 300,
"healthcheckMethod" TEXT NOT NULL DEFAULT 'GET',
"healthcheckExpectedStatus" INTEGER NOT NULL DEFAULT 200,
"healthcheckTimeout" INTEGER NOT NULL DEFAULT 5000,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "App_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AppStatus" (
"id" TEXT NOT NULL PRIMARY KEY,
"appId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'unknown',
"responseTime" INTEGER,
"checkedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AppStatus_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Board" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"icon" TEXT,
"description" TEXT,
"isDefault" BOOLEAN NOT NULL DEFAULT false,
"isGuestAccessible" BOOLEAN NOT NULL DEFAULT false,
"backgroundConfig" TEXT,
"createdById" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Board_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Section" (
"id" TEXT NOT NULL PRIMARY KEY,
"boardId" TEXT NOT NULL,
"title" TEXT NOT NULL,
"icon" TEXT,
"order" INTEGER NOT NULL DEFAULT 0,
"isExpandedByDefault" BOOLEAN NOT NULL DEFAULT true,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Section_boardId_fkey" FOREIGN KEY ("boardId") REFERENCES "Board" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Widget" (
"id" TEXT NOT NULL PRIMARY KEY,
"sectionId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"order" INTEGER NOT NULL DEFAULT 0,
"config" TEXT NOT NULL DEFAULT '{}',
"appId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Widget_sectionId_fkey" FOREIGN KEY ("sectionId") REFERENCES "Section" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Widget_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Permission" (
"id" TEXT NOT NULL PRIMARY KEY,
"entityType" TEXT NOT NULL,
"entityId" TEXT NOT NULL,
"targetType" TEXT NOT NULL,
"targetId" TEXT NOT NULL,
"level" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "SystemSettings" (
"id" TEXT NOT NULL PRIMARY KEY DEFAULT 'singleton',
"authMode" TEXT NOT NULL DEFAULT 'local',
"registrationEnabled" BOOLEAN NOT NULL DEFAULT true,
"oauthClientId" TEXT,
"oauthClientSecret" TEXT,
"oauthDiscoveryUrl" TEXT,
"defaultTheme" TEXT NOT NULL DEFAULT 'dark',
"defaultPrimaryColor" TEXT NOT NULL DEFAULT '#6366f1',
"healthcheckDefaults" TEXT NOT NULL DEFAULT '{}',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE INDEX "User_email_idx" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Group_name_key" ON "Group"("name");
-- CreateIndex
CREATE INDEX "UserGroup_userId_idx" ON "UserGroup"("userId");
-- CreateIndex
CREATE INDEX "UserGroup_groupId_idx" ON "UserGroup"("groupId");
-- CreateIndex
CREATE UNIQUE INDEX "UserGroup_userId_groupId_key" ON "UserGroup"("userId", "groupId");
-- CreateIndex
CREATE INDEX "App_name_idx" ON "App"("name");
-- CreateIndex
CREATE INDEX "App_category_idx" ON "App"("category");
-- CreateIndex
CREATE INDEX "App_createdById_idx" ON "App"("createdById");
-- CreateIndex
CREATE INDEX "AppStatus_appId_idx" ON "AppStatus"("appId");
-- CreateIndex
CREATE INDEX "AppStatus_checkedAt_idx" ON "AppStatus"("checkedAt");
-- CreateIndex
CREATE INDEX "Board_createdById_idx" ON "Board"("createdById");
-- CreateIndex
CREATE INDEX "Section_boardId_idx" ON "Section"("boardId");
-- CreateIndex
CREATE INDEX "Widget_sectionId_idx" ON "Widget"("sectionId");
-- CreateIndex
CREATE INDEX "Widget_appId_idx" ON "Widget"("appId");
-- CreateIndex
CREATE INDEX "Permission_entityType_entityId_idx" ON "Permission"("entityType", "entityId");
-- CreateIndex
CREATE INDEX "Permission_targetType_targetId_idx" ON "Permission"("targetType", "targetId");
-- CreateIndex
CREATE UNIQUE INDEX "Permission_entityType_entityId_targetType_targetId_key" ON "Permission"("entityType", "entityId", "targetType", "targetId");
+3
View File
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "sqlite"
+164 -2
View File
@@ -1,5 +1,3 @@
// Prisma schema — models added in Phase 2
generator client {
provider = "prisma-client-js"
}
@@ -8,3 +6,167 @@ datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
password String?
displayName String
avatarUrl String?
authProvider String @default("local") // local | oauth
role String @default("user") // admin | user
refreshToken String?
refreshTokenExpiresAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
groups UserGroup[]
createdApps App[]
boards Board[]
@@index([email])
}
model Group {
id String @id @default(cuid())
name String @unique
description String?
isDefault Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
users UserGroup[]
}
model UserGroup {
id String @id @default(cuid())
userId String
groupId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
group Group @relation(fields: [groupId], references: [id], onDelete: Cascade)
@@unique([userId, groupId])
@@index([userId])
@@index([groupId])
}
model App {
id String @id @default(cuid())
name String
url String
icon String?
iconType String @default("lucide") // lucide | simple | url | emoji
description String?
category String?
tags String @default("") // comma-separated
healthcheckEnabled Boolean @default(false)
healthcheckInterval Int @default(300) // seconds
healthcheckMethod String @default("GET")
healthcheckExpectedStatus Int @default(200)
healthcheckTimeout Int @default(5000) // milliseconds
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
statuses AppStatus[]
widgets Widget[]
@@index([name])
@@index([category])
@@index([createdById])
}
model AppStatus {
id String @id @default(cuid())
appId String
status String @default("unknown") // online | offline | degraded | unknown
responseTime Int? // milliseconds
checkedAt DateTime @default(now())
app App @relation(fields: [appId], references: [id], onDelete: Cascade)
@@index([appId])
@@index([checkedAt])
}
model Board {
id String @id @default(cuid())
name String
icon String?
description String?
isDefault Boolean @default(false)
isGuestAccessible Boolean @default(false)
backgroundConfig String? // JSON stored as string for SQLite
createdById String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
sections Section[]
@@index([createdById])
}
model Section {
id String @id @default(cuid())
boardId String
title String
icon String?
order Int @default(0)
isExpandedByDefault Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
board Board @relation(fields: [boardId], references: [id], onDelete: Cascade)
widgets Widget[]
@@index([boardId])
}
model Widget {
id String @id @default(cuid())
sectionId String
type String // app | bookmark | note | embed | status
order Int @default(0)
config String @default("{}") // JSON stored as string for SQLite
appId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
section Section @relation(fields: [sectionId], references: [id], onDelete: Cascade)
app App? @relation(fields: [appId], references: [id], onDelete: SetNull)
@@index([sectionId])
@@index([appId])
}
model Permission {
id String @id @default(cuid())
entityType String // board | app
entityId String
targetType String // user | group
targetId String
level String // view | edit | admin
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([entityType, entityId, targetType, targetId])
@@index([entityType, entityId])
@@index([targetType, targetId])
}
model SystemSettings {
id String @id @default("singleton")
authMode String @default("local") // local | oauth | both
registrationEnabled Boolean @default(true)
oauthClientId String?
oauthClientSecret String?
oauthDiscoveryUrl String?
defaultTheme String @default("dark")
defaultPrimaryColor String @default("#6366f1")
healthcheckDefaults String @default("{}") // JSON stored as string for SQLite
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
+275
View File
@@ -0,0 +1,275 @@
import { PrismaClient } from '@prisma/client';
import bcrypt from 'bcryptjs';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding database...');
// --- System Settings ---
const settings = await prisma.systemSettings.upsert({
where: { id: 'singleton' },
update: {},
create: {
id: 'singleton',
authMode: 'local',
registrationEnabled: true,
defaultTheme: 'dark',
defaultPrimaryColor: '#6366f1',
healthcheckDefaults: JSON.stringify({
interval: 300,
timeout: 5000,
method: 'GET',
expectedStatus: 200
})
}
});
console.log(' Created system settings:', settings.id);
// --- Admin User ---
const adminPassword = await bcrypt.hash('admin123', 12);
const admin = await prisma.user.upsert({
where: { email: 'admin@localhost' },
update: {},
create: {
email: 'admin@localhost',
password: adminPassword,
displayName: 'Administrator',
role: 'admin',
authProvider: 'local'
}
});
console.log(' Created admin user:', admin.email);
// --- Groups ---
const adminGroup = await prisma.group.upsert({
where: { name: 'admin' },
update: {},
create: {
name: 'admin',
description: 'Administrators with full system access',
isDefault: false
}
});
console.log(' Created group:', adminGroup.name);
const userGroup = await prisma.group.upsert({
where: { name: 'user' },
update: {},
create: {
name: 'user',
description: 'Default group for all registered users',
isDefault: true
}
});
console.log(' Created group:', userGroup.name);
// --- User-Group memberships ---
await prisma.userGroup.upsert({
where: { userId_groupId: { userId: admin.id, groupId: adminGroup.id } },
update: {},
create: { userId: admin.id, groupId: adminGroup.id }
});
await prisma.userGroup.upsert({
where: { userId_groupId: { userId: admin.id, groupId: userGroup.id } },
update: {},
create: { userId: admin.id, groupId: userGroup.id }
});
console.log(' Added admin to groups');
// --- Sample Apps ---
const apps = [
{
name: 'Plex',
url: 'http://plex.local:32400',
icon: 'plex',
iconType: 'simple',
description: 'Media server for streaming movies, TV shows, and music',
category: 'Media',
tags: 'media,streaming,movies,tv',
healthcheckEnabled: true
},
{
name: 'Nextcloud',
url: 'http://nextcloud.local',
icon: 'nextcloud',
iconType: 'simple',
description: 'Self-hosted file sync, sharing, and collaboration platform',
category: 'Productivity',
tags: 'files,sync,cloud,office',
healthcheckEnabled: true
},
{
name: 'Gitea',
url: 'http://gitea.local:3000',
icon: 'gitea',
iconType: 'simple',
description: 'Lightweight self-hosted Git service',
category: 'Development',
tags: 'git,code,development,ci',
healthcheckEnabled: true
},
{
name: 'Home Assistant',
url: 'http://homeassistant.local:8123',
icon: 'homeassistant',
iconType: 'simple',
description: 'Open-source home automation platform',
category: 'Home Automation',
tags: 'home,automation,iot,smart-home',
healthcheckEnabled: true
},
{
name: 'Grafana',
url: 'http://grafana.local:3000',
icon: 'grafana',
iconType: 'simple',
description: 'Analytics and monitoring dashboards',
category: 'Monitoring',
tags: 'monitoring,analytics,dashboards,metrics',
healthcheckEnabled: true
}
];
const createdApps = [];
for (const appData of apps) {
const app = await prisma.app.upsert({
where: { id: appData.name.toLowerCase().replace(/\s+/g, '-') },
update: {},
create: {
...appData,
createdById: admin.id
}
});
createdApps.push(app);
console.log(' Created app:', app.name);
}
// --- Default Board ---
const board = await prisma.board.upsert({
where: { id: 'default-board' },
update: {},
create: {
id: 'default-board',
name: 'Dashboard',
icon: 'layout-dashboard',
description: 'Default application dashboard',
isDefault: true,
isGuestAccessible: true,
createdById: admin.id
}
});
console.log(' Created board:', board.name);
// --- Sections ---
const mediaSection = await prisma.section.upsert({
where: { id: 'section-media' },
update: {},
create: {
id: 'section-media',
boardId: board.id,
title: 'Media & Entertainment',
icon: 'tv',
order: 0,
isExpandedByDefault: true
}
});
console.log(' Created section:', mediaSection.title);
const infraSection = await prisma.section.upsert({
where: { id: 'section-infra' },
update: {},
create: {
id: 'section-infra',
boardId: board.id,
title: 'Infrastructure & Tools',
icon: 'server',
order: 1,
isExpandedByDefault: true
}
});
console.log(' Created section:', infraSection.title);
// --- Widgets ---
// Plex widget in media section
await prisma.widget.upsert({
where: { id: 'widget-plex' },
update: {},
create: {
id: 'widget-plex',
sectionId: mediaSection.id,
type: 'app',
order: 0,
appId: createdApps[0].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
// Nextcloud widget in infra section
await prisma.widget.upsert({
where: { id: 'widget-nextcloud' },
update: {},
create: {
id: 'widget-nextcloud',
sectionId: infraSection.id,
type: 'app',
order: 0,
appId: createdApps[1].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
// Gitea widget in infra section
await prisma.widget.upsert({
where: { id: 'widget-gitea' },
update: {},
create: {
id: 'widget-gitea',
sectionId: infraSection.id,
type: 'app',
order: 1,
appId: createdApps[2].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
// Home Assistant widget in infra section
await prisma.widget.upsert({
where: { id: 'widget-homeassistant' },
update: {},
create: {
id: 'widget-homeassistant',
sectionId: infraSection.id,
type: 'app',
order: 2,
appId: createdApps[3].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
// Grafana widget in infra section
await prisma.widget.upsert({
where: { id: 'widget-grafana' },
update: {},
create: {
id: 'widget-grafana',
sectionId: infraSection.id,
type: 'app',
order: 3,
appId: createdApps[4].id,
config: JSON.stringify({ showStatus: true, openInNewTab: true })
}
});
console.log(' Created widgets for all apps');
console.log('Seeding complete!');
}
main()
.catch((e) => {
console.error('Seed error:', e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});
+3 -2
View File
@@ -10,8 +10,9 @@ declare global {
interface Locals {
user: {
id: string;
username: string;
role: 'admin' | 'user' | 'guest';
email: string;
displayName: string;
role: 'admin' | 'user';
} | null;
session: {
id: string;
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client';
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
+148
View File
@@ -0,0 +1,148 @@
import { prisma } from '../prisma.js';
import type { CreateAppInput, UpdateAppInput } from '$lib/types/app.js';
export async function findAll(options?: { category?: string; search?: string }) {
const where: Record<string, unknown> = {};
if (options?.category) {
where.category = options.category;
}
if (options?.search) {
where.OR = [
{ name: { contains: options.search } },
{ description: { contains: options.search } },
{ tags: { contains: options.search } }
];
}
return prisma.app.findMany({
where,
orderBy: { name: 'asc' },
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
});
}
export async function findById(id: string) {
const app = await prisma.app.findUnique({
where: { id },
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
},
createdBy: {
select: { id: true, displayName: true }
}
}
});
if (!app) {
throw new Error(`App not found: ${id}`);
}
return app;
}
export async function create(input: CreateAppInput) {
return prisma.app.create({
data: {
name: input.name,
url: input.url,
icon: input.icon ?? null,
iconType: input.iconType ?? 'lucide',
description: input.description ?? null,
category: input.category ?? null,
tags: input.tags ?? '',
healthcheckEnabled: input.healthcheckEnabled ?? false,
healthcheckInterval: input.healthcheckInterval ?? 300,
healthcheckMethod: input.healthcheckMethod ?? 'GET',
healthcheckExpectedStatus: input.healthcheckExpectedStatus ?? 200,
healthcheckTimeout: input.healthcheckTimeout ?? 5000,
createdById: input.createdById ?? null
}
});
}
export async function update(id: string, input: UpdateAppInput) {
await findById(id);
const data: Record<string, unknown> = {};
if (input.name !== undefined) data.name = input.name;
if (input.url !== undefined) data.url = input.url;
if (input.icon !== undefined) data.icon = input.icon;
if (input.iconType !== undefined) data.iconType = input.iconType;
if (input.description !== undefined) data.description = input.description;
if (input.category !== undefined) data.category = input.category;
if (input.tags !== undefined) data.tags = input.tags;
if (input.healthcheckEnabled !== undefined) data.healthcheckEnabled = input.healthcheckEnabled;
if (input.healthcheckInterval !== undefined) data.healthcheckInterval = input.healthcheckInterval;
if (input.healthcheckMethod !== undefined) data.healthcheckMethod = input.healthcheckMethod;
if (input.healthcheckExpectedStatus !== undefined) data.healthcheckExpectedStatus = input.healthcheckExpectedStatus;
if (input.healthcheckTimeout !== undefined) data.healthcheckTimeout = input.healthcheckTimeout;
return prisma.app.update({
where: { id },
data
});
}
export async function remove(id: string) {
await findById(id);
await prisma.app.delete({ where: { id } });
}
export async function recordStatus(
appId: string,
status: string,
responseTime: number | null
) {
return prisma.appStatus.create({
data: {
appId,
status,
responseTime
}
});
}
export async function getLatestStatus(appId: string) {
return prisma.appStatus.findFirst({
where: { appId },
orderBy: { checkedAt: 'desc' }
});
}
export async function getStatusHistory(appId: string, limit: number = 50) {
return prisma.appStatus.findMany({
where: { appId },
orderBy: { checkedAt: 'desc' },
take: limit
});
}
export async function getHealthcheckTargets() {
return prisma.app.findMany({
where: { healthcheckEnabled: true },
select: {
id: true,
name: true,
url: true,
healthcheckMethod: true,
healthcheckExpectedStatus: true,
healthcheckTimeout: true
}
});
}
export async function getCategories() {
const apps = await prisma.app.findMany({
where: { category: { not: null } },
select: { category: true },
distinct: ['category']
});
return apps.map((a) => a.category).filter(Boolean) as string[];
}
+117
View File
@@ -0,0 +1,117 @@
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { prisma } from '../prisma.js';
import { DEFAULTS } from '$lib/utils/constants.js';
import type { JwtPayload, TokenPair } from '$lib/types/auth.js';
const SALT_ROUNDS = 12;
function getJwtSecret(): string {
const secret = process.env.JWT_SECRET;
if (!secret) {
throw new Error('JWT_SECRET environment variable is not set');
}
return secret;
}
function getJwtExpiry(): string {
return process.env.JWT_EXPIRY || DEFAULTS.JWT_EXPIRY;
}
function getRefreshTokenExpiryDays(): number {
const envValue = process.env.REFRESH_TOKEN_EXPIRY;
if (envValue) {
const days = parseInt(envValue.replace('d', ''), 10);
if (!isNaN(days)) return days;
}
return DEFAULTS.REFRESH_TOKEN_EXPIRY_DAYS;
}
export async function hashPassword(password: string): Promise<string> {
return bcrypt.hash(password, SALT_ROUNDS);
}
export async function verifyPassword(password: string, hash: string): Promise<boolean> {
return bcrypt.compare(password, hash);
}
export function signAccessToken(payload: JwtPayload): string {
return jwt.sign(payload, getJwtSecret(), {
expiresIn: getJwtExpiry()
});
}
export function verifyAccessToken(token: string): JwtPayload {
try {
const decoded = jwt.verify(token, getJwtSecret()) as JwtPayload & jwt.JwtPayload;
return {
userId: decoded.userId,
email: decoded.email,
role: decoded.role
};
} catch {
throw new Error('Invalid or expired access token');
}
}
export function generateRefreshToken(): string {
const bytes = new Uint8Array(48);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
export function getRefreshTokenExpiry(): Date {
const days = getRefreshTokenExpiryDays();
const expiry = new Date();
expiry.setDate(expiry.getDate() + days);
return expiry;
}
export async function saveRefreshToken(userId: string, refreshToken: string): Promise<void> {
const hashedToken = await bcrypt.hash(refreshToken, SALT_ROUNDS);
await prisma.user.update({
where: { id: userId },
data: {
refreshToken: hashedToken,
refreshTokenExpiresAt: getRefreshTokenExpiry()
}
});
}
export async function validateRefreshToken(
userId: string,
refreshToken: string
): Promise<boolean> {
const user = await prisma.user.findUnique({
where: { id: userId },
select: { refreshToken: true, refreshTokenExpiresAt: true }
});
if (!user?.refreshToken || !user.refreshTokenExpiresAt) {
return false;
}
if (new Date() > user.refreshTokenExpiresAt) {
return false;
}
return bcrypt.compare(refreshToken, user.refreshToken);
}
export async function revokeRefreshToken(userId: string): Promise<void> {
await prisma.user.update({
where: { id: userId },
data: {
refreshToken: null,
refreshTokenExpiresAt: null
}
});
}
export async function rotateTokens(userId: string, email: string, role: string): Promise<TokenPair> {
const accessToken = signAccessToken({ userId, email, role });
const refreshToken = generateRefreshToken();
await saveRefreshToken(userId, refreshToken);
return { accessToken, refreshToken };
}
+263
View File
@@ -0,0 +1,263 @@
import { prisma } from '../prisma.js';
import type { CreateBoardInput, UpdateBoardInput, CreateSectionInput, UpdateSectionInput } from '$lib/types/board.js';
import type { CreateWidgetInput, UpdateWidgetInput } from '$lib/types/widget.js';
// --- Board ---
export async function findAllBoards() {
return prisma.board.findMany({
orderBy: { createdAt: 'asc' },
include: {
_count: { select: { sections: true } }
}
});
}
export async function findBoardById(id: string) {
const board = await prisma.board.findUnique({
where: { id },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
}
}
}
}
});
if (!board) {
throw new Error(`Board not found: ${id}`);
}
return board;
}
export async function findDefaultBoard() {
return prisma.board.findFirst({
where: { isDefault: true },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
}
}
}
}
});
}
export async function findGuestAccessibleBoards() {
return prisma.board.findMany({
where: { isGuestAccessible: true },
orderBy: { createdAt: 'asc' },
include: {
sections: {
orderBy: { order: 'asc' },
include: {
widgets: {
orderBy: { order: 'asc' },
include: {
app: {
include: {
statuses: {
orderBy: { checkedAt: 'desc' },
take: 1
}
}
}
}
}
}
}
}
});
}
export async function createBoard(input: CreateBoardInput) {
// If this board is default, unset other defaults
if (input.isDefault) {
await prisma.board.updateMany({
where: { isDefault: true },
data: { isDefault: false }
});
}
return prisma.board.create({
data: {
name: input.name,
icon: input.icon ?? null,
description: input.description ?? null,
isDefault: input.isDefault ?? false,
isGuestAccessible: input.isGuestAccessible ?? false,
backgroundConfig: input.backgroundConfig ?? null,
createdById: input.createdById ?? null
}
});
}
export async function updateBoard(id: string, input: UpdateBoardInput) {
await findBoardById(id);
if (input.isDefault) {
await prisma.board.updateMany({
where: { isDefault: true, NOT: { id } },
data: { isDefault: false }
});
}
const data: Record<string, unknown> = {};
if (input.name !== undefined) data.name = input.name;
if (input.icon !== undefined) data.icon = input.icon;
if (input.description !== undefined) data.description = input.description;
if (input.isDefault !== undefined) data.isDefault = input.isDefault;
if (input.isGuestAccessible !== undefined) data.isGuestAccessible = input.isGuestAccessible;
if (input.backgroundConfig !== undefined) data.backgroundConfig = input.backgroundConfig;
return prisma.board.update({
where: { id },
data
});
}
export async function removeBoard(id: string) {
await findBoardById(id);
await prisma.board.delete({ where: { id } });
}
// --- Section ---
export async function findSectionById(id: string) {
const section = await prisma.section.findUnique({
where: { id },
include: {
widgets: {
orderBy: { order: 'asc' }
}
}
});
if (!section) {
throw new Error(`Section not found: ${id}`);
}
return section;
}
export async function createSection(input: CreateSectionInput) {
// Auto-calculate order if not provided
let order = input.order;
if (order === undefined) {
const maxSection = await prisma.section.findFirst({
where: { boardId: input.boardId },
orderBy: { order: 'desc' },
select: { order: true }
});
order = (maxSection?.order ?? -1) + 1;
}
return prisma.section.create({
data: {
boardId: input.boardId,
title: input.title,
icon: input.icon ?? null,
order,
isExpandedByDefault: input.isExpandedByDefault ?? true
}
});
}
export async function updateSection(id: string, input: UpdateSectionInput) {
await findSectionById(id);
const data: Record<string, unknown> = {};
if (input.title !== undefined) data.title = input.title;
if (input.icon !== undefined) data.icon = input.icon;
if (input.order !== undefined) data.order = input.order;
if (input.isExpandedByDefault !== undefined) data.isExpandedByDefault = input.isExpandedByDefault;
return prisma.section.update({
where: { id },
data
});
}
export async function removeSection(id: string) {
await findSectionById(id);
await prisma.section.delete({ where: { id } });
}
// --- Widget ---
export async function findWidgetById(id: string) {
const widget = await prisma.widget.findUnique({
where: { id },
include: { app: true }
});
if (!widget) {
throw new Error(`Widget not found: ${id}`);
}
return widget;
}
export async function createWidget(input: CreateWidgetInput) {
let order = input.order;
if (order === undefined) {
const maxWidget = await prisma.widget.findFirst({
where: { sectionId: input.sectionId },
orderBy: { order: 'desc' },
select: { order: true }
});
order = (maxWidget?.order ?? -1) + 1;
}
return prisma.widget.create({
data: {
sectionId: input.sectionId,
type: input.type,
order,
config: input.config ?? '{}',
appId: input.appId ?? null
}
});
}
export async function updateWidget(id: string, input: UpdateWidgetInput) {
await findWidgetById(id);
const data: Record<string, unknown> = {};
if (input.type !== undefined) data.type = input.type;
if (input.order !== undefined) data.order = input.order;
if (input.config !== undefined) data.config = input.config;
if (input.appId !== undefined) data.appId = input.appId;
return prisma.widget.update({
where: { id },
data
});
}
export async function removeWidget(id: string) {
await findWidgetById(id);
await prisma.widget.delete({ where: { id } });
}
+125
View File
@@ -0,0 +1,125 @@
import { prisma } from '../prisma.js';
import type { CreateGroupInput, UpdateGroupInput } from '$lib/types/group.js';
export async function findAll() {
return prisma.group.findMany({
orderBy: { name: 'asc' },
include: {
_count: { select: { users: true } }
}
});
}
export async function findById(id: string) {
const group = await prisma.group.findUnique({
where: { id },
include: {
_count: { select: { users: true } }
}
});
if (!group) {
throw new Error(`Group not found: ${id}`);
}
return group;
}
export async function findByName(name: string) {
return prisma.group.findUnique({
where: { name }
});
}
export async function findDefaultGroups() {
return prisma.group.findMany({
where: { isDefault: true }
});
}
export async function create(input: CreateGroupInput) {
const existing = await prisma.group.findUnique({
where: { name: input.name }
});
if (existing) {
throw new Error(`Group with name "${input.name}" already exists`);
}
return prisma.group.create({
data: {
name: input.name,
description: input.description ?? null,
isDefault: input.isDefault ?? false
}
});
}
export async function update(id: string, input: UpdateGroupInput) {
await findById(id);
if (input.name) {
const existing = await prisma.group.findFirst({
where: { name: input.name, NOT: { id } }
});
if (existing) {
throw new Error(`Group with name "${input.name}" already exists`);
}
}
return prisma.group.update({
where: { id },
data: {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.description !== undefined ? { description: input.description } : {}),
...(input.isDefault !== undefined ? { isDefault: input.isDefault } : {})
}
});
}
export async function remove(id: string) {
await findById(id);
await prisma.group.delete({ where: { id } });
}
export async function addUser(groupId: string, userId: string) {
const existing = await prisma.userGroup.findUnique({
where: { userId_groupId: { userId, groupId } }
});
if (existing) {
return existing;
}
return prisma.userGroup.create({
data: { userId, groupId }
});
}
export async function removeUser(groupId: string, userId: string) {
await prisma.userGroup.deleteMany({
where: { userId, groupId }
});
}
export async function getGroupMembers(groupId: string) {
const memberships = await prisma.userGroup.findMany({
where: { groupId },
include: {
user: {
select: {
id: true,
email: true,
displayName: true,
role: true,
avatarUrl: true
}
}
}
});
return memberships.map((m) => m.user);
}
export async function addUserToDefaultGroups(userId: string) {
const defaultGroups = await findDefaultGroups();
const results = await Promise.all(
defaultGroups.map((group) => addUser(group.id, userId))
);
return results;
}
@@ -0,0 +1,157 @@
import { prisma } from '../prisma.js';
import {
UserRole,
PermissionLevel,
PERMISSION_HIERARCHY,
TargetType,
type EntityType,
type TargetType as TargetTypeType
} from '$lib/utils/constants.js';
import type { CreatePermissionInput, PermissionCheckResult } from '$lib/types/permission.js';
export async function checkPermission(
entityType: EntityType,
entityId: string,
userId: string,
requiredLevel: string
): Promise<PermissionCheckResult> {
// Admins always have full access
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true }
});
if (user?.role === UserRole.ADMIN) {
return {
hasPermission: true,
effectiveLevel: PermissionLevel.ADMIN,
source: 'admin'
};
}
// Check direct user permission
const userPermission = await prisma.permission.findFirst({
where: {
entityType,
entityId,
targetType: TargetType.USER,
targetId: userId
}
});
if (userPermission) {
const hasAccess =
PERMISSION_HIERARCHY[userPermission.level] >= PERMISSION_HIERARCHY[requiredLevel];
return {
hasPermission: hasAccess,
effectiveLevel: userPermission.level as PermissionCheckResult['effectiveLevel'],
source: 'user'
};
}
// Check group permissions
const userGroups = await prisma.userGroup.findMany({
where: { userId },
select: { groupId: true }
});
if (userGroups.length > 0) {
const groupIds = userGroups.map((ug) => ug.groupId);
const groupPermissions = await prisma.permission.findMany({
where: {
entityType,
entityId,
targetType: TargetType.GROUP,
targetId: { in: groupIds }
}
});
if (groupPermissions.length > 0) {
// Use the highest group permission
const highestLevel = groupPermissions.reduce((highest, perm) => {
const permLevel = PERMISSION_HIERARCHY[perm.level] ?? 0;
const highestScore = PERMISSION_HIERARCHY[highest] ?? 0;
return permLevel > highestScore ? perm.level : highest;
}, groupPermissions[0].level);
const hasAccess =
PERMISSION_HIERARCHY[highestLevel] >= PERMISSION_HIERARCHY[requiredLevel];
return {
hasPermission: hasAccess,
effectiveLevel: highestLevel as PermissionCheckResult['effectiveLevel'],
source: 'group'
};
}
}
return {
hasPermission: false,
effectiveLevel: null,
source: null
};
}
export async function grantPermission(input: CreatePermissionInput) {
return prisma.permission.upsert({
where: {
entityType_entityId_targetType_targetId: {
entityType: input.entityType,
entityId: input.entityId,
targetType: input.targetType,
targetId: input.targetId
}
},
update: {
level: input.level
},
create: {
entityType: input.entityType,
entityId: input.entityId,
targetType: input.targetType,
targetId: input.targetId,
level: input.level
}
});
}
export async function revokePermission(
entityType: EntityType,
entityId: string,
targetType: TargetTypeType,
targetId: string
) {
await prisma.permission.deleteMany({
where: {
entityType,
entityId,
targetType,
targetId
}
});
}
export async function getPermissionsForEntity(entityType: EntityType, entityId: string) {
return prisma.permission.findMany({
where: { entityType, entityId },
orderBy: { createdAt: 'asc' }
});
}
export async function getPermissionsForTarget(
targetType: TargetTypeType,
targetId: string
) {
return prisma.permission.findMany({
where: { targetType, targetId },
orderBy: { createdAt: 'asc' }
});
}
export async function removeAllPermissionsForEntity(
entityType: EntityType,
entityId: string
) {
await prisma.permission.deleteMany({
where: { entityType, entityId }
});
}
+104
View File
@@ -0,0 +1,104 @@
import { prisma } from '../prisma.js';
import { hashPassword } from './authService.js';
import type { CreateUserInput, UpdateUserInput } from '$lib/types/user.js';
const USER_SELECT = {
id: true,
email: true,
displayName: true,
avatarUrl: true,
authProvider: true,
role: true,
createdAt: true,
updatedAt: true
} as const;
export async function findAll() {
return prisma.user.findMany({
select: USER_SELECT,
orderBy: { createdAt: 'desc' }
});
}
export async function findById(id: string) {
const user = await prisma.user.findUnique({
where: { id },
select: USER_SELECT
});
if (!user) {
throw new Error(`User not found: ${id}`);
}
return user;
}
export async function findByEmail(email: string) {
return prisma.user.findUnique({
where: { email },
select: {
...USER_SELECT,
password: true
}
});
}
export async function create(input: CreateUserInput) {
const existing = await prisma.user.findUnique({
where: { email: input.email }
});
if (existing) {
throw new Error(`User with email ${input.email} already exists`);
}
const hashedPassword = input.password ? await hashPassword(input.password) : null;
return prisma.user.create({
data: {
email: input.email,
password: hashedPassword,
displayName: input.displayName,
avatarUrl: input.avatarUrl ?? null,
authProvider: input.authProvider ?? 'local',
role: input.role ?? 'user'
},
select: USER_SELECT
});
}
export async function update(id: string, input: UpdateUserInput) {
await findById(id); // Ensure user exists
return prisma.user.update({
where: { id },
data: {
...(input.displayName !== undefined ? { displayName: input.displayName } : {}),
...(input.avatarUrl !== undefined ? { avatarUrl: input.avatarUrl } : {}),
...(input.role !== undefined ? { role: input.role } : {})
},
select: USER_SELECT
});
}
export async function remove(id: string) {
await findById(id); // Ensure user exists
await prisma.user.delete({ where: { id } });
}
export async function updateRole(id: string, role: string) {
return prisma.user.update({
where: { id },
data: { role },
select: USER_SELECT
});
}
export async function getUserGroups(userId: string) {
const memberships = await prisma.userGroup.findMany({
where: { userId },
include: { group: true }
});
return memberships.map((m) => m.group);
}
export async function count() {
return prisma.user.count();
}
+41
View File
@@ -0,0 +1,41 @@
export interface ApiResponse<T = unknown> {
readonly success: boolean;
readonly data: T | null;
readonly error: string | null;
readonly meta?: {
readonly total?: number;
readonly page?: number;
readonly limit?: number;
};
}
export function success<T>(data: T, meta?: ApiResponse['meta']): ApiResponse<T> {
return {
success: true,
data,
error: null,
...(meta ? { meta } : {})
};
}
export function error(message: string): ApiResponse<null> {
return {
success: false,
data: null,
error: message
};
}
export function paginated<T>(
data: T,
total: number,
page: number,
limit: number
): ApiResponse<T> {
return {
success: true,
data,
error: null,
meta: { total, page, limit }
};
}
+59
View File
@@ -0,0 +1,59 @@
import type { IconType, HealthcheckMethod, AppStatusValue } from '$lib/utils/constants';
export interface AppRecord {
readonly id: string;
readonly name: string;
readonly url: string;
readonly icon: string | null;
readonly iconType: IconType;
readonly description: string | null;
readonly category: string | null;
readonly tags: string;
readonly healthcheckEnabled: boolean;
readonly healthcheckInterval: number;
readonly healthcheckMethod: string;
readonly healthcheckExpectedStatus: number;
readonly healthcheckTimeout: number;
readonly createdById: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateAppInput {
readonly name: string;
readonly url: string;
readonly icon?: string;
readonly iconType?: IconType;
readonly description?: string;
readonly category?: string;
readonly tags?: string;
readonly healthcheckEnabled?: boolean;
readonly healthcheckInterval?: number;
readonly healthcheckMethod?: HealthcheckMethod;
readonly healthcheckExpectedStatus?: number;
readonly healthcheckTimeout?: number;
readonly createdById?: string;
}
export interface UpdateAppInput {
readonly name?: string;
readonly url?: string;
readonly icon?: string | null;
readonly iconType?: IconType;
readonly description?: string | null;
readonly category?: string | null;
readonly tags?: string;
readonly healthcheckEnabled?: boolean;
readonly healthcheckInterval?: number;
readonly healthcheckMethod?: HealthcheckMethod;
readonly healthcheckExpectedStatus?: number;
readonly healthcheckTimeout?: number;
}
export interface AppStatusRecord {
readonly id: string;
readonly appId: string;
readonly status: AppStatusValue;
readonly responseTime: number | null;
readonly checkedAt: Date;
}
+28
View File
@@ -0,0 +1,28 @@
export interface JwtPayload {
readonly userId: string;
readonly email: string;
readonly role: string;
}
export interface TokenPair {
readonly accessToken: string;
readonly refreshToken: string;
}
export interface LoginRequest {
readonly email: string;
readonly password: string;
}
export interface RegisterRequest {
readonly email: string;
readonly password: string;
readonly displayName: string;
}
export interface AuthSession {
readonly userId: string;
readonly email: string;
readonly role: string;
readonly expiresAt: Date;
}
+57
View File
@@ -0,0 +1,57 @@
export interface BoardRecord {
readonly id: string;
readonly name: string;
readonly icon: string | null;
readonly description: string | null;
readonly isDefault: boolean;
readonly isGuestAccessible: boolean;
readonly backgroundConfig: string | null;
readonly createdById: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateBoardInput {
readonly name: string;
readonly icon?: string;
readonly description?: string;
readonly isDefault?: boolean;
readonly isGuestAccessible?: boolean;
readonly backgroundConfig?: string;
readonly createdById?: string;
}
export interface UpdateBoardInput {
readonly name?: string;
readonly icon?: string | null;
readonly description?: string | null;
readonly isDefault?: boolean;
readonly isGuestAccessible?: boolean;
readonly backgroundConfig?: string | null;
}
export interface SectionRecord {
readonly id: string;
readonly boardId: string;
readonly title: string;
readonly icon: string | null;
readonly order: number;
readonly isExpandedByDefault: boolean;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateSectionInput {
readonly boardId: string;
readonly title: string;
readonly icon?: string;
readonly order?: number;
readonly isExpandedByDefault?: boolean;
}
export interface UpdateSectionInput {
readonly title?: string;
readonly icon?: string | null;
readonly order?: number;
readonly isExpandedByDefault?: boolean;
}
+20
View File
@@ -0,0 +1,20 @@
export interface GroupRecord {
readonly id: string;
readonly name: string;
readonly description: string | null;
readonly isDefault: boolean;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateGroupInput {
readonly name: string;
readonly description?: string;
readonly isDefault?: boolean;
}
export interface UpdateGroupInput {
readonly name?: string;
readonly description?: string | null;
readonly isDefault?: boolean;
}
+7
View File
@@ -0,0 +1,7 @@
export type * from './auth.js';
export type * from './user.js';
export type * from './group.js';
export type * from './app.js';
export type * from './board.js';
export type * from './widget.js';
export type * from './permission.js';
+26
View File
@@ -0,0 +1,26 @@
import type { EntityType, TargetType, PermissionLevel } from '$lib/utils/constants';
export interface PermissionRecord {
readonly id: string;
readonly entityType: EntityType;
readonly entityId: string;
readonly targetType: TargetType;
readonly targetId: string;
readonly level: PermissionLevel;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreatePermissionInput {
readonly entityType: EntityType;
readonly entityId: string;
readonly targetType: TargetType;
readonly targetId: string;
readonly level: PermissionLevel;
}
export interface PermissionCheckResult {
readonly hasPermission: boolean;
readonly effectiveLevel: PermissionLevel | null;
readonly source: 'user' | 'group' | 'admin' | null;
}
+27
View File
@@ -0,0 +1,27 @@
import type { UserRole, AuthProvider } from '$lib/utils/constants';
export interface UserRecord {
readonly id: string;
readonly email: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly authProvider: AuthProvider;
readonly role: UserRole;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateUserInput {
readonly email: string;
readonly password?: string;
readonly displayName: string;
readonly avatarUrl?: string;
readonly authProvider?: AuthProvider;
readonly role?: UserRole;
}
export interface UpdateUserInput {
readonly displayName?: string;
readonly avatarUrl?: string | null;
readonly role?: UserRole;
}
+55
View File
@@ -0,0 +1,55 @@
import type { WidgetType } from '$lib/utils/constants';
export interface WidgetRecord {
readonly id: string;
readonly sectionId: string;
readonly type: WidgetType;
readonly order: number;
readonly config: string;
readonly appId: string | null;
readonly createdAt: Date;
readonly updatedAt: Date;
}
export interface CreateWidgetInput {
readonly sectionId: string;
readonly type: WidgetType;
readonly order?: number;
readonly config?: string;
readonly appId?: string;
}
export interface UpdateWidgetInput {
readonly type?: WidgetType;
readonly order?: number;
readonly config?: string;
readonly appId?: string | null;
}
// Typed config shapes for different widget types
export interface AppWidgetConfig {
readonly appId: string;
readonly showStatus?: boolean;
readonly openInNewTab?: boolean;
}
export interface BookmarkWidgetConfig {
readonly url: string;
readonly title: string;
readonly icon?: string;
readonly openInNewTab?: boolean;
}
export interface NoteWidgetConfig {
readonly content: string;
}
export interface EmbedWidgetConfig {
readonly url: string;
readonly height?: number;
}
export interface StatusWidgetConfig {
readonly appIds: readonly string[];
readonly layout?: 'grid' | 'list';
}
+98
View File
@@ -0,0 +1,98 @@
// User roles
export const UserRole = {
ADMIN: 'admin',
USER: 'user'
} as const;
export type UserRole = (typeof UserRole)[keyof typeof UserRole];
// Authentication modes
export const AuthMode = {
LOCAL: 'local',
OAUTH: 'oauth',
BOTH: 'both'
} as const;
export type AuthMode = (typeof AuthMode)[keyof typeof AuthMode];
// Auth providers
export const AuthProvider = {
LOCAL: 'local',
OAUTH: 'oauth'
} as const;
export type AuthProvider = (typeof AuthProvider)[keyof typeof AuthProvider];
// App status values
export const AppStatusValue = {
ONLINE: 'online',
OFFLINE: 'offline',
DEGRADED: 'degraded',
UNKNOWN: 'unknown'
} as const;
export type AppStatusValue = (typeof AppStatusValue)[keyof typeof AppStatusValue];
// Widget types
export const WidgetType = {
APP: 'app',
BOOKMARK: 'bookmark',
NOTE: 'note',
EMBED: 'embed',
STATUS: 'status'
} as const;
export type WidgetType = (typeof WidgetType)[keyof typeof WidgetType];
// Icon types
export const IconType = {
LUCIDE: 'lucide',
SIMPLE: 'simple',
URL: 'url',
EMOJI: 'emoji'
} as const;
export type IconType = (typeof IconType)[keyof typeof IconType];
// Permission levels (ordered by privilege)
export const PermissionLevel = {
VIEW: 'view',
EDIT: 'edit',
ADMIN: 'admin'
} as const;
export type PermissionLevel = (typeof PermissionLevel)[keyof typeof PermissionLevel];
// Permission hierarchy for comparison
export const PERMISSION_HIERARCHY: Record<string, number> = {
[PermissionLevel.VIEW]: 1,
[PermissionLevel.EDIT]: 2,
[PermissionLevel.ADMIN]: 3
};
// Entity types for permissions
export const EntityType = {
BOARD: 'board',
APP: 'app'
} as const;
export type EntityType = (typeof EntityType)[keyof typeof EntityType];
// Target types for permissions
export const TargetType = {
USER: 'user',
GROUP: 'group'
} as const;
export type TargetType = (typeof TargetType)[keyof typeof TargetType];
// Healthcheck method
export const HealthcheckMethod = {
GET: 'GET',
HEAD: 'HEAD'
} as const;
export type HealthcheckMethod = (typeof HealthcheckMethod)[keyof typeof HealthcheckMethod];
// Defaults
export const DEFAULTS = {
HEALTHCHECK_INTERVAL: 300,
HEALTHCHECK_TIMEOUT: 5000,
HEALTHCHECK_EXPECTED_STATUS: 200,
HEALTHCHECK_METHOD: 'GET',
JWT_EXPIRY: '15m',
REFRESH_TOKEN_EXPIRY_DAYS: 7,
DEFAULT_THEME: 'dark',
DEFAULT_PRIMARY_COLOR: '#6366f1',
SYSTEM_SETTINGS_ID: 'singleton'
} as const;
+169
View File
@@ -0,0 +1,169 @@
import { z } from 'zod';
import {
UserRole,
AuthMode,
WidgetType,
IconType,
PermissionLevel,
EntityType,
TargetType,
HealthcheckMethod
} from './constants.js';
// --- Auth ---
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required')
});
export const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6, 'Password must be at least 6 characters'),
displayName: z.string().min(1, 'Display name is required').max(100)
});
// --- User ---
export const createUserSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(6).optional(),
displayName: z.string().min(1).max(100),
avatarUrl: z.string().url().optional(),
authProvider: z.enum([AuthMode.LOCAL, AuthMode.OAUTH]).optional(),
role: z.enum([UserRole.ADMIN, UserRole.USER]).optional()
});
export const updateUserSchema = z.object({
displayName: z.string().min(1).max(100).optional(),
avatarUrl: z.string().url().nullable().optional(),
role: z.enum([UserRole.ADMIN, UserRole.USER]).optional()
});
// --- Group ---
export const createGroupSchema = z.object({
name: z.string().min(1, 'Group name is required').max(100),
description: z.string().max(500).optional(),
isDefault: z.boolean().optional()
});
export const updateGroupSchema = z.object({
name: z.string().min(1).max(100).optional(),
description: z.string().max(500).nullable().optional(),
isDefault: z.boolean().optional()
});
// --- App ---
export const createAppSchema = z.object({
name: z.string().min(1, 'App name is required').max(200),
url: z.string().url('Invalid URL'),
icon: z.string().max(500).optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).optional(),
category: z.string().max(100).optional(),
tags: z.string().max(500).optional(),
healthcheckEnabled: z.boolean().optional(),
healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(),
healthcheckTimeout: z.number().int().min(1000).max(30000).optional()
});
export const updateAppSchema = z.object({
name: z.string().min(1).max(200).optional(),
url: z.string().url().optional(),
icon: z.string().max(500).nullable().optional(),
iconType: z.enum([IconType.LUCIDE, IconType.SIMPLE, IconType.URL, IconType.EMOJI]).optional(),
description: z.string().max(1000).nullable().optional(),
category: z.string().max(100).nullable().optional(),
tags: z.string().max(500).optional(),
healthcheckEnabled: z.boolean().optional(),
healthcheckInterval: z.number().int().min(30).max(86400).optional(),
healthcheckMethod: z.enum([HealthcheckMethod.GET, HealthcheckMethod.HEAD]).optional(),
healthcheckExpectedStatus: z.number().int().min(100).max(599).optional(),
healthcheckTimeout: z.number().int().min(1000).max(30000).optional()
});
// --- Board ---
export const createBoardSchema = z.object({
name: z.string().min(1, 'Board name is required').max(200),
icon: z.string().max(500).optional(),
description: z.string().max(1000).optional(),
isDefault: z.boolean().optional(),
isGuestAccessible: z.boolean().optional(),
backgroundConfig: z.string().optional()
});
export const updateBoardSchema = z.object({
name: z.string().min(1).max(200).optional(),
icon: z.string().max(500).nullable().optional(),
description: z.string().max(1000).nullable().optional(),
isDefault: z.boolean().optional(),
isGuestAccessible: z.boolean().optional(),
backgroundConfig: z.string().nullable().optional()
});
// --- Section ---
export const createSectionSchema = z.object({
boardId: z.string().cuid(),
title: z.string().min(1, 'Section title is required').max(200),
icon: z.string().max(500).optional(),
order: z.number().int().min(0).optional(),
isExpandedByDefault: z.boolean().optional()
});
export const updateSectionSchema = z.object({
title: z.string().min(1).max(200).optional(),
icon: z.string().max(500).nullable().optional(),
order: z.number().int().min(0).optional(),
isExpandedByDefault: z.boolean().optional()
});
// --- Widget ---
export const createWidgetSchema = z.object({
sectionId: z.string().cuid(),
type: z.enum([WidgetType.APP, WidgetType.BOOKMARK, WidgetType.NOTE, WidgetType.EMBED, WidgetType.STATUS]),
order: z.number().int().min(0).optional(),
config: z.string().optional(),
appId: z.string().cuid().optional()
});
export const updateWidgetSchema = z.object({
type: z
.enum([WidgetType.APP, WidgetType.BOOKMARK, WidgetType.NOTE, WidgetType.EMBED, WidgetType.STATUS])
.optional(),
order: z.number().int().min(0).optional(),
config: z.string().optional(),
appId: z.string().cuid().nullable().optional()
});
// --- Permission ---
export const createPermissionSchema = z.object({
entityType: z.enum([EntityType.BOARD, EntityType.APP]),
entityId: z.string().cuid(),
targetType: z.enum([TargetType.USER, TargetType.GROUP]),
targetId: z.string().cuid(),
level: z.enum([PermissionLevel.VIEW, PermissionLevel.EDIT, PermissionLevel.ADMIN])
});
// --- System Settings ---
export const updateSystemSettingsSchema = z.object({
authMode: z.enum([AuthMode.LOCAL, AuthMode.OAUTH, AuthMode.BOTH]).optional(),
registrationEnabled: z.boolean().optional(),
oauthClientId: z.string().nullable().optional(),
oauthClientSecret: z.string().nullable().optional(),
oauthDiscoveryUrl: z.string().url().nullable().optional(),
defaultTheme: z.enum(['dark', 'light']).optional(),
defaultPrimaryColor: z
.string()
.regex(/^#[0-9a-fA-F]{6}$/, 'Invalid hex color')
.optional(),
healthcheckDefaults: z.string().optional()
});