Compare commits
7 Commits
cd295abf2c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f6a68b9f1 | |||
| 63c0a6acff | |||
| a0ca6be8e1 | |||
| eb77aca715 | |||
| 041d891a8d | |||
| de5ec1af93 | |||
| d09a702d64 |
@@ -16,6 +16,9 @@ GOOGLE_CLIENT_SECRET=
|
||||
# Role
|
||||
SUPER_ADMIN_EMAIL=admin@example.com
|
||||
|
||||
# API Key for external clients (e.g. mobile apps)
|
||||
API_KEY=your-secret-api-key-here
|
||||
|
||||
# Telegram Notification (optional)
|
||||
TELEGRAM_NOTIFY_TOKEN=
|
||||
TELEGRAM_NOTIFY_CHAT_ID=
|
||||
|
||||
102
CLAUDE.md
102
CLAUDE.md
@@ -1,82 +1,52 @@
|
||||
Default to using Bun instead of Node.js.
|
||||
# CLAUDE.md
|
||||
|
||||
- Use `bun <file>` instead of `node <file>` or `ts-node <file>`
|
||||
- Use `bun test` instead of `jest` or `vitest`
|
||||
- Use `bun install` instead of `npm install` or `yarn install` or `pnpm install`
|
||||
- Use `bun run <script>` instead of `npm run <script>`
|
||||
- Use `bunx <package> <command>` instead of `npx <package> <command>`
|
||||
- Bun automatically loads .env, so don't use dotenv.
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Server
|
||||
## Runtime
|
||||
|
||||
Elysia.js as the HTTP framework, running on Bun. API routes are in `src/app.ts` (exported as `createApp()`), frontend serving and dev tools are in `src/index.tsx`.
|
||||
Default to Bun instead of Node.js everywhere:
|
||||
|
||||
- `src/app.ts` — Elysia app factory with all API routes (auth, hello, health, Google OAuth). Testable via `app.handle(request)`.
|
||||
- `src/index.tsx` — Server entry. Adds Vite middleware (dev) or static file serving (prod), click-to-source editor integration, and `.listen()`.
|
||||
- `src/serve.ts` — Dev entry (`bun --watch src/serve.ts`). Dynamic import workaround for Bun EADDRINUSE race.
|
||||
- `bun <file>` not `node` / `ts-node`
|
||||
- `bun test` not `jest` / `vitest`
|
||||
- `bun install` not `npm install` / `yarn` / `pnpm`
|
||||
- `bun run <script>` not `npm run`
|
||||
- `bunx <pkg>` not `npx`
|
||||
- Bun auto-loads `.env` — never use dotenv.
|
||||
|
||||
## Database
|
||||
## Common Commands
|
||||
|
||||
PostgreSQL via Prisma v6. Client generated to `./generated/prisma` (gitignored).
|
||||
```bash
|
||||
bun run dev # dev server with hot reload (bun --watch src/serve.ts)
|
||||
bun run build # Vite production build
|
||||
bun run start # production server (NODE_ENV=production)
|
||||
bun run typecheck # tsc --noEmit
|
||||
bun run lint # biome check src/
|
||||
bun run lint:fix # biome check --write src/
|
||||
|
||||
- Schema: `prisma/schema.prisma` — User (id, name, email, password, timestamps) + Session (id, token, userId, expiresAt)
|
||||
- Client singleton: `src/lib/db.ts` — import `{ prisma }` from here
|
||||
- Seed: `prisma/seed.ts` — demo users with `Bun.password.hash` bcrypt
|
||||
- Commands: `bun run db:migrate`, `bun run db:seed`, `bun run db:generate`
|
||||
# Database
|
||||
bun run db:migrate # prisma migrate dev
|
||||
bun run db:seed # seed demo data
|
||||
bun run db:generate # regenerate prisma client
|
||||
bun run db:studio # Prisma Studio GUI
|
||||
bun run db:push # push schema without migration
|
||||
|
||||
## Auth
|
||||
# Tests
|
||||
bun run test # all tests
|
||||
bun run test:unit # tests/unit/
|
||||
bun run test:integration # tests/integration/ — no server needed
|
||||
bun run test:e2e # tests/e2e/ — requires Lightpanda Docker
|
||||
```
|
||||
|
||||
Session-based auth with HttpOnly cookies stored in DB.
|
||||
Run a single test file: `bun test tests/integration/auth.test.ts`
|
||||
|
||||
- Login: `POST /api/auth/login` — finds user by email, verifies password with `Bun.password.verify`, creates Session record
|
||||
- Google OAuth: `GET /api/auth/google` → Google → `GET /api/auth/callback/google` — upserts user, creates session
|
||||
- Session: `GET /api/auth/session` — looks up session by cookie token, returns user or 401, auto-deletes expired
|
||||
- Logout: `POST /api/auth/logout` — deletes session from DB, clears cookie
|
||||
## Architecture
|
||||
|
||||
## Frontend
|
||||
|
||||
React 19 + Vite 8 (middleware mode in dev). File-based routing with TanStack Router.
|
||||
|
||||
- Entry: `src/frontend.tsx` — renders App, removes splash screen, DevInspector in dev
|
||||
- App: `src/frontend/App.tsx` — MantineProvider (dark, forced), QueryClientProvider, RouterProvider
|
||||
- Routes: `src/frontend/routes/` — `__root.tsx`, `index.tsx`, `login.tsx`, `dashboard.tsx`
|
||||
- Auth hooks: `src/frontend/hooks/useAuth.ts` — `useSession()`, `useLogin()`, `useLogout()`
|
||||
- UI: Mantine v8 (dark theme `#242424`), react-icons
|
||||
- Splash: `index.html` has inline dark CSS + spinner, removed on React mount
|
||||
|
||||
## Dev Tools
|
||||
|
||||
- Click-to-source: `Ctrl+Shift+Cmd+C` toggles inspector. Custom Vite plugin (`inspectorPlugin` in `src/vite.ts`) injects `data-inspector-*` attributes. Reads original file from disk for accurate line numbers.
|
||||
- HMR: Vite 8 with `@vitejs/plugin-react` v6. `dedupeRefreshPlugin` fixes double React Refresh injection.
|
||||
- Editor: `REACT_EDITOR` env var. `zed` and `subl` use `file:line:col`, others use `--goto file:line:col`.
|
||||
|
||||
## Playwright MCP
|
||||
|
||||
Playwright MCP server enables AI-assisted browser automation for testing and debugging.
|
||||
|
||||
- MCP config: `.qwen/settings.json` — Qwen Code auto-loads on session start
|
||||
- Playwright config: `playwright.config.ts` — E2E test configuration
|
||||
- Run manually: `bun run mcp:playwright` — starts headless browser MCP server
|
||||
- Install browsers: `bunx playwright install` — downloads Chromium and other browsers
|
||||
See @docs/ARCHITECTURE.md
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use `bun:test`. Three levels:
|
||||
See @docs/TESTING.md
|
||||
|
||||
```bash
|
||||
bun run test # All tests
|
||||
bun run test:unit # tests/unit/ — env, db connection, bcrypt
|
||||
bun run test:integration # tests/integration/ — API endpoints via app.handle()
|
||||
bun run test:e2e # tests/e2e/ — browser tests via Lightpanda CDP
|
||||
```
|
||||
## Dev Tools
|
||||
|
||||
- `tests/helpers.ts` — `createTestApp()`, `seedTestUser()`, `createTestSession()`, `cleanupTestData()`
|
||||
- Integration tests use `createApp().handle(new Request(...))` — no server needed
|
||||
- E2E tests use Lightpanda browser (Docker, `ws://127.0.0.1:9222`). App URLs use `host.docker.internal` from container. Lightpanda executes JS but POST fetch returns 407 — use integration tests for mutations.
|
||||
|
||||
## APIs
|
||||
|
||||
- `Bun.password.hash()` / `Bun.password.verify()` for bcrypt
|
||||
- `Bun.file()` for static file serving in production
|
||||
- `Bun.which()` / `Bun.spawn()` for editor integration
|
||||
- `crypto.randomUUID()` for session tokens
|
||||
See @docs/DEV_TOOLS.md
|
||||
|
||||
135
bun.lock
135
bun.lock
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-react-template",
|
||||
@@ -8,13 +7,16 @@
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/eden": "^1.4.9",
|
||||
"@elysiajs/html": "^1.4.0",
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@mantine/charts": "^9.0.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
"@mantine/notifications": "^8.3.18",
|
||||
"@prisma/client": "6",
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"elysia": "^1.4.28",
|
||||
"minio": "^8.0.7",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
@@ -26,11 +28,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.10",
|
||||
"@playwright/mcp": "^0.0.70",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@tanstack/router-vite-plugin": "^1.166.27",
|
||||
"@types/bun": "latest",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"playwright": "^1.59.1",
|
||||
"prisma": "6",
|
||||
"puppeteer-core": "^24.40.0",
|
||||
"typescript": "^6.0.2",
|
||||
@@ -105,6 +110,8 @@
|
||||
|
||||
"@elysiajs/html": ["@elysiajs/html@1.4.0", "", { "dependencies": { "@kitajs/html": "^4.1.0", "@kitajs/ts-html-plugin": "^4.0.1" }, "peerDependencies": { "elysia": ">= 1.4.0" } }, "sha512-j4jFqGEkIC8Rg2XiTOujb9s0WLnz1dnY/4uqczyCdOVruDeJtGP+6+GvF0A76SxEvltn8UR1yCUnRdLqRi3vuw=="],
|
||||
|
||||
"@elysiajs/swagger": ["@elysiajs/swagger@1.3.1", "", { "dependencies": { "@scalar/themes": "^0.9.52", "@scalar/types": "^0.0.12", "openapi-types": "^12.1.3", "pathe": "^1.1.2" }, "peerDependencies": { "elysia": ">= 1.3.0" } }, "sha512-LcbLHa0zE6FJKWPWKsIC/f+62wbDv3aXydqcNPVPyqNcaUgwvCajIi+5kHEU6GO3oXUCpzKaMsb3gsjt8sLzFQ=="],
|
||||
|
||||
"@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" } }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="],
|
||||
|
||||
"@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="],
|
||||
@@ -193,10 +200,20 @@
|
||||
|
||||
"@mantine/hooks": ["@mantine/hooks@8.3.18", "", { "peerDependencies": { "react": "^18.x || ^19.x" } }, "sha512-QoWr9+S8gg5050TQ06aTSxtlpGjYOpIllRbjYYXlRvZeTsUqiTbVfvQROLexu4rEaK+yy9Wwriwl9PMRgbLqPw=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-sNXv5oLJ7ob93xkZ1XnxisYhGYXfaG9f65/ZgYuAu3qt7b3NadcOEhLvx28hv31PgX8SZJRYrAIPQilQmFpLVw=="],
|
||||
|
||||
"@nodable/entities": ["@nodable/entities@2.1.0", "", {}, "sha512-nyT7T3nbMyBI/lvr6L5TyWbFJAI9FTgVRakNoBqCD+PmID8DzFrrNdLLtHMwMszOtqZa8PAOV24ZqDnQrhQINA=="],
|
||||
|
||||
"@oxc-project/types": ["@oxc-project/types@0.122.0", "", {}, "sha512-oLAl5kBpV4w69UtFZ9xqcmTi+GENWOcPF7FCrczTiBbmC0ibXxCwyvZGbO39rCVEuLGAZM84DH0pUIyyv/YJzA=="],
|
||||
|
||||
"@playwright/mcp": ["@playwright/mcp@0.0.70", "", { "dependencies": { "playwright": "1.60.0-alpha-1774999321000", "playwright-core": "1.60.0-alpha-1774999321000" }, "bin": { "playwright-mcp": "cli.js" } }, "sha512-Kl0a6l9VL8rvT1oBou3hS5yArjwWV9UlwAkq+0skfK1YVg8XfmmNaAmwZhMeNx/ZhGiWXfCllo6rD/jvZz+WuA=="],
|
||||
|
||||
"@playwright/test": ["@playwright/test@1.59.1", "", { "dependencies": { "playwright": "1.59.1" }, "bin": { "playwright": "cli.js" } }, "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg=="],
|
||||
|
||||
"@prisma/client": ["@prisma/client@6.19.2", "", { "peerDependencies": { "prisma": "*", "typescript": ">=5.1.0" }, "optionalPeers": ["prisma", "typescript"] }, "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg=="],
|
||||
|
||||
"@prisma/config": ["@prisma/config@6.19.2", "", { "dependencies": { "c12": "3.1.0", "deepmerge-ts": "7.1.5", "effect": "3.18.4", "empathic": "2.0.0" } }, "sha512-kadBGDl+aUswv/zZMk9Mx0C8UZs1kjao8H9/JpI4Wh4SHZaM7zkTwiKn/iFLfRg+XtOAo/Z/c6pAYhijKl0nzQ=="],
|
||||
@@ -247,6 +264,12 @@
|
||||
|
||||
"@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.7", "", {}, "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA=="],
|
||||
|
||||
"@scalar/openapi-types": ["@scalar/openapi-types@0.1.1", "", {}, "sha512-NMy3QNk6ytcCoPUGJH0t4NNr36OWXgZhA3ormr3TvhX1NDgoF95wFyodGVH8xiHeUyn2/FxtETm8UBLbB5xEmg=="],
|
||||
|
||||
"@scalar/themes": ["@scalar/themes@0.9.86", "", { "dependencies": { "@scalar/types": "0.1.7" } }, "sha512-QUHo9g5oSWi+0Lm1vJY9TaMZRau8LHg+vte7q5BVTBnu6NuQfigCaN+ouQ73FqIVd96TwMO6Db+dilK1B+9row=="],
|
||||
|
||||
"@scalar/types": ["@scalar/types@0.0.12", "", { "dependencies": { "@scalar/openapi-types": "0.1.1", "@unhead/schema": "^1.9.5" } }, "sha512-XYZ36lSEx87i4gDqopQlGCOkdIITHHEvgkuJFrXFATQs9zHARop0PN0g4RZYWj+ZpCUclOcaOjbCt8JGe22mnQ=="],
|
||||
|
||||
"@sinclair/typebox": ["@sinclair/typebox@0.34.49", "", {}, "sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A=="],
|
||||
|
||||
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
|
||||
@@ -315,6 +338,8 @@
|
||||
|
||||
"@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="],
|
||||
|
||||
"@unhead/schema": ["@unhead/schema@1.11.20", "", { "dependencies": { "hookable": "^5.5.3", "zhead": "^2.2.4" } }, "sha512-0zWykKAaJdm+/Y7yi/Yds20PrUK7XabLe9c3IRcjnwYmSWY6z0Cr19VIs3ozCj8P+GhR+/TI2mwtGlueCEYouA=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
@@ -331,6 +356,8 @@
|
||||
|
||||
"ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="],
|
||||
|
||||
"async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="],
|
||||
|
||||
"b4a": ["b4a@1.8.0", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg=="],
|
||||
|
||||
"babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.12", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig=="],
|
||||
@@ -353,11 +380,15 @@
|
||||
|
||||
"binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="],
|
||||
|
||||
"block-stream2": ["block-stream2@2.1.0", "", { "dependencies": { "readable-stream": "^3.4.0" } }, "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg=="],
|
||||
|
||||
"braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="],
|
||||
|
||||
"browser-or-node": ["browser-or-node@2.1.1", "", {}, "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg=="],
|
||||
|
||||
"browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
"buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
|
||||
@@ -425,6 +456,8 @@
|
||||
|
||||
"decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="],
|
||||
|
||||
"decode-uri-component": ["decode-uri-component@0.2.2", "", {}, "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ=="],
|
||||
|
||||
"deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="],
|
||||
|
||||
"defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="],
|
||||
@@ -443,6 +476,8 @@
|
||||
|
||||
"diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
|
||||
|
||||
"dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="],
|
||||
|
||||
"dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
|
||||
|
||||
"effect": ["effect@3.18.4", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-b1LXQJLe9D11wfnOKAk3PKxuqYshQ0Heez+y5pnkd3jLj1yx9QhM72zZ9uUrOQyNvrs2GZZd/3maL0ZV18YuDA=="],
|
||||
@@ -487,6 +522,10 @@
|
||||
|
||||
"fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="],
|
||||
|
||||
"fast-xml-builder": ["fast-xml-builder@1.1.5", "", { "dependencies": { "path-expression-matcher": "^1.1.3" } }, "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA=="],
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.7.1", "", { "dependencies": { "@nodable/entities": "^2.1.0", "fast-xml-builder": "^1.1.5", "path-expression-matcher": "^1.5.0", "strnum": "^2.2.3" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-8Cc3f8GUGUULg34pBch/KGyPLglS+OFs05deyOlY7fL2MTagYPKrVQNmR1fLF/yJ9PH5ZSTd3YDF6pnmeZU+zA=="],
|
||||
|
||||
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
|
||||
|
||||
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||
@@ -495,7 +534,9 @@
|
||||
|
||||
"fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
"filter-obj": ["filter-obj@1.1.0", "", {}, "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ=="],
|
||||
|
||||
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
|
||||
|
||||
"gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
|
||||
|
||||
@@ -515,6 +556,8 @@
|
||||
|
||||
"glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
|
||||
|
||||
"hookable": ["hookable@5.5.3", "", {}, "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ=="],
|
||||
|
||||
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||
|
||||
"https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
|
||||
@@ -523,10 +566,14 @@
|
||||
|
||||
"immer": ["immer@10.2.0", "", {}, "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw=="],
|
||||
|
||||
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
|
||||
|
||||
"internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="],
|
||||
|
||||
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
|
||||
|
||||
"ipaddr.js": ["ipaddr.js@2.3.0", "", {}, "sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg=="],
|
||||
|
||||
"is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="],
|
||||
|
||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||
@@ -571,10 +618,20 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"lodash": ["lodash@4.18.1", "", {}, "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q=="],
|
||||
|
||||
"loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="],
|
||||
|
||||
"lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="],
|
||||
|
||||
"memoirist": ["memoirist@0.4.0", "", {}, "sha512-zxTgA0mSYELa66DimuNQDvyLq36AwDlTuVRbnQtB+VuTcKWm5Qc4z3WkSpgsFWHNhexqkIooqpv4hdcqrX5Nmg=="],
|
||||
|
||||
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||
|
||||
"minio": ["minio@8.0.7", "", { "dependencies": { "async": "^3.2.4", "block-stream2": "^2.1.0", "browser-or-node": "^2.1.1", "buffer-crc32": "^1.0.0", "eventemitter3": "^5.0.1", "fast-xml-parser": "^5.3.4", "ipaddr.js": "^2.0.1", "lodash": "^4.17.21", "mime-types": "^2.1.35", "query-string": "^7.1.3", "stream-json": "^1.8.0", "through2": "^4.0.2", "xml2js": "^0.5.0 || ^0.6.2" } }, "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ=="],
|
||||
|
||||
"mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="],
|
||||
|
||||
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||
@@ -591,6 +648,8 @@
|
||||
|
||||
"nypm": ["nypm@0.6.5", "", { "dependencies": { "citty": "^0.2.0", "pathe": "^2.0.3", "tinyexec": "^1.0.2" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ=="],
|
||||
|
||||
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
|
||||
|
||||
"ohash": ["ohash@2.0.11", "", {}, "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ=="],
|
||||
|
||||
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
|
||||
@@ -601,7 +660,9 @@
|
||||
|
||||
"pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="],
|
||||
|
||||
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
"path-expression-matcher": ["path-expression-matcher@1.5.0", "", {}, "sha512-cbrerZV+6rvdQrrD+iGMcZFEiiSrbv9Tfdkvnusy6y0x0GKBXREFg/Y65GhIfm0tnLntThhzCnfKwp1WRjeCyQ=="],
|
||||
|
||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
@@ -613,6 +674,10 @@
|
||||
|
||||
"pkg-types": ["pkg-types@2.3.0", "", { "dependencies": { "confbox": "^0.2.2", "exsolve": "^1.0.7", "pathe": "^2.0.3" } }, "sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig=="],
|
||||
|
||||
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
|
||||
|
||||
"playwright-core": ["playwright-core@1.60.0-alpha-1774999321000", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-ams3Zo4VXxeOg5ZTTh16GkE8g48Bmxo/9pg9gXl9SVKlVohCU7Jaog7XntY8yFuzENA6dJc1Fz7Z/NNTm9nGEw=="],
|
||||
|
||||
"postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="],
|
||||
|
||||
"postcss-js": ["postcss-js@4.1.0", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw=="],
|
||||
@@ -633,6 +698,8 @@
|
||||
|
||||
"progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="],
|
||||
|
||||
"prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="],
|
||||
|
||||
"proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
@@ -643,6 +710,8 @@
|
||||
|
||||
"pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="],
|
||||
|
||||
"query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="],
|
||||
|
||||
"rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="],
|
||||
|
||||
"react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="],
|
||||
@@ -665,6 +734,10 @@
|
||||
|
||||
"react-textarea-autosize": ["react-textarea-autosize@8.5.9", "", { "dependencies": { "@babel/runtime": "^7.20.13", "use-composed-ref": "^1.3.0", "use-latest": "^1.2.1" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-U1DGlIQN5AwgjTyOEnI1oCcMuEr1pv1qOtklB2l4nyMGbHzWrI0eFsYK0zos2YWqAolJyG0IWJaqWmWj5ETh0A=="],
|
||||
|
||||
"react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="],
|
||||
|
||||
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
|
||||
|
||||
"readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
|
||||
|
||||
"recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="],
|
||||
@@ -683,6 +756,10 @@
|
||||
|
||||
"rolldown": ["rolldown@1.0.0-rc.12", "", { "dependencies": { "@oxc-project/types": "=0.122.0", "@rolldown/pluginutils": "1.0.0-rc.12" }, "optionalDependencies": { "@rolldown/binding-android-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-arm64": "1.0.0-rc.12", "@rolldown/binding-darwin-x64": "1.0.0-rc.12", "@rolldown/binding-freebsd-x64": "1.0.0-rc.12", "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.12", "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.12", "@rolldown/binding-linux-x64-musl": "1.0.0-rc.12", "@rolldown/binding-openharmony-arm64": "1.0.0-rc.12", "@rolldown/binding-wasm32-wasi": "1.0.0-rc.12", "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.12", "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.12" }, "bin": { "rolldown": "bin/cli.mjs" } }, "sha512-yP4USLIMYrwpPHEFB5JGH1uxhcslv6/hL0OyvTuY+3qlOSJvZ7ntYnoWpehBxufkgN0cvXxppuTu5hHa/zPh+A=="],
|
||||
|
||||
"safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
|
||||
|
||||
"sax": ["sax@1.6.0", "", {}, "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="],
|
||||
@@ -701,12 +778,24 @@
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
|
||||
"split-on-first": ["split-on-first@1.1.0", "", {}, "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw=="],
|
||||
|
||||
"stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="],
|
||||
|
||||
"stream-json": ["stream-json@1.9.1", "", { "dependencies": { "stream-chain": "^2.2.5" } }, "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw=="],
|
||||
|
||||
"streamx": ["streamx@2.25.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg=="],
|
||||
|
||||
"strict-uri-encode": ["strict-uri-encode@2.0.0", "", {}, "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ=="],
|
||||
|
||||
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
|
||||
|
||||
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
|
||||
|
||||
"strnum": ["strnum@2.2.3", "", {}, "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg=="],
|
||||
|
||||
"strtok3": ["strtok3@10.3.5", "", { "dependencies": { "@tokenizer/token": "^0.3.0" } }, "sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA=="],
|
||||
|
||||
"sugarss": ["sugarss@5.0.1", "", { "peerDependencies": { "postcss": "^8.3.3" } }, "sha512-ctS5RYCBVvPoZAnzIaX5QSShK8ZiZxD5HUqSxlusvEMC+QZQIPCPOIJg6aceFX+K2rf4+SH89eu++h1Zmsr2nw=="],
|
||||
@@ -723,6 +812,8 @@
|
||||
|
||||
"text-decoder": ["text-decoder@1.2.7", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ=="],
|
||||
|
||||
"through2": ["through2@4.0.2", "", { "dependencies": { "readable-stream": "3" } }, "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw=="],
|
||||
|
||||
"tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.4", "", {}, "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw=="],
|
||||
@@ -779,6 +870,10 @@
|
||||
|
||||
"ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="],
|
||||
|
||||
"xml2js": ["xml2js@0.6.2", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA=="],
|
||||
|
||||
"xmlbuilder": ["xmlbuilder@11.0.1", "", {}, "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="],
|
||||
|
||||
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
|
||||
|
||||
"yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
|
||||
@@ -789,6 +884,8 @@
|
||||
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
"zhead": ["zhead@2.2.4", "", {}, "sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
|
||||
"@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
|
||||
@@ -799,30 +896,60 @@
|
||||
|
||||
"@kitajs/ts-html-plugin/yargs": ["yargs@18.0.0", "", { "dependencies": { "cliui": "^9.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "string-width": "^7.2.0", "y18n": "^5.0.5", "yargs-parser": "^22.0.0" } }, "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg=="],
|
||||
|
||||
"@playwright/mcp/playwright": ["playwright@1.60.0-alpha-1774999321000", "", { "dependencies": { "playwright-core": "1.60.0-alpha-1774999321000" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-Bd5DkzYKG+2g1jLO6NeTXmGLbBYSFffJIOsR4l4hUBkJvzvGGdLZ7jZb2tOtb0WIoWXQKdQj3Ap6WthV4DBS8w=="],
|
||||
|
||||
"@reduxjs/toolkit/immer": ["immer@11.1.4", "", {}, "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw=="],
|
||||
|
||||
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
|
||||
|
||||
"@tanstack/router-utils/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],
|
||||
|
||||
"c12/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"chokidar/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"degenerator/ast-types": ["ast-types@0.13.4", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w=="],
|
||||
|
||||
"escodegen/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"nypm/citty": ["citty@0.2.1", "", {}, "sha512-kEV95lFBhQgtogAPlQfJJ0WGVSokvLr/UEoFPiKKOXF7pl98HfUVUD0ejsuTCld/9xH9vogSywZ5KqHzXrZpqg=="],
|
||||
|
||||
"nypm/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
|
||||
|
||||
"playwright/playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
|
||||
|
||||
"prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="],
|
||||
|
||||
"readdirp/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
|
||||
|
||||
"recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||
|
||||
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.12", "", {}, "sha512-HHMwmarRKvoFsJorqYlFeFRzXZqCt2ETQlEDOb9aqssrnVBB1/+xgTGtuTrIk5vzLNX1MjMtTf7W9z3tsSbrxw=="],
|
||||
|
||||
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"@kitajs/ts-html-plugin/yargs/cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
|
||||
|
||||
"@kitajs/ts-html-plugin/yargs/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"@kitajs/ts-html-plugin/yargs/yargs-parser": ["yargs-parser@22.0.0", "", {}, "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw=="],
|
||||
|
||||
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
|
||||
|
||||
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.9", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-ZUvP7KeBLe3OZ1ypw6dI/TzYJuvHP77IM4Ry73waSQTLn8/g8rpdjfyVAh7t1/+FjBtG4lCP42MEbDxOsRpBMw=="],
|
||||
|
||||
"c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="],
|
||||
|
||||
"@kitajs/ts-html-plugin/yargs/cliui/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
66
docs/ARCHITECTURE.md
Normal file
66
docs/ARCHITECTURE.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Architecture
|
||||
|
||||
## Server
|
||||
|
||||
Elysia.js on Bun. All API routes are in `src/app.ts` as `createApp()` — testable via `app.handle(request)` without starting a server. `src/index.tsx` adds Vite middleware (dev) or static serving (prod) and calls `.listen()`. `src/serve.ts` is the dev entry point (dynamic import workaround for Bun EADDRINUSE race).
|
||||
|
||||
## Database
|
||||
|
||||
PostgreSQL via Prisma v6. Client generated to `./generated/prisma` (gitignored — run `bun run db:generate` after checkout or schema changes).
|
||||
|
||||
**Schema models:** `User`, `Session`, `App`, `Log`, `Bug`, `BugImage`, `BugLog`
|
||||
|
||||
**Enums:** `Role` (ADMIN, DEVELOPER), `BugStatus` (OPEN, ON_HOLD, IN_PROGRESS, RESOLVED, RELEASED, CLOSED), `BugSource` (QC, SYSTEM, USER), `LogType` (CREATE, UPDATE, DELETE, LOGIN, LOGOUT)
|
||||
|
||||
Import the singleton: `import { prisma } from './lib/db'`
|
||||
|
||||
## Auth & Roles
|
||||
|
||||
Session-based auth with HttpOnly cookies stored in the DB (24h expiry). Two roles: `DEVELOPER` (super admin) and `ADMIN`. Users listed in `SUPER_ADMIN_EMAIL` env var are auto-promoted to DEVELOPER on login.
|
||||
|
||||
Endpoints: `POST /api/auth/login`, `POST /api/auth/logout`, `GET /api/auth/session`
|
||||
|
||||
Auth state on the frontend is managed via `useSession()` / `useLogin()` / `useLogout()` in `src/frontend/hooks/useAuth.ts` (TanStack Query).
|
||||
|
||||
## Frontend
|
||||
|
||||
React 19 + Vite 8 (middleware mode in dev). TanStack Router with file-based routing in `src/frontend/routes/`. All routes are wrapped in `DashboardLayout` from `src/frontend/components/DashboardLayout.tsx`.
|
||||
|
||||
**Route structure:**
|
||||
- `/` → redirect
|
||||
- `/login` → login page
|
||||
- `/dashboard` → stats overview
|
||||
- `/apps` → app list
|
||||
- `/apps/$appId` → per-app layout with nested routes: `index`, `errors`, `logs`, `users`, `villages`, `orders`, `products`, `payments`
|
||||
- `/users` → operator management
|
||||
- `/logs` → system activity log
|
||||
- `/bug-reports` → cross-app bug reports
|
||||
- `/profile` → user profile
|
||||
|
||||
**App configs** are defined in `src/frontend/config/appMenus.ts` — each app has an ID and a menu list. Currently active: `desa-plus`. Add new app entries here to register them.
|
||||
|
||||
**routeTree.gen.ts** is auto-generated by the TanStack Router Vite plugin — never edit it manually.
|
||||
|
||||
**UI:** Mantine v8, dark theme forced (`#242424`). Charts use `@mantine/charts` (recharts under the hood). Icons from `react-icons/tb`.
|
||||
|
||||
## API Structure
|
||||
|
||||
All API routes live in `src/app.ts`. Key groups:
|
||||
- `/api/auth/*` — authentication
|
||||
- `/api/dashboard/*` — stats and recent errors
|
||||
- `/api/apps`, `/api/apps/:appId` — app listing and detail
|
||||
- `/api/bugs`, `/api/bugs/:id/status`, `/api/bugs/:id/feedback` — bug report CRUD
|
||||
- `/api/operators`, `/api/operators/:id` — user management
|
||||
- `/api/logs` — system activity log
|
||||
- `/api/system/status` — health check with DB connectivity
|
||||
|
||||
## Logging
|
||||
|
||||
`createSystemLog(userId, type, message)` from `src/lib/logger.ts` writes to the `Log` model. Call it for any significant user action (login/logout/CRUD). Logging errors are swallowed so they never break the main flow.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Required: `DATABASE_URL`, `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`
|
||||
Optional: `PORT` (default 3000), `NODE_ENV`, `REACT_EDITOR`, `SUPER_ADMIN_EMAIL` (comma-separated)
|
||||
|
||||
Validated at startup in `src/lib/env.ts` — missing required vars throw immediately.
|
||||
6
docs/DEV_TOOLS.md
Normal file
6
docs/DEV_TOOLS.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Dev Tools
|
||||
|
||||
- **Click-to-source:** `Ctrl+Shift+Cmd+C` toggles inspector. Custom Vite plugin in `src/vite.ts` injects `data-inspector-*` attributes; reads original source from disk for accurate line numbers.
|
||||
- **HMR:** Vite 8 + `@vitejs/plugin-react` v6. `dedupeRefreshPlugin` in `src/vite.ts` prevents double React Refresh injection.
|
||||
- **Editor:** Set `REACT_EDITOR` env var. `zed`/`subl` use `file:line:col`; others get `--goto file:line:col`.
|
||||
- **Playwright MCP:** `bun run mcp:playwright` starts headless browser MCP server (config in `.qwen/settings.json`).
|
||||
6
docs/TESTING.md
Normal file
6
docs/TESTING.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# Testing
|
||||
|
||||
- **Unit:** env, DB connection, bcrypt — in `tests/unit/`
|
||||
- **Integration:** `createApp().handle(new Request(...))` — no running server needed, use these for mutations
|
||||
- **E2E:** Lightpanda browser via CDP (`ws://127.0.0.1:9222`). App URLs use `host.docker.internal` from inside Docker. Lightpanda executes JS but POST fetch returns 407 — use integration tests for anything that writes data.
|
||||
- **Helpers:** `tests/helpers.ts` — `createTestApp()`, `seedTestUser()`, `createTestSession()`, `cleanupTestData()`
|
||||
@@ -25,6 +25,7 @@
|
||||
"@elysiajs/cors": "^1.4.1",
|
||||
"@elysiajs/eden": "^1.4.9",
|
||||
"@elysiajs/html": "^1.4.0",
|
||||
"@elysiajs/swagger": "^1.3.1",
|
||||
"@mantine/charts": "^9.0.0",
|
||||
"@mantine/core": "^8.3.18",
|
||||
"@mantine/hooks": "^8.3.18",
|
||||
@@ -33,6 +34,7 @@
|
||||
"@tanstack/react-query": "^5.95.2",
|
||||
"@tanstack/react-router": "^1.168.10",
|
||||
"elysia": "^1.4.28",
|
||||
"minio": "^8.0.7",
|
||||
"postcss": "^8.5.8",
|
||||
"postcss-preset-mantine": "^1.18.0",
|
||||
"postcss-simple-vars": "^7.0.1",
|
||||
|
||||
425
src/app.ts
425
src/app.ts
@@ -1,12 +1,57 @@
|
||||
import { cors } from '@elysiajs/cors'
|
||||
import { html } from '@elysiajs/html'
|
||||
import { Elysia } from 'elysia'
|
||||
import { swagger } from '@elysiajs/swagger'
|
||||
import { Elysia, t } from 'elysia'
|
||||
import { BugSource } from '../generated/prisma'
|
||||
import { prisma } from './lib/db'
|
||||
import { env } from './lib/env'
|
||||
import { createSystemLog } from './lib/logger'
|
||||
import { getMinioDownloadUrl, uploadBugImage } from './lib/minio'
|
||||
|
||||
interface AuthResult {
|
||||
actingUserId: string
|
||||
reporterUserId: string | null // null jika via API key (tidak ada user spesifik)
|
||||
isApiKey: boolean // true = dari klien eksternal (mobile app)
|
||||
}
|
||||
|
||||
// Validates session cookie OR X-API-Key header. Returns null if neither is valid.
|
||||
async function checkAuth(request: Request): Promise<AuthResult | null> {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
if (token) {
|
||||
const session = await prisma.session.findUnique({ where: { token } })
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
return { actingUserId: session.userId, reporterUserId: session.userId, isApiKey: false }
|
||||
}
|
||||
}
|
||||
|
||||
const apiKey = request.headers.get('x-api-key')
|
||||
if (apiKey && apiKey === env.API_KEY) {
|
||||
const developer = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||
if (!developer) return null
|
||||
return { actingUserId: developer.id, reporterUserId: null, isApiKey: true }
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function createApp() {
|
||||
return new Elysia()
|
||||
.use(swagger({
|
||||
path: '/docs',
|
||||
documentation: {
|
||||
info: { title: 'Monitoring App API', version: '0.1.0' },
|
||||
tags: [
|
||||
{ name: 'Auth', description: 'Autentikasi dan manajemen sesi' },
|
||||
{ name: 'Dashboard', description: 'Statistik dan ringkasan monitoring' },
|
||||
{ name: 'Apps', description: 'Manajemen aplikasi yang dimonitor' },
|
||||
{ name: 'Bugs', description: 'Manajemen laporan bug' },
|
||||
{ name: 'Logs', description: 'Log aktivitas sistem' },
|
||||
{ name: 'Operators', description: 'Manajemen operator / pengguna sistem' },
|
||||
{ name: 'System', description: 'Status dan kesehatan sistem' },
|
||||
],
|
||||
},
|
||||
}))
|
||||
.use(cors())
|
||||
.use(html())
|
||||
|
||||
@@ -25,12 +70,18 @@ export function createApp() {
|
||||
})
|
||||
})
|
||||
|
||||
// API routes
|
||||
.get('/health', () => ({ status: 'ok' }))
|
||||
// ─── Health ───────────────────────────────────────
|
||||
.get('/health', () => ({ status: 'ok' }), {
|
||||
detail: {
|
||||
summary: 'Health Check',
|
||||
description: 'Memeriksa apakah server sedang berjalan.',
|
||||
tags: ['System'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Auth API ──────────────────────────────────────
|
||||
.post('/api/auth/login', async ({ request, set }) => {
|
||||
const { email, password } = (await request.json()) as { email: string; password: string }
|
||||
.post('/api/auth/login', async ({ body, set }) => {
|
||||
const { email, password } = body
|
||||
let user = await prisma.user.findUnique({ where: { email } })
|
||||
if (!user || !(await Bun.password.verify(password, user.password))) {
|
||||
set.status = 401
|
||||
@@ -46,6 +97,16 @@ export function createApp() {
|
||||
set.headers['set-cookie'] = `session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=86400`
|
||||
await createSystemLog(user.id, 'LOGIN', 'Logged in successfully')
|
||||
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } }
|
||||
}, {
|
||||
body: t.Object({
|
||||
email: t.String({ format: 'email', description: 'Email pengguna' }),
|
||||
password: t.String({ minLength: 1, description: 'Password pengguna' }),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Login',
|
||||
description: 'Login dengan email dan password. Mengembalikan data user dan set session cookie (HttpOnly, 24 jam). Jika email terdaftar di SUPER_ADMIN_EMAIL, role otomatis di-promote ke DEVELOPER.',
|
||||
tags: ['Auth'],
|
||||
},
|
||||
})
|
||||
|
||||
.post('/api/auth/logout', async ({ request, set }) => {
|
||||
@@ -60,6 +121,12 @@ export function createApp() {
|
||||
}
|
||||
set.headers['set-cookie'] = 'session=; Path=/; HttpOnly; Max-Age=0'
|
||||
return { ok: true }
|
||||
}, {
|
||||
detail: {
|
||||
summary: 'Logout',
|
||||
description: 'Menghapus sesi aktif dari database dan membersihkan session cookie.',
|
||||
tags: ['Auth'],
|
||||
},
|
||||
})
|
||||
|
||||
.get('/api/auth/session', async ({ request, set }) => {
|
||||
@@ -76,11 +143,15 @@ export function createApp() {
|
||||
return { user: null }
|
||||
}
|
||||
return { user: session.user }
|
||||
}, {
|
||||
detail: {
|
||||
summary: 'Get Current Session',
|
||||
description: 'Mengembalikan data user dari sesi aktif berdasarkan session cookie. Mengembalikan 401 jika tidak ada sesi atau sudah kadaluarsa.',
|
||||
tags: ['Auth'],
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
|
||||
// ─── Monitoring API ────────────────────────────────
|
||||
// ─── Dashboard API ─────────────────────────────────
|
||||
.get('/api/dashboard/stats', async () => {
|
||||
const newErrors = await prisma.bug.count({ where: { status: 'OPEN' } })
|
||||
const users = await prisma.user.count()
|
||||
@@ -90,6 +161,12 @@ export function createApp() {
|
||||
activeUsers: users,
|
||||
trends: { totalApps: 0, newErrors: 12, activeUsers: 5.2 }
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
summary: 'Dashboard Stats',
|
||||
description: 'Mengembalikan statistik utama dashboard: total aplikasi, jumlah error baru (status OPEN), total pengguna, dan data tren.',
|
||||
tags: ['Dashboard'],
|
||||
},
|
||||
})
|
||||
|
||||
.get('/api/dashboard/recent-errors', async () => {
|
||||
@@ -105,10 +182,17 @@ export function createApp() {
|
||||
time: b.createdAt.toISOString(),
|
||||
severity: b.status
|
||||
}))
|
||||
}, {
|
||||
detail: {
|
||||
summary: 'Recent Errors',
|
||||
description: 'Mengembalikan 5 bug report terbaru (diurutkan dari yang terbaru) untuk ditampilkan di dashboard.',
|
||||
tags: ['Dashboard'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Apps API ──────────────────────────────────────
|
||||
.get('/api/apps', async ({ query }) => {
|
||||
const search = (query.search as string) || ''
|
||||
const search = query.search || ''
|
||||
const where: any = {}
|
||||
if (search) {
|
||||
where.name = { contains: search, mode: 'insensitive' }
|
||||
@@ -131,6 +215,15 @@ export function createApp() {
|
||||
version: app.version ?? '-',
|
||||
maintenance: app.maintenance,
|
||||
}))
|
||||
}, {
|
||||
query: t.Object({
|
||||
search: t.Optional(t.String({ description: 'Filter berdasarkan nama aplikasi' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'List Apps',
|
||||
description: 'Mengembalikan semua aplikasi yang dimonitor beserta status (active/warning/error), jumlah bug OPEN, versi, dan mode maintenance.',
|
||||
tags: ['Apps'],
|
||||
},
|
||||
})
|
||||
|
||||
.get('/api/apps/:appId', async ({ params: { appId }, set }) => {
|
||||
@@ -157,14 +250,24 @@ export function createApp() {
|
||||
maintenance: app.maintenance,
|
||||
totalBugs: app._count.bugs,
|
||||
}
|
||||
}, {
|
||||
params: t.Object({
|
||||
appId: t.String({ description: 'ID aplikasi (contoh: desa-plus)' }),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Get App Detail',
|
||||
description: 'Mengembalikan detail satu aplikasi berdasarkan ID, termasuk status, versi minimum, mode maintenance, dan total semua bug.',
|
||||
tags: ['Apps'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Logs API ──────────────────────────────────────
|
||||
.get('/api/logs', async ({ query }) => {
|
||||
const page = Number(query.page) || 1
|
||||
const limit = Number(query.limit) || 20
|
||||
const search = (query.search as string) || ''
|
||||
const search = query.search || ''
|
||||
const type = query.type as any
|
||||
const userId = query.userId as string
|
||||
const userId = query.userId
|
||||
|
||||
const where: any = {}
|
||||
if (search) {
|
||||
@@ -196,31 +299,63 @@ export function createApp() {
|
||||
totalPages: Math.ceil(total / limit),
|
||||
totalItems: total
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })),
|
||||
limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })),
|
||||
search: t.Optional(t.String({ description: 'Cari berdasarkan pesan log atau nama pengguna' })),
|
||||
type: t.Optional(t.String({ description: 'Filter tipe: CREATE | UPDATE | DELETE | LOGIN | LOGOUT | all' })),
|
||||
userId: t.Optional(t.String({ description: 'Filter berdasarkan ID pengguna, atau "all"' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'List Activity Logs',
|
||||
description: 'Mengembalikan log aktivitas sistem dengan pagination. Mendukung filter berdasarkan tipe log (CREATE, UPDATE, DELETE, LOGIN, LOGOUT) dan pengguna.',
|
||||
tags: ['Logs'],
|
||||
},
|
||||
})
|
||||
|
||||
.post('/api/logs', async ({ request, set }) => {
|
||||
.post('/api/logs', async ({ body, request }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
let userId: string | undefined
|
||||
|
||||
if (token) {
|
||||
const session = await prisma.session.findUnique({ where: { token } })
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
userId = session.userId
|
||||
}
|
||||
if (session && session.expiresAt > new Date()) userId = session.userId
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { type: string, message: string }
|
||||
const actingUserId = userId || (await prisma.user.findFirst({ where: { role: 'DEVELOPER' } }))?.id || ''
|
||||
|
||||
await createSystemLog(actingUserId, body.type as any, body.message)
|
||||
return { ok: true }
|
||||
}, {
|
||||
body: t.Object({
|
||||
type: t.String({ description: 'Tipe log: CREATE | UPDATE | DELETE | LOGIN | LOGOUT' }),
|
||||
message: t.String({ description: 'Pesan log yang akan dicatat' }),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Create Log',
|
||||
description: 'Mencatat log aktivitas sistem. Jika ada session cookie yang valid, log dikaitkan ke pengguna yang sedang login. Jika tidak, log dikaitkan ke akun DEVELOPER pertama.',
|
||||
tags: ['Logs'],
|
||||
},
|
||||
})
|
||||
|
||||
.get('/api/logs/operators', async () => {
|
||||
return await prisma.user.findMany({
|
||||
select: { id: true, name: true, image: true },
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
}, {
|
||||
detail: {
|
||||
summary: 'List Operators for Log Filter',
|
||||
description: 'Mengembalikan daftar semua pengguna (id, name, image) sebagai opsi filter pada halaman log aktivitas.',
|
||||
tags: ['Logs'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Operators API ─────────────────────────────────
|
||||
.get('/api/operators', async ({ query }) => {
|
||||
const page = Number(query.page) || 1
|
||||
const limit = Number(query.limit) || 20
|
||||
const search = (query.search as string) || ''
|
||||
const search = query.search || ''
|
||||
|
||||
const where: any = {}
|
||||
if (search) {
|
||||
@@ -246,11 +381,22 @@ export function createApp() {
|
||||
totalPages: Math.ceil(total / limit),
|
||||
totalItems: total
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })),
|
||||
limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })),
|
||||
search: t.Optional(t.String({ description: 'Cari berdasarkan nama atau email' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'List Operators',
|
||||
description: 'Mengembalikan daftar operator/pengguna sistem dengan pagination. Mendukung pencarian berdasarkan nama dan email.',
|
||||
tags: ['Operators'],
|
||||
},
|
||||
})
|
||||
|
||||
.get('/api/operators/stats', async () => {
|
||||
const [totalStaff, activeNow, rolesGroup] = await Promise.all([
|
||||
prisma.user.count({where: {active: true}}),
|
||||
prisma.user.count({ where: { active: true } }),
|
||||
prisma.session.count({
|
||||
where: { expiresAt: { gte: new Date() } },
|
||||
}),
|
||||
@@ -265,9 +411,15 @@ export function createApp() {
|
||||
activeNow,
|
||||
rolesCount: rolesGroup.length
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
summary: 'Operator Stats',
|
||||
description: 'Mengembalikan statistik operator: total staf aktif, jumlah sesi yang sedang aktif saat ini, dan jumlah role yang ada.',
|
||||
tags: ['Operators'],
|
||||
},
|
||||
})
|
||||
|
||||
.post('/api/operators', async ({ request, set }) => {
|
||||
.post('/api/operators', async ({ body, request, set }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
let userId: string | undefined
|
||||
@@ -276,8 +428,6 @@ export function createApp() {
|
||||
if (session && session.expiresAt > new Date()) userId = session.userId
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { name: string; email: string; password: string; role: string }
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { email: body.email } })
|
||||
if (existing) {
|
||||
set.status = 400
|
||||
@@ -299,9 +449,21 @@ export function createApp() {
|
||||
}
|
||||
|
||||
return { id: user.id, name: user.name, email: user.email, role: user.role }
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1, description: 'Nama lengkap operator' }),
|
||||
email: t.String({ format: 'email', description: 'Alamat email (harus unik)' }),
|
||||
password: t.String({ minLength: 6, description: 'Password (minimal 6 karakter)' }),
|
||||
role: t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role: ADMIN atau DEVELOPER' }),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Create Operator',
|
||||
description: 'Membuat akun operator baru. Password di-hash dengan bcrypt sebelum disimpan. Gagal jika email sudah terdaftar.',
|
||||
tags: ['Operators'],
|
||||
},
|
||||
})
|
||||
|
||||
.patch('/api/operators/:id', async ({ params: { id }, request, set }) => {
|
||||
.patch('/api/operators/:id', async ({ params: { id }, body, request }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
let userId: string | undefined
|
||||
@@ -310,8 +472,6 @@ export function createApp() {
|
||||
if (session && session.expiresAt > new Date()) userId = session.userId
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { name?: string; email?: string; role?: string; active?: boolean }
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -327,6 +487,21 @@ export function createApp() {
|
||||
}
|
||||
|
||||
return { id: user.id, name: user.name, email: user.email, role: user.role, active: user.active }
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ description: 'ID operator yang akan diupdate' }),
|
||||
}),
|
||||
body: t.Object({
|
||||
name: t.Optional(t.String({ minLength: 1, description: 'Nama baru' })),
|
||||
email: t.Optional(t.String({ format: 'email', description: 'Email baru' })),
|
||||
role: t.Optional(t.Union([t.Literal('ADMIN'), t.Literal('DEVELOPER')], { description: 'Role baru' })),
|
||||
active: t.Optional(t.Boolean({ description: 'Status aktif operator' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Update Operator',
|
||||
description: 'Mengupdate data operator secara parsial. Semua field bersifat opsional — hanya field yang dikirim yang akan diupdate.',
|
||||
tags: ['Operators'],
|
||||
},
|
||||
})
|
||||
|
||||
.delete('/api/operators/:id', async ({ params: { id }, request, set }) => {
|
||||
@@ -358,19 +533,22 @@ export function createApp() {
|
||||
}
|
||||
|
||||
return { ok: true }
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ description: 'ID operator yang akan dideactivate' }),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Deactivate Operator',
|
||||
description: 'Menonaktifkan akun operator (soft delete: set active=false). Semua sesi aktif operator tersebut ikut dihapus. Tidak bisa menghapus akun sendiri.',
|
||||
tags: ['Operators'],
|
||||
},
|
||||
})
|
||||
|
||||
.get('/api/logs/operators', async () => {
|
||||
return await prisma.user.findMany({
|
||||
select: { id: true, name: true, image: true },
|
||||
orderBy: { name: 'asc' }
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Bugs API ──────────────────────────────────────
|
||||
.get('/api/bugs', async ({ query }) => {
|
||||
const page = Number(query.page) || 1
|
||||
const limit = Number(query.limit) || 20
|
||||
const search = (query.search as string) || ''
|
||||
const search = query.search || ''
|
||||
const app = query.app as any
|
||||
const status = query.status as any
|
||||
|
||||
@@ -413,23 +591,28 @@ export function createApp() {
|
||||
totalPages: Math.ceil(total / limit),
|
||||
totalItems: total,
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
page: t.Optional(t.String({ description: 'Nomor halaman (default: 1)' })),
|
||||
limit: t.Optional(t.String({ description: 'Jumlah data per halaman (default: 20)' })),
|
||||
search: t.Optional(t.String({ description: 'Cari berdasarkan deskripsi, device, OS, atau versi' })),
|
||||
app: t.Optional(t.String({ description: 'Filter berdasarkan ID aplikasi, atau "all"' })),
|
||||
status: t.Optional(t.String({ description: 'Filter status: OPEN | ON_HOLD | IN_PROGRESS | RESOLVED | RELEASED | CLOSED | all' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'List Bug Reports',
|
||||
description: 'Mengembalikan daftar bug report dengan pagination, beserta data pelapor, gambar, dan riwayat status (BugLog). Mendukung filter berdasarkan aplikasi dan status.',
|
||||
tags: ['Bugs'],
|
||||
},
|
||||
})
|
||||
|
||||
.post('/api/bugs', async ({ request, set }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
let userId: string | undefined
|
||||
|
||||
if (token) {
|
||||
const session = await prisma.session.findUnique({ where: { token } })
|
||||
if (session && session.expiresAt > new Date()) {
|
||||
userId = session.userId
|
||||
}
|
||||
.post('/api/bugs', async ({ body, request, set }) => {
|
||||
const auth = await checkAuth(request)
|
||||
if (!auth) {
|
||||
set.status = 401
|
||||
return { error: 'Unauthorized: sertakan session cookie atau header X-API-Key' }
|
||||
}
|
||||
|
||||
const body = (await request.json()) as any
|
||||
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||
const actingUserId = userId || defaultAdmin?.id || ''
|
||||
const { actingUserId, reporterUserId, isApiKey } = auth
|
||||
|
||||
const bug = await prisma.bug.create({
|
||||
data: {
|
||||
@@ -437,20 +620,18 @@ export function createApp() {
|
||||
affectedVersion: body.affectedVersion,
|
||||
device: body.device,
|
||||
os: body.os,
|
||||
status: body.status || 'OPEN',
|
||||
source: body.source || 'USER',
|
||||
status: 'OPEN',
|
||||
source: body.source as BugSource,
|
||||
description: body.description,
|
||||
stackTrace: body.stackTrace,
|
||||
userId: userId,
|
||||
images: body.imageUrl ? {
|
||||
create: {
|
||||
imageUrl: body.imageUrl
|
||||
}
|
||||
userId: reporterUserId,
|
||||
images: body.imageUrls?.length ? {
|
||||
createMany: { data: body.imageUrls.map(imageUrl => ({ imageUrl })) }
|
||||
} : undefined,
|
||||
logs: {
|
||||
create: {
|
||||
userId: actingUserId,
|
||||
status: body.status || 'OPEN',
|
||||
status: 'OPEN',
|
||||
description: 'Bug reported initially.',
|
||||
},
|
||||
},
|
||||
@@ -458,9 +639,27 @@ export function createApp() {
|
||||
})
|
||||
|
||||
return bug
|
||||
}, {
|
||||
body: t.Object({
|
||||
app: t.Optional(t.String({ description: 'ID aplikasi terkait (contoh: desa-plus)' })),
|
||||
affectedVersion: t.String({ description: 'Versi aplikasi yang terdampak bug' }),
|
||||
device: t.String({ description: 'Tipe/model perangkat pengguna' }),
|
||||
os: t.String({ description: 'Sistem operasi perangkat (contoh: Android 13, iOS 17)' }),
|
||||
description: t.String({ minLength: 1, description: 'Deskripsi bug yang ditemukan' }),
|
||||
stackTrace: t.Optional(t.String({ description: 'Stack trace error (opsional)' })),
|
||||
source: t.Optional(t.String({
|
||||
description: 'Sumber laporan: QC | SYSTEM | USER',
|
||||
})),
|
||||
imageUrls: t.Optional(t.Array(t.String(), { description: 'URL gambar screenshot bug, maks 3 (opsional)' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Create Bug Report',
|
||||
description: 'Membuat laporan bug baru dengan status awal OPEN. Bisa diakses via session cookie (frontend) atau X-API-Key (klien eksternal seperti Desa+). Jika via API key, userId pelapor null dan source default USER.',
|
||||
tags: ['Bugs'],
|
||||
},
|
||||
})
|
||||
|
||||
.patch('/api/bugs/:id/feedback', async ({ params: { id }, request }) => {
|
||||
.patch('/api/bugs/:id/feedback', async ({ params: { id }, body, request }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
let userId: string | undefined
|
||||
@@ -472,15 +671,12 @@ export function createApp() {
|
||||
}
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { feedBack: string }
|
||||
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||
const actingUserId = userId || defaultAdmin?.id || undefined
|
||||
|
||||
const bug = await prisma.bug.update({
|
||||
where: { id },
|
||||
data: {
|
||||
feedBack: body.feedBack,
|
||||
},
|
||||
data: { feedBack: body.feedBack },
|
||||
})
|
||||
|
||||
if (actingUserId) {
|
||||
@@ -488,9 +684,21 @@ export function createApp() {
|
||||
}
|
||||
|
||||
return bug
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ description: 'ID bug report' }),
|
||||
}),
|
||||
body: t.Object({
|
||||
feedBack: t.String({ description: 'Feedback atau catatan developer untuk bug ini' }),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Update Bug Feedback',
|
||||
description: 'Menambahkan atau mengupdate feedback/catatan developer pada sebuah bug report.',
|
||||
tags: ['Bugs'],
|
||||
},
|
||||
})
|
||||
|
||||
.patch('/api/bugs/:id/status', async ({ params: { id }, request }) => {
|
||||
.patch('/api/bugs/:id/status', async ({ params: { id }, body, request }) => {
|
||||
const cookie = request.headers.get('cookie') ?? ''
|
||||
const token = cookie.match(/session=([^;]+)/)?.[1]
|
||||
let userId: string | undefined
|
||||
@@ -502,7 +710,6 @@ export function createApp() {
|
||||
}
|
||||
}
|
||||
|
||||
const body = (await request.json()) as { status: string; description?: string }
|
||||
const defaultAdmin = await prisma.user.findFirst({ where: { role: 'DEVELOPER' } })
|
||||
const actingUserId = userId || defaultAdmin?.id || undefined
|
||||
|
||||
@@ -525,6 +732,77 @@ export function createApp() {
|
||||
}
|
||||
|
||||
return bug
|
||||
}, {
|
||||
params: t.Object({
|
||||
id: t.String({ description: 'ID bug report' }),
|
||||
}),
|
||||
body: t.Object({
|
||||
status: t.Union(
|
||||
[
|
||||
t.Literal('OPEN'),
|
||||
t.Literal('ON_HOLD'),
|
||||
t.Literal('IN_PROGRESS'),
|
||||
t.Literal('RESOLVED'),
|
||||
t.Literal('RELEASED'),
|
||||
t.Literal('CLOSED'),
|
||||
],
|
||||
{ description: 'Status baru bug' }
|
||||
),
|
||||
description: t.Optional(t.String({ description: 'Catatan perubahan status (opsional)' })),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Update Bug Status',
|
||||
description: 'Mengubah status bug dan otomatis membuat entri BugLog baru sebagai riwayat perubahan status.',
|
||||
tags: ['Bugs'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Image Upload & Proxy ──────────────────────────
|
||||
.post('/api/upload/image', async ({ body, request, set }) => {
|
||||
const auth = await checkAuth(request)
|
||||
if (!auth) {
|
||||
set.status = 401
|
||||
return { error: 'Unauthorized' }
|
||||
}
|
||||
const { file } = body
|
||||
if (!file.type.startsWith('image/')) {
|
||||
set.status = 400
|
||||
return { error: 'Hanya file gambar yang diperbolehkan' }
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
set.status = 400
|
||||
return { error: 'Ukuran file maksimal 5MB' }
|
||||
}
|
||||
const path = await uploadBugImage(file)
|
||||
return { url: `/api/bugs/images?path=${encodeURIComponent(path)}` }
|
||||
}, {
|
||||
body: t.Object({
|
||||
file: t.File({ description: 'File gambar screenshot bug (maks 5MB)' }),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Upload Bug Image',
|
||||
description: 'Upload gambar screenshot bug ke MinIO. Mengembalikan URL proxy yang dapat langsung dipakai sebagai imageUrl pada POST /api/bugs.',
|
||||
tags: ['Bugs'],
|
||||
},
|
||||
})
|
||||
|
||||
.get('/api/bugs/images', async ({ query, set }) => {
|
||||
try {
|
||||
const downloadUrl = await getMinioDownloadUrl(query.path)
|
||||
return new Response(null, { status: 302, headers: { Location: downloadUrl } })
|
||||
} catch {
|
||||
set.status = 404
|
||||
return { error: 'Gambar tidak ditemukan' }
|
||||
}
|
||||
}, {
|
||||
query: t.Object({
|
||||
path: t.String({ description: 'Path file di Seafile (contoh: /bug-reports/uuid.jpg)' }),
|
||||
}),
|
||||
detail: {
|
||||
summary: 'Get Bug Image',
|
||||
description: 'Proxy gambar bug dari MinIO. Meredirect ke presigned download URL MinIO (valid 1 jam).',
|
||||
tags: ['Bugs'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── System Status API ─────────────────────────────
|
||||
@@ -549,18 +827,33 @@ export function createApp() {
|
||||
uptime: process.uptime(),
|
||||
}
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
summary: 'System Status',
|
||||
description: 'Memeriksa status operasional sistem: koneksi database dan jumlah sesi aktif. Mengembalikan status "degraded" jika database tidak dapat dijangkau.',
|
||||
tags: ['System'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Example API ───────────────────────────────────
|
||||
.get('/api/hello', () => ({
|
||||
message: 'Hello, world!',
|
||||
method: 'GET',
|
||||
}))
|
||||
}), {
|
||||
detail: { summary: 'Hello GET', tags: ['System'] },
|
||||
})
|
||||
.put('/api/hello', () => ({
|
||||
message: 'Hello, world!',
|
||||
method: 'PUT',
|
||||
}))
|
||||
}), {
|
||||
detail: { summary: 'Hello PUT', tags: ['System'] },
|
||||
})
|
||||
.get('/api/hello/:name', ({ params }) => ({
|
||||
message: `Hello, ${params.name}!`,
|
||||
}))
|
||||
}), {
|
||||
params: t.Object({
|
||||
name: t.String({ description: 'Nama yang akan disapa' }),
|
||||
}),
|
||||
detail: { summary: 'Hello by Name', tags: ['System'] },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -263,16 +263,6 @@ export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
variant="filled"
|
||||
color="brand-blue"
|
||||
className="sidebar-nav-item"
|
||||
styles={(theme) => ({
|
||||
root: {
|
||||
borderRadius: theme.radius.md,
|
||||
transition: 'all 0.2s ease',
|
||||
'&[data-active]': {
|
||||
background: 'var(--gradient-blue-purple)',
|
||||
fontWeight: 600,
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,23 +1,23 @@
|
||||
import {
|
||||
Table,
|
||||
Badge,
|
||||
Text,
|
||||
Paper,
|
||||
Group,
|
||||
Drawer,
|
||||
Stack,
|
||||
Divider,
|
||||
Code,
|
||||
Button,
|
||||
import {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Code,
|
||||
Divider,
|
||||
Drawer,
|
||||
Group,
|
||||
Paper,
|
||||
ScrollArea,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title
|
||||
} from '@mantine/core'
|
||||
import { useDisclosure } from '@mantine/hooks'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import { TbMessageReport, TbHistory, TbExternalLink, TbBug } from 'react-icons/tb'
|
||||
import { useState } from 'react'
|
||||
import { TbBug, TbExternalLink, TbHistory, TbMessageReport } from 'react-icons/tb'
|
||||
|
||||
export interface ErrorDataTableProps {
|
||||
appId?: string
|
||||
@@ -26,6 +26,7 @@ export interface ErrorDataTableProps {
|
||||
export function ErrorDataTable({ appId }: ErrorDataTableProps) {
|
||||
const [opened, { open, close }] = useDisclosure(false)
|
||||
const [selectedError, setSelectedError] = useState<any>(null)
|
||||
const [showStackTrace, setShowStackTrace] = useState(false)
|
||||
|
||||
const { data: bugsData, isLoading } = useQuery({
|
||||
queryKey: ['bugs', appId],
|
||||
@@ -36,11 +37,12 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
|
||||
|
||||
const handleRowClick = (error: any) => {
|
||||
setSelectedError(error)
|
||||
setShowStackTrace(false)
|
||||
open()
|
||||
}
|
||||
|
||||
const getSeverityColor = (sev: string) => {
|
||||
switch(sev?.toUpperCase()) {
|
||||
switch (sev?.toUpperCase()) {
|
||||
case 'OPEN': return 'red'
|
||||
case 'IN_PROGRESS': return 'orange'
|
||||
case 'ON_HOLD': return 'yellow'
|
||||
@@ -90,10 +92,10 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
) : bugs.map((error: any) => (
|
||||
<Table.Tr
|
||||
key={error.id}
|
||||
onClick={() => handleRowClick(error)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
<Table.Tr
|
||||
key={error.id}
|
||||
onClick={() => handleRowClick(error)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Table.Td px="xl">
|
||||
<Text size="sm" fw={600} lineClamp={1}>{error.description}</Text>
|
||||
@@ -107,7 +109,7 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
|
||||
<Table.Td>
|
||||
<Group gap={6}>
|
||||
<TbHistory size={12} color="gray" />
|
||||
<Text size="xs" c="dimmed">{new Date(error.createdAt).toLocaleString()}</Text>
|
||||
<Text size="xs" c="dimmed">{new Date(error.createdAt).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })}</Text>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td pr="xl">
|
||||
@@ -146,8 +148,8 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
|
||||
|
||||
<SimpleGrid cols={2} spacing="lg">
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>REPORTER</Text>
|
||||
<Text fw={600}>{selectedError.user?.name || selectedError.userId || 'System'}</Text>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>SOURCE</Text>
|
||||
<Text fw={600}>{selectedError.source}</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>APP VERSION</Text>
|
||||
@@ -158,16 +160,23 @@ export function ErrorDataTable({ appId }: ErrorDataTableProps) {
|
||||
<Divider opacity={0.1} />
|
||||
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb="sm">STACK TRACE</Text>
|
||||
<Code block color="red" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, border: '1px solid var(--mantine-color-default-border)' }}>
|
||||
{selectedError.stackTrace}
|
||||
</Code>
|
||||
<Group justify="space-between" mb="sm">
|
||||
<Text size="xs" fw={700} c="dimmed">STACK TRACE</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="gray"
|
||||
onClick={() => setShowStackTrace((v) => !v)}
|
||||
>
|
||||
{showStackTrace ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</Group>
|
||||
{showStackTrace && (
|
||||
<Code block color="red" style={{ whiteSpace: 'pre-wrap', lineHeight: 1.6, border: '1px solid var(--mantine-color-default-border)' }}>
|
||||
{selectedError.stackTrace}
|
||||
</Code>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Group justify="flex-end" mt="xl">
|
||||
<Button variant="light" color="gray" onClick={close}>Dismiss</Button>
|
||||
<Button variant="gradient" gradient={{ from: 'red', to: 'orange' }}>Assign Technician</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -37,6 +37,7 @@ export const API_URLS = {
|
||||
getBugs: (page: number, search: string, app: string, status: string) =>
|
||||
`/api/bugs?page=${page}&search=${encodeURIComponent(search)}&app=${app}&status=${status}`,
|
||||
createBug: () => `/api/bugs`,
|
||||
uploadImage: () => `/api/upload/image`,
|
||||
updateBugStatus: (id: string) => `/api/bugs/${id}/status`,
|
||||
updateBugFeedback: (id: string) => `/api/bugs/${id}/feedback`,
|
||||
createLog: () => `/api/logs`,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Button,
|
||||
Code,
|
||||
Collapse,
|
||||
FileInput,
|
||||
Group,
|
||||
Image,
|
||||
Loader,
|
||||
@@ -57,11 +58,16 @@ function AppErrorsPage() {
|
||||
|
||||
|
||||
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
|
||||
const [showStackTrace, setShowStackTrace] = useState<Record<string, boolean>>({})
|
||||
|
||||
const toggleLogs = (bugId: string) => {
|
||||
setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
|
||||
}
|
||||
|
||||
const toggleStackTrace = (bugId: string) => {
|
||||
setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
|
||||
}
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['bugs', { page, search, app, status }],
|
||||
queryFn: () =>
|
||||
@@ -74,19 +80,21 @@ function AppErrorsPage() {
|
||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||
})
|
||||
|
||||
// Image Preview
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
|
||||
// Create Bug Modal Logic
|
||||
const [opened, { open, close }] = useDisclosure(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([])
|
||||
const [createForm, setCreateForm] = useState({
|
||||
description: '',
|
||||
app: appId,
|
||||
status: 'OPEN',
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
device: '',
|
||||
os: '',
|
||||
stackTrace: '',
|
||||
imageUrl: '',
|
||||
})
|
||||
|
||||
// Update Status Modal Logic
|
||||
@@ -189,10 +197,20 @@ function AppErrorsPage() {
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const imageUrls: string[] = []
|
||||
for (const file of imageFiles) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const uploadRes = await fetch(API_URLS.uploadImage(), { method: 'POST', body: formData })
|
||||
if (!uploadRes.ok) throw new Error('Gagal mengupload gambar')
|
||||
const { url } = await uploadRes.json()
|
||||
imageUrls.push(url)
|
||||
}
|
||||
|
||||
const res = await fetch(API_URLS.createBug(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(createForm),
|
||||
body: JSON.stringify({ ...createForm, imageUrls: imageUrls.length ? imageUrls : undefined }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
@@ -210,16 +228,15 @@ function AppErrorsPage() {
|
||||
})
|
||||
refetch()
|
||||
close()
|
||||
setImageFiles([])
|
||||
setCreateForm({
|
||||
description: '',
|
||||
app: 'desa_plus',
|
||||
status: 'OPEN',
|
||||
app: appId,
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
device: '',
|
||||
os: '',
|
||||
stackTrace: '',
|
||||
imageUrl: '',
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to create error report')
|
||||
@@ -256,6 +273,28 @@ function AppErrorsPage() {
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Image Preview Modal */}
|
||||
<Modal
|
||||
opened={!!previewImage}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
|
||||
styles={{ content: { background: 'transparent', boxShadow: 'none' } }}
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
{previewImage && (
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
fit="contain"
|
||||
style={{ maxHeight: '85vh', width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={updateModalOpened}
|
||||
onClose={closeUpdateModal}
|
||||
@@ -331,7 +370,7 @@ function AppErrorsPage() {
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
onClose={() => { close(); setImageFiles([]); }}
|
||||
title={<Text fw={700} size="lg">Report New Error</Text>}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
@@ -368,25 +407,13 @@ function AppErrorsPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={2}>
|
||||
<TextInput
|
||||
label="Version"
|
||||
placeholder="e.g. 2.4.1"
|
||||
required
|
||||
value={createForm.affectedVersion}
|
||||
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Initial Status"
|
||||
data={[
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
{ value: 'ON_HOLD', label: 'On Hold' },
|
||||
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||
]}
|
||||
value={createForm.status}
|
||||
onChange={(val) => setCreateForm({ ...createForm, status: val as any })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="Version"
|
||||
placeholder="e.g. 2.4.1"
|
||||
required
|
||||
value={createForm.affectedVersion}
|
||||
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={2}>
|
||||
<TextInput
|
||||
@@ -405,11 +432,22 @@ function AppErrorsPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Image URL (Optional)"
|
||||
placeholder="https://example.com/screenshot.png"
|
||||
value={createForm.imageUrl}
|
||||
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
|
||||
<FileInput
|
||||
label="Screenshot (Optional)"
|
||||
placeholder="Klik untuk upload gambar..."
|
||||
accept="image/*"
|
||||
leftSection={<TbPhoto size={16} />}
|
||||
description="Maks 3 gambar · 5MB per file · JPG, PNG, WEBP"
|
||||
value={imageFiles}
|
||||
onChange={(files) => {
|
||||
if (files.length > 3) {
|
||||
notifications.show({ title: 'Error', message: 'Maksimal 3 gambar', color: 'red' })
|
||||
return
|
||||
}
|
||||
setImageFiles(files)
|
||||
}}
|
||||
clearable
|
||||
multiple
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
@@ -525,7 +563,7 @@ function AppErrorsPage() {
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(bug.createdAt).toLocaleString()} • {bug.app?.toUpperCase()} • v{bug.affectedVersion}
|
||||
{new Date(bug.createdAt).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} • {bug.appId?.toUpperCase()} • v{bug.affectedVersion}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
@@ -578,19 +616,31 @@ function AppErrorsPage() {
|
||||
{/* Stack Trace */}
|
||||
{bug.stackTrace && (
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
|
||||
<Code
|
||||
block
|
||||
color="red"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '11px',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
{bug.stackTrace}
|
||||
</Code>
|
||||
<Group justify="space-between" mb={4}>
|
||||
<Text size="xs" fw={700} c="dimmed">STACK TRACE</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="gray"
|
||||
onClick={() => toggleStackTrace(bug.id)}
|
||||
>
|
||||
{showStackTrace[bug.id] ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</Group>
|
||||
<Collapse in={!!showStackTrace[bug.id]}>
|
||||
<Code
|
||||
block
|
||||
color="red"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '11px',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
{bug.stackTrace}
|
||||
</Code>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -603,7 +653,13 @@ function AppErrorsPage() {
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
|
||||
{bug.images.map((img: any) => (
|
||||
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
|
||||
<Paper
|
||||
key={img.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
style={{ overflow: 'hidden', cursor: 'zoom-in' }}
|
||||
onClick={() => setPreviewImage(img.imageUrl)}
|
||||
>
|
||||
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
@@ -73,7 +73,14 @@ function ActivityChart({ villageId }: { villageId: string }) {
|
||||
yearly: 'Yearly',
|
||||
}
|
||||
|
||||
const data = response?.data || []
|
||||
const rawData: any[] = Array.isArray(response?.data) ? response.data : []
|
||||
|
||||
// Normalize: map any field names from external API → { label, activity }
|
||||
const data = rawData.map((item) => {
|
||||
const label = item.label
|
||||
const activity = item.aktivitas
|
||||
return { label: String(label), activity: Number(activity) }
|
||||
})
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="xl" p="lg">
|
||||
@@ -430,7 +437,9 @@ function VillageDetailPage() {
|
||||
}}
|
||||
>
|
||||
{/* Left (3/4): Activity Chart */}
|
||||
<ActivityChart villageId={villageId} />
|
||||
<Box style={{ minWidth: 0 }}>
|
||||
<ActivityChart villageId={villageId} />
|
||||
</Box>
|
||||
|
||||
{/* Right (1/4): Informasi Sistem */}
|
||||
<Paper withBorder radius="xl" p="lg">
|
||||
@@ -444,7 +453,7 @@ function VillageDetailPage() {
|
||||
{[
|
||||
{ label: 'Date Created', value: village.createdAt },
|
||||
{ label: 'Created By', value: '-' },
|
||||
{ label: 'Last Updated', value: '-' },
|
||||
{ label: 'Last Updated', value: village.updatedAt },
|
||||
].map((item, idx, arr) => (
|
||||
<Group
|
||||
key={item.label}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
Stack,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
FileInput,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
@@ -55,10 +56,14 @@ function ListErrorsPage() {
|
||||
const [status, setStatus] = useState('all')
|
||||
|
||||
const [showLogs, setShowLogs] = useState<Record<string, boolean>>({})
|
||||
const [showStackTrace, setShowStackTrace] = useState<Record<string, boolean>>({})
|
||||
|
||||
const toggleLogs = (bugId: string) => {
|
||||
setShowLogs((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
|
||||
}
|
||||
const toggleStackTrace = (bugId: string) => {
|
||||
setShowStackTrace((prev) => ({ ...prev, [bugId]: !prev[bugId] }))
|
||||
}
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['bugs', { page, search, app, status }],
|
||||
@@ -72,19 +77,21 @@ function ListErrorsPage() {
|
||||
queryFn: () => fetch('/api/apps').then((r) => r.json()),
|
||||
})
|
||||
|
||||
// Image Preview
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(null)
|
||||
|
||||
// Create Bug Modal Logic
|
||||
const [opened, { open, close }] = useDisclosure(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [imageFiles, setImageFiles] = useState<File[]>([])
|
||||
const [createForm, setCreateForm] = useState({
|
||||
description: '',
|
||||
app: 'desa-plus',
|
||||
status: 'OPEN',
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
device: '',
|
||||
os: '',
|
||||
stackTrace: '',
|
||||
imageUrl: '',
|
||||
})
|
||||
|
||||
// Update Status Modal Logic
|
||||
@@ -187,10 +194,20 @@ function ListErrorsPage() {
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const imageUrls: string[] = []
|
||||
for (const file of imageFiles) {
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const uploadRes = await fetch(API_URLS.uploadImage(), { method: 'POST', body: formData })
|
||||
if (!uploadRes.ok) throw new Error('Gagal mengupload gambar')
|
||||
const { url } = await uploadRes.json()
|
||||
imageUrls.push(url)
|
||||
}
|
||||
|
||||
const res = await fetch(API_URLS.createBug(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(createForm),
|
||||
body: JSON.stringify({ ...createForm, imageUrls: imageUrls.length ? imageUrls : undefined }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
@@ -208,16 +225,15 @@ function ListErrorsPage() {
|
||||
})
|
||||
refetch()
|
||||
close()
|
||||
setImageFiles([])
|
||||
setCreateForm({
|
||||
description: '',
|
||||
app: 'desa-plus',
|
||||
status: 'OPEN',
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
device: '',
|
||||
os: '',
|
||||
stackTrace: '',
|
||||
imageUrl: '',
|
||||
})
|
||||
} else {
|
||||
throw new Error('Failed to create error report')
|
||||
@@ -265,6 +281,28 @@ function ListErrorsPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Image Preview Modal */}
|
||||
<Modal
|
||||
opened={!!previewImage}
|
||||
onClose={() => setPreviewImage(null)}
|
||||
size="xl"
|
||||
radius="xl"
|
||||
padding={0}
|
||||
withCloseButton={false}
|
||||
overlayProps={{ backgroundOpacity: 0.75, blur: 6 }}
|
||||
styles={{ content: { background: 'transparent', boxShadow: 'none' } }}
|
||||
onClick={() => setPreviewImage(null)}
|
||||
>
|
||||
{previewImage && (
|
||||
<Image
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
fit="contain"
|
||||
style={{ maxHeight: '85vh', width: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
opened={updateModalOpened}
|
||||
onClose={closeUpdateModal}
|
||||
@@ -340,7 +378,7 @@ function ListErrorsPage() {
|
||||
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
onClose={() => { close(); setImageFiles([]); }}
|
||||
title={<Text fw={700} size="lg">Report New Error</Text>}
|
||||
radius="xl"
|
||||
size="lg"
|
||||
@@ -377,25 +415,13 @@ function ListErrorsPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<SimpleGrid cols={2}>
|
||||
<TextInput
|
||||
label="Version"
|
||||
placeholder="e.g. 2.4.1"
|
||||
required
|
||||
value={createForm.affectedVersion}
|
||||
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
|
||||
/>
|
||||
<Select
|
||||
label="Initial Status"
|
||||
data={[
|
||||
{ value: 'OPEN', label: 'Open' },
|
||||
{ value: 'ON_HOLD', label: 'On Hold' },
|
||||
{ value: 'IN_PROGRESS', label: 'In Progress' },
|
||||
]}
|
||||
value={createForm.status}
|
||||
onChange={(val) => setCreateForm({ ...createForm, status: val as any })}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
<TextInput
|
||||
label="Version"
|
||||
placeholder="e.g. 2.4.1"
|
||||
required
|
||||
value={createForm.affectedVersion}
|
||||
onChange={(e) => setCreateForm({ ...createForm, affectedVersion: e.target.value })}
|
||||
/>
|
||||
|
||||
<SimpleGrid cols={2}>
|
||||
<TextInput
|
||||
@@ -414,11 +440,22 @@ function ListErrorsPage() {
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<TextInput
|
||||
label="Image URL (Optional)"
|
||||
placeholder="https://example.com/screenshot.png"
|
||||
value={createForm.imageUrl}
|
||||
onChange={(e) => setCreateForm({ ...createForm, imageUrl: e.target.value })}
|
||||
<FileInput
|
||||
label="Screenshot (Optional)"
|
||||
placeholder="Klik untuk upload gambar..."
|
||||
accept="image/*"
|
||||
leftSection={<TbPhoto size={16} />}
|
||||
description="Maks 3 gambar · 5MB per file · JPG, PNG, WEBP"
|
||||
value={imageFiles}
|
||||
onChange={(files) => {
|
||||
if (files.length > 3) {
|
||||
notifications.show({ title: 'Error', message: 'Maksimal 3 gambar', color: 'red' })
|
||||
return
|
||||
}
|
||||
setImageFiles(files)
|
||||
}}
|
||||
clearable
|
||||
multiple
|
||||
/>
|
||||
|
||||
<Textarea
|
||||
@@ -545,7 +582,7 @@ function ListErrorsPage() {
|
||||
</Group>
|
||||
<Group gap="md">
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(bug.createdAt).toLocaleString()} • {bug.app?.toUpperCase()} • v{bug.affectedVersion}
|
||||
{new Date(bug.createdAt).toLocaleString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false })} • {bug.appId?.toUpperCase()} • v{bug.affectedVersion}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
@@ -598,19 +635,31 @@ function ListErrorsPage() {
|
||||
{/* Stack Trace */}
|
||||
{bug.stackTrace && (
|
||||
<Box>
|
||||
<Text size="xs" fw={700} c="dimmed" mb={4}>STACK TRACE</Text>
|
||||
<Code
|
||||
block
|
||||
color="red"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '11px',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
{bug.stackTrace}
|
||||
</Code>
|
||||
<Group justify="space-between" mb={showStackTrace[bug.id] ? 8 : 0}>
|
||||
<Text size="xs" fw={700} c="dimmed">STACK TRACE</Text>
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="compact-xs"
|
||||
color="gray"
|
||||
onClick={() => toggleStackTrace(bug.id)}
|
||||
>
|
||||
{showStackTrace[bug.id] ? 'Hide' : 'Show'}
|
||||
</Button>
|
||||
</Group>
|
||||
<Collapse in={showStackTrace[bug.id]}>
|
||||
<Code
|
||||
block
|
||||
color="red"
|
||||
style={{
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap',
|
||||
fontSize: '11px',
|
||||
border: '1px solid var(--mantine-color-default-border)',
|
||||
}}
|
||||
>
|
||||
{bug.stackTrace}
|
||||
</Code>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
@@ -623,7 +672,13 @@ function ListErrorsPage() {
|
||||
</Group>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="xs">
|
||||
{bug.images.map((img: any) => (
|
||||
<Paper key={img.id} withBorder radius="md" style={{ overflow: 'hidden' }}>
|
||||
<Paper
|
||||
key={img.id}
|
||||
withBorder
|
||||
radius="md"
|
||||
style={{ overflow: 'hidden', cursor: 'zoom-in' }}
|
||||
onClick={() => setPreviewImage(img.imageUrl)}
|
||||
>
|
||||
<Image src={img.imageUrl} alt="Error screenshot" height={100} fit="cover" />
|
||||
</Paper>
|
||||
))}
|
||||
|
||||
@@ -367,9 +367,9 @@ function UsersPage() {
|
||||
))}
|
||||
</List>
|
||||
|
||||
<Button fullWidth variant="light" color={role.color} mt="md" radius="md">
|
||||
{/* <Button fullWidth variant="light" color={role.color} mt="md" radius="md">
|
||||
Edit Permissions
|
||||
</Button>
|
||||
</Button> */}
|
||||
</Stack>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
@@ -84,7 +84,7 @@ body {
|
||||
transition: var(--transition-smooth);
|
||||
}
|
||||
|
||||
.sidebar-nav-item.active {
|
||||
.sidebar-nav-item[data-active] {
|
||||
background: var(--gradient-blue-purple);
|
||||
color: white;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { env } from './lib/env'
|
||||
const isProduction = env.NODE_ENV === 'production'
|
||||
|
||||
// ─── Route Classification ──────────────────────────────
|
||||
const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health']
|
||||
const API_PREFIXES = ['/api/', '/webhook/', '/ws/', '/health', '/docs']
|
||||
|
||||
function isApiRoute(pathname: string): boolean {
|
||||
return API_PREFIXES.some((p) => pathname.startsWith(p)) || pathname === '/health'
|
||||
|
||||
@@ -16,4 +16,12 @@ export const env = {
|
||||
GOOGLE_CLIENT_ID: required('GOOGLE_CLIENT_ID'),
|
||||
GOOGLE_CLIENT_SECRET: required('GOOGLE_CLIENT_SECRET'),
|
||||
SUPER_ADMIN_EMAILS: optional('SUPER_ADMIN_EMAIL', '').split(',').map(e => e.trim()).filter(Boolean),
|
||||
API_KEY: required('API_KEY'),
|
||||
MINIO_ENDPOINT: required('MINIO_ENDPOINT'),
|
||||
MINIO_PORT: parseInt(optional('MINIO_PORT', '443'), 10),
|
||||
MINIO_USE_SSL: optional('MINIO_USE_SSL', 'true') === 'true',
|
||||
MINIO_ACCESS_KEY: required('MINIO_ACCESS_KEY'),
|
||||
MINIO_SECRET_KEY: required('MINIO_SECRET_KEY'),
|
||||
MINIO_BUCKET: required('MINIO_BUCKET'),
|
||||
MINIO_UPLOAD_DIR: optional('MINIO_UPLOAD_DIR', 'bug-reports'),
|
||||
} as const
|
||||
|
||||
36
src/lib/minio.ts
Normal file
36
src/lib/minio.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Client } from 'minio'
|
||||
import { env } from './env'
|
||||
|
||||
const client = new Client({
|
||||
endPoint: env.MINIO_ENDPOINT,
|
||||
port: env.MINIO_PORT,
|
||||
useSSL: env.MINIO_USE_SSL,
|
||||
accessKey: env.MINIO_ACCESS_KEY,
|
||||
secretKey: env.MINIO_SECRET_KEY,
|
||||
})
|
||||
|
||||
// Auto-create bucket if it doesn't exist
|
||||
client.bucketExists(env.MINIO_BUCKET).then(async (exists) => {
|
||||
if (!exists) {
|
||||
await client.makeBucket(env.MINIO_BUCKET)
|
||||
console.log(`[MinIO] Bucket "${env.MINIO_BUCKET}" created.`)
|
||||
}
|
||||
}).catch((err) => {
|
||||
console.error('[MinIO] Failed to check/create bucket:', err.message)
|
||||
})
|
||||
|
||||
export async function uploadBugImage(file: File): Promise<string> {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase() ?? 'bin'
|
||||
const objectName = `${env.MINIO_UPLOAD_DIR}/${crypto.randomUUID()}.${ext}`
|
||||
const buffer = Buffer.from(await file.arrayBuffer())
|
||||
|
||||
await client.putObject(env.MINIO_BUCKET, objectName, buffer, file.size, {
|
||||
'Content-Type': file.type,
|
||||
})
|
||||
|
||||
return objectName // e.g. bug-reports/uuid.jpg
|
||||
}
|
||||
|
||||
export async function getMinioDownloadUrl(objectName: string): Promise<string> {
|
||||
return client.presignedGetObject(env.MINIO_BUCKET, objectName, 3600)
|
||||
}
|
||||
252
src/lib/seafile.ts
Normal file
252
src/lib/seafile.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
#!/usr/bin/env bun
|
||||
import { promises as fs } from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
// --- Constants ---
|
||||
const CONFIG_FILE = path.join(os.homedir(), '.note.conf');
|
||||
|
||||
// --- Types ---
|
||||
interface Config {
|
||||
TOKEN?: string;
|
||||
REPO?: string;
|
||||
URL?: string;
|
||||
}
|
||||
|
||||
export const defaultConfigSF: Config = {
|
||||
TOKEN: process.env.SF_TOKEN,
|
||||
REPO: process.env.SF_REPO,
|
||||
URL: process.env.SF_URL,
|
||||
}
|
||||
|
||||
export async function loadConfig(): Promise<Config> {
|
||||
if (!(await fs.stat(CONFIG_FILE)).isFile()) {
|
||||
console.error(`⚠️ Config file not found at ${CONFIG_FILE}`);
|
||||
console.error('Run: bun note.ts config to create/edit it.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const configContent = await fs.readFile(CONFIG_FILE, 'utf8');
|
||||
const config: Config = {};
|
||||
|
||||
configContent.split('\n').forEach((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) return;
|
||||
|
||||
const [key, ...valueParts] = trimmed.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
let value = valueParts.join('=').trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
config[key as keyof Config] = value;
|
||||
}
|
||||
});
|
||||
|
||||
if (!config.TOKEN || !config.REPO || !config.URL) {
|
||||
console.error(`❌ Config invalid. Please set TOKEN, REPO, and URL inside ${CONFIG_FILE}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// --- HTTP Helpers ---
|
||||
export async function fetchWithAuth(config: Config, url: string, options: RequestInit = {}): Promise<Response> {
|
||||
const headers = {
|
||||
Authorization: `Token ${config.TOKEN}`,
|
||||
...options.headers,
|
||||
};
|
||||
|
||||
const response = await fetch(url, { ...options, headers });
|
||||
if (!response.ok) {
|
||||
console.error(`❌ Request failed: ${response.status} ${response.statusText}`);
|
||||
console.error(`🔍 URL: ${url}`);
|
||||
console.error(`🔍 Headers:`, headers);
|
||||
|
||||
try {
|
||||
const errorText = await response.text();
|
||||
console.error(`🔍 Response body: ${errorText}`);
|
||||
} catch {
|
||||
console.error('🔍 Could not read response body');
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
// --- Commands ---
|
||||
export async function testConnection(config: Config): Promise<string> {
|
||||
try {
|
||||
const response = await fetchWithAuth(config, `${config.URL}/ping/`);
|
||||
return `✅ API connection successful: ${await response.text()}`
|
||||
} catch {
|
||||
// return '⚠️ API ping failed, trying repo access...'
|
||||
try {
|
||||
await fetchWithAuth(config, `${config.URL}/${config.REPO}/`);
|
||||
return `✅ Repo access successful`
|
||||
} catch {
|
||||
return '❌ Both API ping and repo access failed'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function listFiles(config: Config): Promise<{ name: string }[]> {
|
||||
const url = `${config.URL}/${config.REPO}/dir/?p=/`;
|
||||
const response = await fetchWithAuth(config, url);
|
||||
|
||||
try {
|
||||
const files = (await response.json()) as { name: string }[];
|
||||
return files
|
||||
} catch {
|
||||
console.error('❌ Failed to parse response');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
export async function catFile(config: Config, folder: string, fileName: string): Promise<ArrayBuffer> {
|
||||
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`);
|
||||
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
|
||||
|
||||
// Download file sebagai binary, BUKAN text
|
||||
const fileResponse = await fetchWithAuth(config, downloadUrl);
|
||||
const buffer = await fileResponse.arrayBuffer();
|
||||
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export async function uploadFile(config: Config, file: File, folder: string): Promise<string> {
|
||||
const remoteName = path.basename(file.name);
|
||||
|
||||
// 1. Dapatkan upload link (pakai Authorization)
|
||||
const uploadUrlResponse = await fetchWithAuth(
|
||||
config,
|
||||
`${config.URL}/${config.REPO}/upload-link/`
|
||||
);
|
||||
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
|
||||
|
||||
// 2. Siapkan form-data
|
||||
const formData = new FormData();
|
||||
formData.append("parent_dir", "/");
|
||||
formData.append("relative_path", folder); // tanpa slash di akhir
|
||||
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
|
||||
|
||||
// 3. Upload file TANPA Authorization header, token di query param
|
||||
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) return 'gagal'
|
||||
return `✅ Uploaded ${file.name} successfully`;
|
||||
}
|
||||
|
||||
export async function uploadFileBase64(config: Config, base64File: { name: string; data: string; }): Promise<string> {
|
||||
const remoteName = path.basename(base64File.name);
|
||||
|
||||
// 1. Dapatkan upload link (pakai Authorization)
|
||||
const uploadUrlResponse = await fetchWithAuth(
|
||||
config,
|
||||
`${config.URL}/${config.REPO}/upload-link/`
|
||||
);
|
||||
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
|
||||
|
||||
// 2. Konversi base64 ke Blob
|
||||
const binary = Buffer.from(base64File.data, "base64");
|
||||
const blob = new Blob([binary]);
|
||||
|
||||
// 3. Siapkan form-data
|
||||
const formData = new FormData();
|
||||
formData.append("parent_dir", "/");
|
||||
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir
|
||||
formData.append("file", blob, remoteName);
|
||||
|
||||
// 4. Upload file TANPA Authorization header, token di query param
|
||||
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) throw new Error(`Upload failed: ${text}`);
|
||||
return `✅ Uploaded ${base64File.name} successfully`;
|
||||
}
|
||||
|
||||
export async function uploadFileToFolder(config: Config, base64File: { name: string; data: string; }, folder: 'syarat-dokumen' | 'pengaduan'): Promise<string> {
|
||||
const remoteName = path.basename(base64File.name);
|
||||
|
||||
// 1. Dapatkan upload link (pakai Authorization)
|
||||
const uploadUrlResponse = await fetchWithAuth(
|
||||
config,
|
||||
`${config.URL}/${config.REPO}/upload-link/`
|
||||
);
|
||||
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
|
||||
|
||||
// 2. Konversi base64 ke Blob
|
||||
const binary = Buffer.from(base64File.data, "base64");
|
||||
const blob = new Blob([binary]);
|
||||
|
||||
// 3. Siapkan form-data
|
||||
const formData = new FormData();
|
||||
formData.append("parent_dir", "/");
|
||||
formData.append("relative_path", folder); // tanpa slash di akhir
|
||||
formData.append("file", blob, remoteName);
|
||||
|
||||
// 4. Upload file TANPA Authorization header, token di query param
|
||||
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
|
||||
if (!res.ok) throw new Error(`Upload failed: ${text}`);
|
||||
return `✅ Uploaded ${base64File.name} successfully`;
|
||||
}
|
||||
|
||||
|
||||
export async function removeFile(config: Config, fileName: string, folder: string): Promise<string> {
|
||||
const res = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${folder}/${fileName}`, { method: 'DELETE' });
|
||||
|
||||
if (!res.ok) return 'gagal menghapus file';
|
||||
return `🗑️ Removed ${fileName}`
|
||||
}
|
||||
|
||||
export async function moveFile(config: Config, oldName: string, newName: string): Promise<string> {
|
||||
const url = `${config.URL}/${config.REPO}/file/?p=/${oldName}`;
|
||||
const formData = new FormData();
|
||||
formData.append('operation', 'rename');
|
||||
formData.append('newname', newName);
|
||||
|
||||
await fetchWithAuth(config, url, { method: 'POST', body: formData });
|
||||
return `✏️ Renamed ${oldName} → ${newName}`
|
||||
}
|
||||
|
||||
export async function downloadFile(config: Config, fileName: string, folder: string, localFile?: string): Promise<string> {
|
||||
const localName = localFile || fileName;
|
||||
// 🔹 gabungkan path folder + file
|
||||
const filePath = `/${folder}/${fileName}`.replace(/\/+/g, "/");
|
||||
|
||||
// 🔹 encode path agar aman (spasi, dll)
|
||||
const params = new URLSearchParams({
|
||||
p: filePath,
|
||||
});
|
||||
|
||||
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?${params.toString()}`);
|
||||
if (!downloadUrlResponse.ok)
|
||||
return 'gagal'
|
||||
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
|
||||
const buffer = Buffer.from(await (await fetchWithAuth(config, downloadUrl)).arrayBuffer());
|
||||
await fs.writeFile(localName, buffer);
|
||||
return `⬇️ Downloaded ${fileName} → ${localName}`
|
||||
}
|
||||
|
||||
export async function getFileLink(config: Config, fileName: string): Promise<string> {
|
||||
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`);
|
||||
return `🔗 Link for ${fileName}:\n${(await downloadUrlResponse.text()).replace(/"/g, '')}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user