upd: routing dev
This commit is contained in:
33
bun.lock
33
bun.lock
@@ -11,10 +11,13 @@
|
||||
"@mantine/charts": "^9.0.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@prisma/client": "6",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"@xyflow/react": "^12.6.4",
|
||||
"elkjs": "^0.9.3",
|
||||
"elysia": "^1.4.28",
|
||||
"minio": "^8.0.7",
|
||||
"postcss": "^8.5.8",
|
||||
@@ -200,6 +203,8 @@
|
||||
|
||||
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
|
||||
|
||||
"@mantine/modals": ["@mantine/modals@8.3.18", "", { "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-JfPDS4549L314SxFPC1x6CbKwzh82OdnIzwgMxPCVNsWLKV2vEHHUH/fzUYj4Wli6IBrsW4cufjMj9BTj3hm3Q=="],
|
||||
|
||||
"@mantine/notifications": ["@mantine/notifications@8.3.18", "", { "dependencies": { "@mantine/store": "8.3.18", "react-transition-group": "4.4.5" }, "peerDependencies": { "@mantine/core": "8.3.18", "@mantine/hooks": "8.3.18", "react": "^18.x || ^19.x", "react-dom": "^18.x || ^19.x" } }, "sha512-IpQ0lmwbigTBbZCR6iSYWqIOKEx1tlcd7PcEJ5M5X1qeVSY/N3mmDQt1eJmObvcyDeL5cTJMbSA9UPqhRqo9jw=="],
|
||||
|
||||
"@mantine/store": ["@mantine/store@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-i+QRTLmZzLldea0egtUVnGALd6UMIu8jd44nrNWBSNIXJU/8B6rMlC6gyX+l4szopZSuOaaNJIXkqRdC1gQsVg=="],
|
||||
@@ -314,6 +319,8 @@
|
||||
|
||||
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
|
||||
|
||||
"@types/d3-drag": ["@types/d3-drag@3.0.7", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ=="],
|
||||
|
||||
"@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="],
|
||||
|
||||
"@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="],
|
||||
@@ -322,12 +329,18 @@
|
||||
|
||||
"@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="],
|
||||
|
||||
"@types/d3-selection": ["@types/d3-selection@3.0.11", "", {}, "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w=="],
|
||||
|
||||
"@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="],
|
||||
|
||||
"@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="],
|
||||
|
||||
"@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="],
|
||||
|
||||
"@types/d3-transition": ["@types/d3-transition@3.0.9", "", { "dependencies": { "@types/d3-selection": "*" } }, "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg=="],
|
||||
|
||||
"@types/d3-zoom": ["@types/d3-zoom@3.0.8", "", { "dependencies": { "@types/d3-interpolate": "*", "@types/d3-selection": "*" } }, "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw=="],
|
||||
|
||||
"@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="],
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
@@ -342,6 +355,10 @@
|
||||
|
||||
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
|
||||
|
||||
"@xyflow/react": ["@xyflow/react@12.10.2", "", { "dependencies": { "@xyflow/system": "0.0.76", "classcat": "^5.0.3", "zustand": "^4.4.0" }, "peerDependencies": { "react": ">=17", "react-dom": ">=17" } }, "sha512-CgIi6HwlcHXwlkTpr0fxLv/0sRVNZ8IdwKLzzeCscaYBwpvfcH1QFOCeaTCuEn1FQEs/B8CjnTSjhs8udgmBgQ=="],
|
||||
|
||||
"@xyflow/system": ["@xyflow/system@0.0.76", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-hvwvnRS1B3REwVDlWexsq7YQaPZeG3/mKo1jv38UmnpWmxihp14bW6VtEOuHEwJX2FvzFw8k77LyKSk/wiZVNA=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
@@ -406,6 +423,8 @@
|
||||
|
||||
"citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="],
|
||||
|
||||
"classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="],
|
||||
|
||||
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
|
||||
|
||||
"clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="],
|
||||
@@ -432,6 +451,10 @@
|
||||
|
||||
"d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="],
|
||||
|
||||
"d3-dispatch": ["d3-dispatch@3.0.1", "", {}, "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg=="],
|
||||
|
||||
"d3-drag": ["d3-drag@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-selection": "3" } }, "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg=="],
|
||||
|
||||
"d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="],
|
||||
|
||||
"d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="],
|
||||
@@ -442,6 +465,8 @@
|
||||
|
||||
"d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="],
|
||||
|
||||
"d3-selection": ["d3-selection@3.0.0", "", {}, "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ=="],
|
||||
|
||||
"d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="],
|
||||
|
||||
"d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="],
|
||||
@@ -450,6 +475,10 @@
|
||||
|
||||
"d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="],
|
||||
|
||||
"d3-transition": ["d3-transition@3.0.1", "", { "dependencies": { "d3-color": "1 - 3", "d3-dispatch": "1 - 3", "d3-ease": "1 - 3", "d3-interpolate": "1 - 3", "d3-timer": "1 - 3" }, "peerDependencies": { "d3-selection": "2 - 3" } }, "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w=="],
|
||||
|
||||
"d3-zoom": ["d3-zoom@3.0.0", "", { "dependencies": { "d3-dispatch": "1 - 3", "d3-drag": "2 - 3", "d3-interpolate": "1 - 3", "d3-selection": "2 - 3", "d3-transition": "2 - 3" } }, "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw=="],
|
||||
|
||||
"data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
@@ -484,6 +513,8 @@
|
||||
|
||||
"electron-to-chromium": ["electron-to-chromium@1.5.329", "", {}, "sha512-/4t+AS1l4S3ZC0Ja7PHFIWeBIxGA3QGqV8/yKsP36v7NcyUCl+bIcmw6s5zVuMIECWwBrAK/6QLzTmbJChBboQ=="],
|
||||
|
||||
"elkjs": ["elkjs@0.9.3", "", {}, "sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ=="],
|
||||
|
||||
"elysia": ["elysia@1.4.28", "", { "dependencies": { "cookie": "^1.1.1", "exact-mirror": "^0.2.7", "fast-decode-uri-component": "^1.0.1", "memoirist": "^0.4.0" }, "peerDependencies": { "@sinclair/typebox": ">= 0.34.0 < 1", "@types/bun": ">= 1.2.0", "file-type": ">= 20.0.0", "openapi-types": ">= 12.0.0", "typescript": ">= 5.0.0" }, "optionalPeers": ["@types/bun", "typescript"] }, "sha512-Vrx8sBnvq8squS/3yNBzR1jBXI+SgmnmvwawPjNuEHndUe5l1jV2Gp6JJ4ulDkEB8On6bWmmuyPpA+bq4t+WYg=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
|
||||
@@ -888,6 +919,8 @@
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"zustand": ["zustand@4.5.7", "", { "dependencies": { "use-sync-external-store": "^1.2.2" }, "peerDependencies": { "@types/react": ">=16.8", "immer": ">=9.0.6", "react": ">=16.8" }, "optionalPeers": ["@types/react", "immer", "react"] }, "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
|
||||
"@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
|
||||
|
||||
@@ -42,7 +42,10 @@
|
||||
"react-dom": "^19",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "^3.8.1",
|
||||
"swr": "^2.4.1"
|
||||
"swr": "^2.4.1",
|
||||
"@mantine/modals": "^8.3.18",
|
||||
"@xyflow/react": "^12.6.4",
|
||||
"elkjs": "^0.9.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
|
||||
518
src/app.ts
518
src/app.ts
@@ -3,10 +3,13 @@ import { html } from '@elysiajs/html'
|
||||
import { swagger } from '@elysiajs/swagger'
|
||||
import { Elysia, t } from 'elysia'
|
||||
import { BugSource } from '../generated/prisma'
|
||||
import { appLog, clearAppLogs, getAppLogs } from './lib/applog'
|
||||
import { prisma } from './lib/db'
|
||||
import { env } from './lib/env'
|
||||
import { createSystemLog } from './lib/logger'
|
||||
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio'
|
||||
import { addConnection, broadcastToAdmins, getOnlineUserIds, removeConnection } from './lib/presence'
|
||||
import { parseSchema } from './lib/schema-parser'
|
||||
|
||||
function getPublicOrigin(request: Request): string {
|
||||
if (process.env.BUN_PUBLIC_BASE_URL) return process.env.BUN_PUBLIC_BASE_URL.replace(/\/$/, '')
|
||||
@@ -43,6 +46,19 @@ async function checkAuth(request: Request): Promise<AuthResult | null> {
|
||||
return null
|
||||
}
|
||||
|
||||
async function requireDeveloper(request: Request, set: { status?: number | string }): Promise<{ userId: string; role: string } | null> {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
if (!token) { set.status = 401; return null }
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { token },
|
||||
include: { user: { select: { id: true, role: true } } },
|
||||
})
|
||||
if (!session || session.expiresAt < new Date()) { set.status = 401; return null }
|
||||
if (session.user.role !== 'DEVELOPER') { set.status = 403; return null }
|
||||
return { userId: session.user.id, role: session.user.role }
|
||||
}
|
||||
|
||||
export function createApp() {
|
||||
return new Elysia()
|
||||
.use(swagger({
|
||||
@@ -63,6 +79,21 @@ export function createApp() {
|
||||
.use(cors())
|
||||
.use(html())
|
||||
|
||||
// ─── Request timing + app log broadcasting ────────
|
||||
.onRequest(({ request }) => {
|
||||
;(request as any).__startTime = performance.now()
|
||||
})
|
||||
.onAfterResponse(({ request, set }) => {
|
||||
const url = new URL(request.url)
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
const status = typeof set.status === 'number' ? set.status : 200
|
||||
const level = status >= 500 ? ('error' as const) : status >= 400 ? ('warn' as const) : ('info' as const)
|
||||
appLog(level, `${request.method} ${url.pathname} ${status}`)
|
||||
const duration = Math.round(performance.now() - ((request as any).__startTime || 0))
|
||||
broadcastToAdmins({ type: 'request', method: request.method, path: url.pathname, status, duration, timestamp: new Date().toISOString() })
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Global Error Handler ────────────────────────
|
||||
.onError(({ code, error }) => {
|
||||
if (code === 'NOT_FOUND') {
|
||||
@@ -181,7 +212,7 @@ export function createApp() {
|
||||
await prisma.session.create({ data: { token, userId: user.id, expiresAt } })
|
||||
await createSystemLog(user.id, 'LOGIN', 'Logged in with Google')
|
||||
|
||||
const redirectPath = user.role === 'USER' ? '/profile' : '/dashboard'
|
||||
const redirectPath = user.role === 'DEVELOPER' ? '/dev' : user.role === 'USER' ? '/profile' : '/dashboard'
|
||||
const headers = new Headers()
|
||||
headers.append('Location', redirectPath)
|
||||
headers.append('Set-Cookie', `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`)
|
||||
@@ -971,4 +1002,489 @@ export function createApp() {
|
||||
}),
|
||||
detail: { summary: 'Hello by Name', tags: ['System'] },
|
||||
})
|
||||
|
||||
// ─── Dev Console Admin API (DEVELOPER only) ────────
|
||||
|
||||
.get('/api/admin/stats', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const [totalApps, openBugs, totalOperators] = await Promise.all([
|
||||
prisma.app.count(),
|
||||
prisma.bug.count({ where: { status: 'OPEN' } }),
|
||||
prisma.user.count(),
|
||||
])
|
||||
const onlineCount = getOnlineUserIds().length
|
||||
return { totalApps, openBugs, totalOperators, onlineOperators: onlineCount }
|
||||
})
|
||||
|
||||
.get('/api/admin/users', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const users = await prisma.user.findMany({
|
||||
select: { id: true, name: true, email: true, role: true, active: true, image: true, createdAt: true },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
return { users }
|
||||
})
|
||||
|
||||
.put('/api/admin/users/:id/role', async ({ request, params, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
if (auth.userId === params.id) { set.status = 400; return { error: 'Tidak bisa mengubah role sendiri' } }
|
||||
const { role } = (await request.json()) as { role: string }
|
||||
if (!['USER', 'ADMIN'].includes(role)) { set.status = 400; return { error: 'Role tidak valid (USER atau ADMIN)' } }
|
||||
const target = await prisma.user.findUnique({ where: { id: params.id }, select: { role: true } })
|
||||
if (target?.role === 'DEVELOPER') { set.status = 400; return { error: 'Tidak bisa mengubah role DEVELOPER' } }
|
||||
const user = await prisma.user.update({
|
||||
where: { id: params.id },
|
||||
data: { role: role as 'USER' | 'ADMIN' },
|
||||
select: { id: true, name: true, email: true, role: true, active: true, createdAt: true },
|
||||
})
|
||||
await appLog('info', `Role changed: ${user.email} ${target?.role} → ${role}`)
|
||||
return { user }
|
||||
})
|
||||
|
||||
.put('/api/admin/users/:id/activate', async ({ request, params, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
if (auth.userId === params.id) { set.status = 400; return { error: 'Tidak bisa mengubah status sendiri' } }
|
||||
const { active } = (await request.json()) as { active: boolean }
|
||||
const user = await prisma.user.update({
|
||||
where: { id: params.id },
|
||||
data: { active },
|
||||
select: { id: true, name: true, email: true, role: true, active: true, createdAt: true },
|
||||
})
|
||||
if (!active) await prisma.session.deleteMany({ where: { userId: params.id } })
|
||||
await appLog('info', `User ${active ? 'activated' : 'deactivated'}: ${user.email}`)
|
||||
return { user }
|
||||
})
|
||||
|
||||
.ws('/ws/presence', {
|
||||
async open(ws) {
|
||||
const cookie = ws.data.headers?.cookie ?? ''
|
||||
const token = (cookie as string).match(/session=([^;]+)/)?.[1]
|
||||
if (!token) { ws.close(4001, 'Unauthorized'); return }
|
||||
const session = await prisma.session.findUnique({
|
||||
where: { token },
|
||||
include: { user: { select: { id: true, role: true } } },
|
||||
})
|
||||
if (!session || session.expiresAt < new Date()) { ws.close(4001, 'Unauthorized'); return }
|
||||
const isAdmin = session.user.role === 'DEVELOPER'
|
||||
;(ws.data as unknown as { userId: string }).userId = session.user.id
|
||||
addConnection(ws as any, session.user.id, isAdmin)
|
||||
},
|
||||
close(ws) { removeConnection(ws as any) },
|
||||
message() {},
|
||||
})
|
||||
|
||||
.get('/api/admin/presence', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
return { online: getOnlineUserIds() }
|
||||
})
|
||||
|
||||
.get('/api/admin/logs/app', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const url = new URL(request.url)
|
||||
const level = url.searchParams.get('level') as any
|
||||
const limit = parseInt(url.searchParams.get('limit') ?? '100', 10)
|
||||
const afterId = parseInt(url.searchParams.get('afterId') ?? '0', 10)
|
||||
if (!env.REDIS_URL) return { logs: [], redisDisabled: true }
|
||||
return { logs: await getAppLogs({ level: level || undefined, limit, afterId: afterId || undefined }) }
|
||||
})
|
||||
|
||||
.delete('/api/admin/logs/app', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
await clearAppLogs()
|
||||
return { ok: true }
|
||||
})
|
||||
|
||||
.get('/api/admin/logs/audit', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const url = new URL(request.url)
|
||||
const userId = url.searchParams.get('userId')
|
||||
const type = url.searchParams.get('type')
|
||||
const limit = Math.min(parseInt(url.searchParams.get('limit') ?? '100', 10), 500)
|
||||
const where: Record<string, any> = {}
|
||||
if (userId) where.userId = userId
|
||||
if (type) where.type = type
|
||||
const logs = await prisma.log.findMany({
|
||||
where,
|
||||
include: { user: { select: { name: true, email: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
})
|
||||
return { logs }
|
||||
})
|
||||
|
||||
.delete('/api/admin/logs/audit', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const { count } = await prisma.log.deleteMany()
|
||||
await appLog('info', `Activity logs cleared manually (${count} entries)`)
|
||||
return { ok: true, deleted: count }
|
||||
})
|
||||
|
||||
.get('/api/admin/schema', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const fs = await import('node:fs')
|
||||
const schemaPath = `${process.cwd()}/prisma/schema.prisma`
|
||||
if (!fs.existsSync(schemaPath)) { set.status = 404; return { error: 'Schema not found' } }
|
||||
const raw = fs.readFileSync(schemaPath, 'utf-8')
|
||||
return { schema: parseSchema(raw) }
|
||||
})
|
||||
|
||||
.get('/api/admin/routes', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const routes: { method: string; path: string; auth: string; category: string; description: string }[] = [
|
||||
{ method: 'PAGE', path: '/', auth: 'public', category: 'frontend', description: 'Landing page' },
|
||||
{ method: 'PAGE', path: '/login', auth: 'public', category: 'frontend', description: 'Login page (email/password + Google OAuth)' },
|
||||
{ method: 'PAGE', path: '/dev', auth: 'developer', category: 'frontend', description: 'Dev console (DEVELOPER only)' },
|
||||
{ method: 'PAGE', path: '/dashboard', auth: 'admin', category: 'frontend', description: 'Admin dashboard (ADMIN/DEVELOPER)' },
|
||||
{ method: 'PAGE', path: '/apps', auth: 'admin', category: 'frontend', description: 'App list' },
|
||||
{ method: 'PAGE', path: '/apps/:appId', auth: 'admin', category: 'frontend', description: 'App detail (errors, logs, users, etc.)' },
|
||||
{ method: 'PAGE', path: '/bug-reports', auth: 'admin', category: 'frontend', description: 'Bug reports management' },
|
||||
{ method: 'PAGE', path: '/logs', auth: 'admin', category: 'frontend', description: 'Activity logs' },
|
||||
{ method: 'PAGE', path: '/users', auth: 'admin', category: 'frontend', description: 'Operator management' },
|
||||
{ method: 'PAGE', path: '/profile', auth: 'authenticated', category: 'frontend', description: 'User profile' },
|
||||
{ method: 'POST', path: '/api/auth/login', auth: 'public', category: 'auth', description: 'Email/password login' },
|
||||
{ method: 'POST', path: '/api/auth/logout', auth: 'authenticated', category: 'auth', description: 'Logout' },
|
||||
{ method: 'GET', path: '/api/auth/session', auth: 'public', category: 'auth', description: 'Check current session' },
|
||||
{ method: 'GET', path: '/api/auth/google', auth: 'public', category: 'auth', description: 'Google OAuth redirect' },
|
||||
{ method: 'GET', path: '/api/auth/callback/google', auth: 'public', category: 'auth', description: 'Google OAuth callback' },
|
||||
{ method: 'GET', path: '/api/dashboard/stats', auth: 'authenticated', category: 'dashboard', description: 'Dashboard statistics' },
|
||||
{ method: 'GET', path: '/api/dashboard/recent-errors', auth: 'authenticated', category: 'dashboard', description: 'Recent bug reports' },
|
||||
{ method: 'GET', path: '/api/apps', auth: 'authenticated', category: 'apps', description: 'List monitored apps' },
|
||||
{ method: 'GET', path: '/api/apps/:appId', auth: 'authenticated', category: 'apps', description: 'Get app details' },
|
||||
{ method: 'GET', path: '/api/bugs', auth: 'authenticated', category: 'bugs', description: 'List bug reports' },
|
||||
{ method: 'POST', path: '/api/bugs', auth: 'apiKeyOrSession', category: 'bugs', description: 'Create bug report' },
|
||||
{ method: 'PATCH', path: '/api/bugs/:id/status', auth: 'authenticated', category: 'bugs', description: 'Update bug status' },
|
||||
{ method: 'PATCH', path: '/api/bugs/:id/feedback', auth: 'authenticated', category: 'bugs', description: 'Update bug feedback' },
|
||||
{ method: 'POST', path: '/api/upload/image', auth: 'apiKeyOrSession', category: 'bugs', description: 'Upload bug screenshot' },
|
||||
{ method: 'GET', path: '/api/bugs/images', auth: 'public', category: 'bugs', description: 'Proxy bug image from MinIO' },
|
||||
{ method: 'GET', path: '/api/logs', auth: 'authenticated', category: 'logs', description: 'List activity logs' },
|
||||
{ method: 'POST', path: '/api/logs', auth: 'authenticated', category: 'logs', description: 'Create activity log' },
|
||||
{ method: 'GET', path: '/api/logs/operators', auth: 'authenticated', category: 'logs', description: 'Operators list for log filter' },
|
||||
{ method: 'GET', path: '/api/operators', auth: 'authenticated', category: 'operators', description: 'List operators' },
|
||||
{ method: 'GET', path: '/api/operators/stats', auth: 'authenticated', category: 'operators', description: 'Operator stats' },
|
||||
{ method: 'POST', path: '/api/operators', auth: 'authenticated', category: 'operators', description: 'Create operator' },
|
||||
{ method: 'PATCH', path: '/api/operators/:id', auth: 'authenticated', category: 'operators', description: 'Update operator' },
|
||||
{ method: 'DELETE', path: '/api/operators/:id', auth: 'authenticated', category: 'operators', description: 'Deactivate operator' },
|
||||
{ method: 'GET', path: '/api/admin/stats', auth: 'developer', category: 'admin', description: 'Dev console overview stats' },
|
||||
{ method: 'GET', path: '/api/admin/users', auth: 'developer', category: 'admin', description: 'List all users' },
|
||||
{ method: 'PUT', path: '/api/admin/users/:id/role', auth: 'developer', category: 'admin', description: 'Change user role' },
|
||||
{ method: 'PUT', path: '/api/admin/users/:id/activate', auth: 'developer', category: 'admin', description: 'Activate/deactivate user' },
|
||||
{ method: 'GET', path: '/api/admin/presence', auth: 'developer', category: 'admin', description: 'Online user IDs' },
|
||||
{ method: 'GET', path: '/api/admin/logs/app', auth: 'developer', category: 'admin', description: 'App logs (Redis)' },
|
||||
{ method: 'GET', path: '/api/admin/logs/audit', auth: 'developer', category: 'admin', description: 'Activity logs (DB)' },
|
||||
{ method: 'DELETE', path: '/api/admin/logs/app', auth: 'developer', category: 'admin', description: 'Clear app logs' },
|
||||
{ method: 'DELETE', path: '/api/admin/logs/audit', auth: 'developer', category: 'admin', description: 'Clear activity logs' },
|
||||
{ method: 'GET', path: '/api/admin/schema', auth: 'developer', category: 'admin', description: 'Database schema (Prisma)' },
|
||||
{ method: 'GET', path: '/api/admin/routes', auth: 'developer', category: 'admin', description: 'Routes metadata' },
|
||||
{ method: 'GET', path: '/api/admin/project-structure', auth: 'developer', category: 'admin', description: 'Project file structure' },
|
||||
{ method: 'GET', path: '/api/admin/env-map', auth: 'developer', category: 'admin', description: 'Environment variables map' },
|
||||
{ method: 'GET', path: '/api/admin/test-coverage', auth: 'developer', category: 'admin', description: 'Test coverage mapping' },
|
||||
{ method: 'GET', path: '/api/admin/dependencies', auth: 'developer', category: 'admin', description: 'NPM dependencies graph' },
|
||||
{ method: 'GET', path: '/api/admin/migrations', auth: 'developer', category: 'admin', description: 'Migration timeline' },
|
||||
{ method: 'GET', path: '/api/admin/sessions', auth: 'developer', category: 'admin', description: 'Active sessions' },
|
||||
{ method: 'WS', path: '/ws/presence', auth: 'authenticated', category: 'realtime', description: 'Real-time presence tracking' },
|
||||
{ method: 'GET', path: '/health', auth: 'public', category: 'utility', description: 'Health check' },
|
||||
]
|
||||
const byMethod: Record<string, number> = {}
|
||||
const byAuth: Record<string, number> = {}
|
||||
const byCategory: Record<string, number> = {}
|
||||
for (const r of routes) {
|
||||
byMethod[r.method] = (byMethod[r.method] || 0) + 1
|
||||
byAuth[r.auth] = (byAuth[r.auth] || 0) + 1
|
||||
byCategory[r.category] = (byCategory[r.category] || 0) + 1
|
||||
}
|
||||
return { routes, summary: { total: routes.length, byMethod, byAuth, byCategory } }
|
||||
})
|
||||
|
||||
.get('/api/admin/project-structure', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const fs = await import('node:fs')
|
||||
const path = await import('node:path')
|
||||
const root = process.cwd()
|
||||
const scanDirs = ['src', 'prisma', 'tests']
|
||||
const skipDirs = new Set(['node_modules', 'dist', 'generated', '.git', '.next'])
|
||||
const exts = new Set(['.ts', '.tsx'])
|
||||
interface FileInfo { path: string; category: string; lines: number; exports: string[]; imports: { from: string; names: string[] }[] }
|
||||
interface DirInfo { path: string; category: string; fileCount: number }
|
||||
const files: FileInfo[] = []
|
||||
const dirs: DirInfo[] = []
|
||||
function categorize(filePath: string): string {
|
||||
if (filePath.startsWith('src/frontend/routes/')) return 'route'
|
||||
if (filePath.startsWith('src/frontend/hooks/')) return 'hook'
|
||||
if (filePath.startsWith('src/frontend/components/')) return 'component'
|
||||
if (filePath.startsWith('src/frontend')) return 'frontend'
|
||||
if (filePath.startsWith('src/lib/')) return 'lib'
|
||||
if (filePath.startsWith('prisma/')) return 'prisma'
|
||||
if (filePath.startsWith('tests/unit/')) return 'test-unit'
|
||||
if (filePath.startsWith('tests/integration/')) return 'test-integration'
|
||||
if (filePath.startsWith('tests/')) return 'test'
|
||||
if (filePath.startsWith('src/')) return 'backend'
|
||||
return 'config'
|
||||
}
|
||||
function parseFile(filePath: string, content: string): FileInfo {
|
||||
const lines = content.split('\n').length
|
||||
const exports: string[] = []
|
||||
const imports: { from: string; names: string[] }[] = []
|
||||
for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|const|let|var|class|type|interface|enum)\s+(\w+)/g)) exports.push(m[1])
|
||||
for (const m of content.matchAll(/import\s+(?:\{([^}]+)\}|(\w+))(?:\s*,\s*\{([^}]+)\})?\s+from\s+['"]([^'"]+)['"]/g)) {
|
||||
const names: string[] = []
|
||||
if (m[1]) names.push(...m[1].split(',').map((s) => s.trim().split(' as ')[0].trim()).filter(Boolean))
|
||||
if (m[2]) names.push(m[2])
|
||||
if (m[3]) names.push(...m[3].split(',').map((s) => s.trim().split(' as ')[0].trim()).filter(Boolean))
|
||||
let from = m[4]
|
||||
if (from.startsWith('.')) {
|
||||
const dir = path.dirname(filePath)
|
||||
from = path.normalize(path.join(dir, from)).replace(/\\/g, '/')
|
||||
for (const ext of ['.ts', '.tsx', '/index.ts', '/index.tsx']) {
|
||||
if (fs.existsSync(path.join(root, from + ext))) { from = from + ext; break }
|
||||
if (fs.existsSync(path.join(root, from))) break
|
||||
}
|
||||
}
|
||||
imports.push({ from, names })
|
||||
}
|
||||
return { path: filePath, category: categorize(filePath), lines, exports, imports }
|
||||
}
|
||||
function scan(dir: string) {
|
||||
const absDir = path.join(root, dir)
|
||||
if (!fs.existsSync(absDir)) return
|
||||
const entries = fs.readdirSync(absDir, { withFileTypes: true })
|
||||
let fileCount = 0
|
||||
for (const entry of entries) {
|
||||
if (skipDirs.has(entry.name)) continue
|
||||
const rel = path.join(dir, entry.name).replace(/\\/g, '/')
|
||||
if (entry.isDirectory()) scan(rel)
|
||||
else if (exts.has(path.extname(entry.name))) { files.push(parseFile(rel, fs.readFileSync(path.join(root, rel), 'utf-8'))); fileCount++ }
|
||||
}
|
||||
dirs.push({ path: dir, category: categorize(`${dir}/`), fileCount })
|
||||
}
|
||||
for (const d of scanDirs) scan(d)
|
||||
files.sort((a, b) => a.path.localeCompare(b.path))
|
||||
dirs.sort((a, b) => a.path.localeCompare(b.path))
|
||||
const totalLines = files.reduce((s, f) => s + f.lines, 0)
|
||||
const totalExports = files.reduce((s, f) => s + f.exports.length, 0)
|
||||
const totalImports = files.reduce((s, f) => s + f.imports.length, 0)
|
||||
const byCategory: Record<string, number> = {}
|
||||
for (const f of files) byCategory[f.category] = (byCategory[f.category] || 0) + 1
|
||||
return { files, directories: dirs, summary: { totalFiles: files.length, totalLines, totalExports, totalImports, byCategory } }
|
||||
})
|
||||
|
||||
.get('/api/admin/env-map', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const fs = await import('node:fs')
|
||||
const path = await import('node:path')
|
||||
const root = process.cwd()
|
||||
const envDefs: { name: string; envKey: string; required: boolean; default: string | null; category: string; description: string }[] = [
|
||||
{ name: 'DATABASE_URL', envKey: 'DATABASE_URL', required: true, default: null, category: 'database', description: 'PostgreSQL connection string' },
|
||||
{ name: 'REDIS_URL', envKey: 'REDIS_URL', required: false, default: '(empty)', category: 'cache', description: 'Redis connection string (optional, enables App Logs)' },
|
||||
{ name: 'GOOGLE_CLIENT_ID', envKey: 'GOOGLE_CLIENT_ID', required: true, default: null, category: 'auth', description: 'Google OAuth client ID' },
|
||||
{ name: 'GOOGLE_CLIENT_SECRET', envKey: 'GOOGLE_CLIENT_SECRET', required: true, default: null, category: 'auth', description: 'Google OAuth client secret' },
|
||||
{ name: 'SUPER_ADMIN_EMAIL', envKey: 'SUPER_ADMIN_EMAIL', required: false, default: '(empty)', category: 'auth', description: 'Emails to auto-promote to DEVELOPER role' },
|
||||
{ name: 'API_KEY', envKey: 'API_KEY', required: true, default: null, category: 'auth', description: 'API key for external clients (mobile app)' },
|
||||
{ name: 'MINIO_ENDPOINT', envKey: 'MINIO_ENDPOINT', required: true, default: null, category: 'storage', description: 'MinIO server endpoint' },
|
||||
{ name: 'MINIO_PORT', envKey: 'MINIO_PORT', required: false, default: '443', category: 'storage', description: 'MinIO server port' },
|
||||
{ name: 'MINIO_USE_SSL', envKey: 'MINIO_USE_SSL', required: false, default: 'true', category: 'storage', description: 'Use SSL for MinIO connection' },
|
||||
{ name: 'MINIO_ACCESS_KEY', envKey: 'MINIO_ACCESS_KEY', required: true, default: null, category: 'storage', description: 'MinIO access key' },
|
||||
{ name: 'MINIO_SECRET_KEY', envKey: 'MINIO_SECRET_KEY', required: true, default: null, category: 'storage', description: 'MinIO secret key' },
|
||||
{ name: 'MINIO_BUCKET', envKey: 'MINIO_BUCKET', required: true, default: null, category: 'storage', description: 'MinIO bucket name' },
|
||||
{ name: 'MINIO_UPLOAD_DIR', envKey: 'MINIO_UPLOAD_DIR', required: false, default: 'bug-reports', category: 'storage', description: 'MinIO upload directory prefix' },
|
||||
{ name: 'PORT', envKey: 'PORT', required: false, default: '3000', category: 'app', description: 'Server port' },
|
||||
{ name: 'NODE_ENV', envKey: 'NODE_ENV', required: false, default: 'development', category: 'app', description: 'Environment mode' },
|
||||
{ name: 'REACT_EDITOR', envKey: 'REACT_EDITOR', required: false, default: 'code', category: 'app', description: 'Editor for click-to-source' },
|
||||
{ name: 'BUN_PUBLIC_BASE_URL', envKey: 'BUN_PUBLIC_BASE_URL', required: false, default: 'http://localhost:3000', category: 'app', description: 'Public base URL (for OAuth redirect)' },
|
||||
]
|
||||
const srcFiles = ['src/lib/env.ts', 'src/lib/db.ts', 'src/lib/redis.ts', 'src/app.ts', 'src/index.tsx']
|
||||
const fileContents: Record<string, string> = {}
|
||||
for (const f of srcFiles) {
|
||||
const absPath = path.join(root, f)
|
||||
if (fs.existsSync(absPath)) fileContents[f] = fs.readFileSync(absPath, 'utf-8')
|
||||
}
|
||||
const variables = envDefs.map((def) => {
|
||||
const usedBy: string[] = []
|
||||
for (const [file, content] of Object.entries(fileContents)) {
|
||||
if (content.includes(def.envKey) || content.includes(`env.${def.name}`)) usedBy.push(file)
|
||||
}
|
||||
return { name: def.name, required: def.required, isSet: !!process.env[def.envKey], default: def.default, category: def.category, description: def.description, usedBy }
|
||||
})
|
||||
const byCategory: Record<string, number> = {}
|
||||
let setCount = 0, requiredCount = 0
|
||||
for (const v of variables) {
|
||||
byCategory[v.category] = (byCategory[v.category] || 0) + 1
|
||||
if (v.isSet) setCount++
|
||||
if (v.required) requiredCount++
|
||||
}
|
||||
return { variables, summary: { total: variables.length, set: setCount, unset: variables.length - setCount, required: requiredCount, byCategory } }
|
||||
})
|
||||
|
||||
.get('/api/admin/test-coverage', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const fs = await import('node:fs')
|
||||
const pathMod = await import('node:path')
|
||||
const root = process.cwd()
|
||||
const exts = new Set(['.ts', '.tsx'])
|
||||
const skipDirs = new Set(['node_modules', 'dist', 'generated', '.git'])
|
||||
interface SrcFile { path: string; lines: number; exports: string[]; testedBy: string[]; coverage: string }
|
||||
interface TestFile { path: string; lines: number; type: string; targets: string[] }
|
||||
function scanDir(dir: string, collect: string[]) {
|
||||
const abs = pathMod.join(root, dir)
|
||||
if (!fs.existsSync(abs)) return
|
||||
for (const entry of fs.readdirSync(abs, { withFileTypes: true })) {
|
||||
if (skipDirs.has(entry.name)) continue
|
||||
const rel = pathMod.join(dir, entry.name).replace(/\\/g, '/')
|
||||
if (entry.isDirectory()) scanDir(rel, collect)
|
||||
else if (exts.has(pathMod.extname(entry.name))) collect.push(rel)
|
||||
}
|
||||
}
|
||||
const srcPaths: string[] = []
|
||||
scanDir('src', srcPaths)
|
||||
const srcFiltered = srcPaths.filter((f) => !f.includes('routeTree.gen'))
|
||||
const testPaths: string[] = []
|
||||
scanDir('tests', testPaths)
|
||||
const testFiltered = testPaths.filter((f) => f.includes('.test.'))
|
||||
const testFiles: TestFile[] = testFiltered.map((tp) => {
|
||||
const content = fs.readFileSync(pathMod.join(root, tp), 'utf-8')
|
||||
const lines = content.split('\n').length
|
||||
const type = tp.includes('/unit/') ? 'unit' : tp.includes('/integration/') ? 'integration' : 'other'
|
||||
const targets: string[] = []
|
||||
for (const m of content.matchAll(/from\s+['"]([^'"]*(?:src|lib)[^'"]*)['"]/g)) {
|
||||
let resolved = m[1].replace(/^.*?src\//, 'src/')
|
||||
if (resolved.startsWith('.')) resolved = pathMod.normalize(pathMod.join(pathMod.dirname(tp), resolved)).replace(/\\/g, '/')
|
||||
for (const ext of ['', '.ts', '.tsx']) {
|
||||
const full = resolved + ext
|
||||
if (srcFiltered.includes(full)) { targets.push(full); break }
|
||||
}
|
||||
}
|
||||
if (/fetch\(['"`]\/api\//.test(content) || /createApp|createTestApp/.test(content)) {
|
||||
if (!targets.includes('src/app.ts')) targets.push('src/app.ts')
|
||||
}
|
||||
return { path: tp, lines, type, targets: [...new Set(targets)] }
|
||||
})
|
||||
const testedByMap: Record<string, string[]> = {}
|
||||
for (const t of testFiles) for (const target of t.targets) { if (!testedByMap[target]) testedByMap[target] = []; testedByMap[target].push(t.path) }
|
||||
const sourceFiles: SrcFile[] = srcFiltered.map((sp) => {
|
||||
const content = fs.readFileSync(pathMod.join(root, sp), 'utf-8')
|
||||
const lines = content.split('\n').length
|
||||
const exports: string[] = []
|
||||
for (const m of content.matchAll(/export\s+(?:default\s+)?(?:function|const|let|var|class|type|interface|enum)\s+(\w+)/g)) exports.push(m[1])
|
||||
const tb = testedByMap[sp] || []
|
||||
const coverage = tb.length === 0 ? 'uncovered' : tb.some((t) => t.includes('/unit/')) ? 'covered' : 'partial'
|
||||
return { path: sp, lines, exports, testedBy: tb, coverage }
|
||||
})
|
||||
const covered = sourceFiles.filter((f) => f.coverage === 'covered').length
|
||||
const partial = sourceFiles.filter((f) => f.coverage === 'partial').length
|
||||
const uncovered = sourceFiles.filter((f) => f.coverage === 'uncovered').length
|
||||
return { sourceFiles, testFiles, summary: { totalSource: sourceFiles.length, totalTests: testFiles.length, covered, partial, uncovered, coveragePercent: Math.round(((covered + partial * 0.5) / sourceFiles.length) * 100) } }
|
||||
})
|
||||
|
||||
.get('/api/admin/dependencies', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const fs = await import('node:fs')
|
||||
const pathMod = await import('node:path')
|
||||
const root = process.cwd()
|
||||
const pkgPath = pathMod.join(root, 'package.json')
|
||||
if (!fs.existsSync(pkgPath)) { set.status = 404; return { error: 'package.json not found' } }
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
|
||||
const deps: Record<string, string> = pkg.dependencies || {}
|
||||
const devDeps: Record<string, string> = pkg.devDependencies || {}
|
||||
const catMap: Record<string, string> = {
|
||||
elysia: 'server', '@elysiajs/cors': 'server', '@elysiajs/html': 'server', '@elysiajs/swagger': 'server',
|
||||
react: 'ui', 'react-dom': 'ui', '@mantine/core': 'ui', '@mantine/hooks': 'ui', '@mantine/charts': 'ui',
|
||||
'@mantine/notifications': 'ui', '@mantine/modals': 'ui', '@tanstack/react-router': 'ui',
|
||||
'@tanstack/react-query': 'ui', '@xyflow/react': 'ui', 'react-icons': 'ui', recharts: 'ui', swr: 'ui',
|
||||
'@prisma/client': 'database', prisma: 'database', minio: 'storage',
|
||||
vite: 'build', typescript: 'build', '@biomejs/biome': 'build', '@vitejs/plugin-react': 'build', elkjs: 'build',
|
||||
}
|
||||
const srcFiles: string[] = []
|
||||
function scanSrc(dir: string) {
|
||||
const abs = pathMod.join(root, dir)
|
||||
if (!fs.existsSync(abs)) return
|
||||
for (const e of fs.readdirSync(abs, { withFileTypes: true })) {
|
||||
if (['node_modules', 'dist', 'generated', '.git'].includes(e.name)) continue
|
||||
const rel = pathMod.join(dir, e.name).replace(/\\/g, '/')
|
||||
if (e.isDirectory()) scanSrc(rel)
|
||||
else if (/\.(ts|tsx)$/.test(e.name)) srcFiles.push(rel)
|
||||
}
|
||||
}
|
||||
scanSrc('src')
|
||||
const fileContents: Record<string, string> = {}
|
||||
for (const f of srcFiles) fileContents[f] = fs.readFileSync(pathMod.join(root, f), 'utf-8')
|
||||
const allPkgs: { name: string; version: string; type: string; category: string; usedBy: string[] }[] = []
|
||||
for (const [name, version] of Object.entries(deps)) {
|
||||
const usedBy: string[] = []
|
||||
const importPattern = new RegExp(`from\\s+['"]${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
|
||||
for (const [file, content] of Object.entries(fileContents)) if (importPattern.test(content)) usedBy.push(file)
|
||||
allPkgs.push({ name, version, type: 'runtime', category: catMap[name] || 'other', usedBy })
|
||||
}
|
||||
for (const [name, version] of Object.entries(devDeps)) allPkgs.push({ name, version, type: 'dev', category: catMap[name] || 'build', usedBy: [] })
|
||||
const byCategory: Record<string, number> = {}
|
||||
let runtime = 0, dev = 0
|
||||
for (const p of allPkgs) { byCategory[p.category] = (byCategory[p.category] || 0) + 1; if (p.type === 'runtime') runtime++; else dev++ }
|
||||
return { packages: allPkgs, summary: { total: allPkgs.length, runtime, dev, byCategory } }
|
||||
})
|
||||
|
||||
.get('/api/admin/migrations', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const fs = await import('node:fs')
|
||||
const pathMod = await import('node:path')
|
||||
const root = process.cwd()
|
||||
const migrationsDir = pathMod.join(root, 'prisma/migrations')
|
||||
if (!fs.existsSync(migrationsDir)) return { migrations: [], summary: { totalMigrations: 0, firstMigration: null, lastMigration: null, totalChanges: 0 } }
|
||||
const entries = fs.readdirSync(migrationsDir, { withFileTypes: true }).filter((e) => e.isDirectory() && /^\d{14}_/.test(e.name)).sort((a, b) => a.name.localeCompare(b.name))
|
||||
const migrations = entries.map((entry) => {
|
||||
const sqlPath = pathMod.join(migrationsDir, entry.name, 'migration.sql')
|
||||
let sql = ''
|
||||
const changes: string[] = []
|
||||
if (fs.existsSync(sqlPath)) {
|
||||
sql = fs.readFileSync(sqlPath, 'utf-8')
|
||||
for (const m of sql.matchAll(/^(CREATE TABLE|ALTER TABLE|CREATE INDEX|CREATE UNIQUE INDEX|DROP TABLE|DROP INDEX|CREATE TYPE|ALTER TYPE)\s+["']?(\w+)["']?/gim)) changes.push(`${m[1]} ${m[2]}`)
|
||||
for (const m of sql.matchAll(/CREATE TYPE\s+"(\w+)"/g)) if (!changes.some((c) => c.includes(m[1]))) changes.push(`CREATE TYPE ${m[1]}`)
|
||||
}
|
||||
const dateStr = entry.name.substring(0, 14)
|
||||
const createdAt = new Date(`${dateStr.slice(0, 4)}-${dateStr.slice(4, 6)}-${dateStr.slice(6, 8)}T${dateStr.slice(8, 10)}:${dateStr.slice(10, 12)}:${dateStr.slice(12, 14)}.000Z`).toISOString()
|
||||
const name = entry.name.substring(15)
|
||||
return { name, folder: entry.name, createdAt, changes, sql: sql.substring(0, 800) }
|
||||
})
|
||||
const totalChanges = migrations.reduce((s, m) => s + m.changes.length, 0)
|
||||
return { migrations, summary: { totalMigrations: migrations.length, firstMigration: migrations[0]?.createdAt || null, lastMigration: migrations[migrations.length - 1]?.createdAt || null, totalChanges } }
|
||||
})
|
||||
|
||||
.get('/api/admin/sessions', async ({ request, set }) => {
|
||||
const auth = await requireDeveloper(request, set)
|
||||
if (!auth) return { error: set.status === 401 ? 'Unauthorized' : 'Forbidden' }
|
||||
const onlineIds = new Set(getOnlineUserIds())
|
||||
const sessions = await prisma.session.findMany({
|
||||
include: { user: { select: { id: true, name: true, email: true, role: true, active: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const now = new Date()
|
||||
const result = sessions.map((s) => ({
|
||||
id: s.id, userId: s.user.id, userName: s.user.name, userEmail: s.user.email,
|
||||
userRole: s.user.role, userActive: s.user.active,
|
||||
isOnline: onlineIds.has(s.user.id),
|
||||
createdAt: s.createdAt.toISOString(), expiresAt: s.expiresAt.toISOString(), isExpired: s.expiresAt < now,
|
||||
}))
|
||||
const byRole: Record<string, number> = {}
|
||||
const uniqueUsers = new Set<string>()
|
||||
let active = 0, expired = 0
|
||||
for (const s of result) {
|
||||
uniqueUsers.add(s.userId)
|
||||
byRole[s.userRole] = (byRole[s.userRole] || 0) + 1
|
||||
if (s.isExpired) expired++; else active++
|
||||
}
|
||||
return { sessions: result, summary: { totalSessions: result.length, activeSessions: active, expiredSessions: expired, onlineUsers: onlineIds.size, byRole } }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ColorSchemeScript, MantineProvider, createTheme } from '@mantine/core'
|
||||
import '@mantine/core/styles.css'
|
||||
import '@mantine/notifications/styles.css'
|
||||
import { ModalsProvider } from '@mantine/modals'
|
||||
import { Notifications } from '@mantine/notifications'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { createRouter, RouterProvider } from '@tanstack/react-router'
|
||||
@@ -64,9 +65,11 @@ export function App() {
|
||||
<ColorSchemeScript defaultColorScheme="auto" />
|
||||
<MantineProvider theme={theme} defaultColorScheme="auto">
|
||||
<Notifications />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
<ModalsProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ModalsProvider>
|
||||
</MantineProvider>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -3,6 +3,12 @@ import { useNavigate } from '@tanstack/react-router'
|
||||
|
||||
export type Role = 'USER' | 'ADMIN' | 'DEVELOPER'
|
||||
|
||||
export function getDefaultRoute(role: Role): string {
|
||||
if (role === 'DEVELOPER') return '/dev'
|
||||
if (role === 'ADMIN') return '/dashboard'
|
||||
return '/profile'
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
@@ -42,7 +48,7 @@ export function useLogin() {
|
||||
}),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(['auth', 'session'], data)
|
||||
navigate({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' })
|
||||
navigate({ to: getDefaultRoute(data.user.role) })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
42
src/frontend/hooks/usePresence.ts
Normal file
42
src/frontend/hooks/usePresence.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSession } from './useAuth'
|
||||
|
||||
export function usePresence() {
|
||||
const { data } = useSession()
|
||||
const [onlineUserIds, setOnlineUserIds] = useState<string[]>([])
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
if (!data?.user) return
|
||||
|
||||
function connect() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${location.host}/ws/presence`)
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data)
|
||||
if (msg.type === 'presence') setOnlineUserIds(msg.online)
|
||||
}
|
||||
ws.onclose = () => {
|
||||
wsRef.current = null
|
||||
reconnectTimer.current = setTimeout(connect, 3000)
|
||||
}
|
||||
ws.onerror = () => ws.close()
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
clearTimeout(reconnectTimer.current)
|
||||
if (wsRef.current) {
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}
|
||||
}, [data?.user?.id, data?.user])
|
||||
|
||||
return { onlineUserIds }
|
||||
}
|
||||
1481
src/frontend/routes/dev.tsx
Normal file
1481
src/frontend/routes/dev.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,8 @@ export const Route = createFileRoute('/login')({
|
||||
queryFn: () => fetch('/api/auth/session', { credentials: 'include' }).then((r) => r.json()),
|
||||
})
|
||||
if (data?.user) {
|
||||
throw redirect({ to: data.user.role === 'USER' ? '/profile' : '/dashboard' })
|
||||
const dest = data.user.role === 'DEVELOPER' ? '/dev' : data.user.role === 'USER' ? '/profile' : '/dashboard'
|
||||
throw redirect({ to: dest })
|
||||
}
|
||||
} catch (e) {
|
||||
if (e instanceof Error) return
|
||||
|
||||
45
src/lib/applog.ts
Normal file
45
src/lib/applog.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { redis } from './redis'
|
||||
|
||||
export type LogLevel = 'info' | 'warn' | 'error'
|
||||
|
||||
export interface AppLogEntry {
|
||||
id: number
|
||||
level: LogLevel
|
||||
message: string
|
||||
detail?: string
|
||||
timestamp: string
|
||||
}
|
||||
|
||||
const REDIS_KEY = 'app:logs'
|
||||
const MAX_ENTRIES = 500
|
||||
const ID_KEY = 'app:logs:next_id'
|
||||
|
||||
export async function appLog(level: LogLevel, message: string, detail?: string) {
|
||||
if (!redis) return
|
||||
const id = await redis.incr(ID_KEY)
|
||||
const entry: AppLogEntry = { id, level, message, detail, timestamp: new Date().toISOString() }
|
||||
await redis.lpush(REDIS_KEY, JSON.stringify(entry))
|
||||
await redis.ltrim(REDIS_KEY, 0, MAX_ENTRIES - 1)
|
||||
}
|
||||
|
||||
export async function getAppLogs(options?: {
|
||||
level?: LogLevel
|
||||
limit?: number
|
||||
afterId?: number
|
||||
}): Promise<AppLogEntry[]> {
|
||||
if (!redis) return []
|
||||
const limit = options?.limit ?? 100
|
||||
const fetchCount = options?.level || options?.afterId ? MAX_ENTRIES : limit
|
||||
const raw = await redis.lrange(REDIS_KEY, 0, fetchCount - 1)
|
||||
let logs: AppLogEntry[] = raw.map((s: string) => JSON.parse(s))
|
||||
if (options?.afterId) logs = logs.filter((l) => l.id > options.afterId!)
|
||||
if (options?.level) logs = logs.filter((l) => l.level === options.level)
|
||||
logs.reverse()
|
||||
return logs.slice(-limit)
|
||||
}
|
||||
|
||||
export async function clearAppLogs() {
|
||||
if (!redis) return
|
||||
await redis.del(REDIS_KEY)
|
||||
await redis.del(ID_KEY)
|
||||
}
|
||||
@@ -25,4 +25,5 @@ export const env = {
|
||||
MINIO_SECRET_KEY: required('MINIO_SECRET_KEY'),
|
||||
MINIO_BUCKET: required('MINIO_BUCKET'),
|
||||
MINIO_UPLOAD_DIR: optional('MINIO_UPLOAD_DIR', 'bug-reports'),
|
||||
REDIS_URL: optional('REDIS_URL', ''),
|
||||
} as const
|
||||
|
||||
44
src/lib/presence.ts
Normal file
44
src/lib/presence.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { ServerWebSocket } from 'bun'
|
||||
|
||||
const connections = new Map<string, Set<ServerWebSocket<{ userId: string }>>>()
|
||||
const adminSubs = new Set<ServerWebSocket<{ userId: string }>>()
|
||||
|
||||
export function getOnlineUserIds(): string[] {
|
||||
return Array.from(connections.keys())
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
const online = getOnlineUserIds()
|
||||
const msg = JSON.stringify({ type: 'presence', online })
|
||||
for (const ws of adminSubs) ws.send(msg)
|
||||
}
|
||||
|
||||
export function addConnection(ws: ServerWebSocket<{ userId: string }>, userId: string, isAdmin: boolean) {
|
||||
let set = connections.get(userId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
connections.set(userId, set)
|
||||
}
|
||||
set.add(ws)
|
||||
if (isAdmin) {
|
||||
adminSubs.add(ws)
|
||||
ws.send(JSON.stringify({ type: 'presence', online: getOnlineUserIds() }))
|
||||
}
|
||||
broadcast()
|
||||
}
|
||||
|
||||
export function broadcastToAdmins(message: object) {
|
||||
const msg = JSON.stringify(message)
|
||||
for (const ws of adminSubs) ws.send(msg)
|
||||
}
|
||||
|
||||
export function removeConnection(ws: ServerWebSocket<{ userId: string }>) {
|
||||
const userId = ws.data.userId
|
||||
const set = connections.get(userId)
|
||||
if (set) {
|
||||
set.delete(ws)
|
||||
if (set.size === 0) connections.delete(userId)
|
||||
}
|
||||
adminSubs.delete(ws)
|
||||
broadcast()
|
||||
}
|
||||
3
src/lib/redis.ts
Normal file
3
src/lib/redis.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { env } from './env'
|
||||
|
||||
export const redis = env.REDIS_URL ? new Bun.RedisClient(env.REDIS_URL) : null
|
||||
104
src/lib/schema-parser.ts
Normal file
104
src/lib/schema-parser.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
export interface SchemaField {
|
||||
name: string
|
||||
type: string
|
||||
isId: boolean
|
||||
isUnique: boolean
|
||||
isOptional: boolean
|
||||
isList: boolean
|
||||
isRelation: boolean
|
||||
default?: string
|
||||
}
|
||||
|
||||
export interface SchemaRelation {
|
||||
from: string
|
||||
fromField: string
|
||||
to: string
|
||||
toField: string
|
||||
onDelete?: string
|
||||
}
|
||||
|
||||
export interface SchemaModel {
|
||||
name: string
|
||||
tableName: string
|
||||
fields: SchemaField[]
|
||||
}
|
||||
|
||||
export interface SchemaEnum {
|
||||
name: string
|
||||
values: string[]
|
||||
}
|
||||
|
||||
export interface ParsedSchema {
|
||||
models: SchemaModel[]
|
||||
enums: SchemaEnum[]
|
||||
relations: SchemaRelation[]
|
||||
}
|
||||
|
||||
export function parseSchema(raw: string): ParsedSchema {
|
||||
const models: SchemaModel[] = []
|
||||
const enums: SchemaEnum[] = []
|
||||
const relations: SchemaRelation[] = []
|
||||
|
||||
const blocks = raw.match(/(model|enum)\s+(\w+)\s*\{([^}]*)}/gs) ?? []
|
||||
|
||||
for (const block of blocks) {
|
||||
const match = block.match(/(model|enum)\s+(\w+)\s*\{([^}]*)}/s)
|
||||
if (!match) continue
|
||||
const [, type, name, body] = match
|
||||
const lines = body
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l && !l.startsWith('//'))
|
||||
|
||||
if (type === 'enum') {
|
||||
enums.push({ name, values: lines })
|
||||
continue
|
||||
}
|
||||
|
||||
let tableName = name
|
||||
const fields: SchemaField[] = []
|
||||
|
||||
for (const line of lines) {
|
||||
const mapMatch = line.match(/@@map\("(\w+)"\)/)
|
||||
if (mapMatch) { tableName = mapMatch[1]; continue }
|
||||
if (line.startsWith('@@')) continue
|
||||
|
||||
const fieldMatch = line.match(/^(\w+)\s+(\w+)(\?)?(\[\])?\s*(.*)$/)
|
||||
if (!fieldMatch) continue
|
||||
const [, fName, fType, optional, list, attrs] = fieldMatch
|
||||
|
||||
const isId = attrs.includes('@id')
|
||||
const isUnique = attrs.includes('@unique')
|
||||
const isRelation = attrs.includes('@relation')
|
||||
const defaultMatch = attrs.match(/@default\(([^)]+)\)/)
|
||||
|
||||
const isModelRef =
|
||||
/^[A-Z]/.test(fType) &&
|
||||
!enums.some((e) => e.name === fType) &&
|
||||
!['String', 'Int', 'Float', 'Boolean', 'DateTime', 'BigInt', 'Decimal', 'Bytes', 'Json'].includes(fType)
|
||||
|
||||
if (isRelation) {
|
||||
const relMatch = attrs.match(
|
||||
/@relation\(fields:\s*\[(\w+)],\s*references:\s*\[(\w+)](?:,\s*onDelete:\s*(\w+))?\)/,
|
||||
)
|
||||
if (relMatch) {
|
||||
relations.push({ from: name, fromField: relMatch[1], to: fType, toField: relMatch[2], onDelete: relMatch[3] })
|
||||
}
|
||||
}
|
||||
|
||||
fields.push({
|
||||
name: fName,
|
||||
type: fType + (list ? '[]' : ''),
|
||||
isId, isUnique,
|
||||
isOptional: !!optional,
|
||||
isList: !!list,
|
||||
isRelation: isModelRef,
|
||||
default: defaultMatch?.[1],
|
||||
})
|
||||
}
|
||||
|
||||
models.push({ name, tableName, fields })
|
||||
}
|
||||
|
||||
return { models, enums, relations }
|
||||
}
|
||||
Reference in New Issue
Block a user