upd: swagger docs, api key auth, bug fixes
- tambah Elysia Swagger di /docs dengan deskripsi lengkap semua endpoint - tambah API key auth (X-API-Key) untuk klien eksternal di POST /api/bugs - tambah normalisasi BugSource: SYSTEM/USER untuk eksternal, QC/SYSTEM/USER untuk dashboard - perbaiki source schema jadi optional string agar tidak reject nilai unknown dari klien lama - hapus field status dari form create bug (selalu OPEN) - perbaiki typo desa_plus → appId di apps.$appId.errors.tsx - tambah toggle hide/show stack trace di bug-reports.tsx dan apps.$appId.errors.tsx - perbaiki grafik desa (width(-1)/height(-1)) dengan minWidth: 0 pada grid item - perbaiki error &[data-active] inline style di DashboardLayout → pindah ke CSS class - update CLAUDE.md dengan arsitektur lengkap Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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=
|
||||
|
||||
157
CLAUDE.md
157
CLAUDE.md
@@ -1,82 +1,121 @@
|
||||
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
|
||||
### Server
|
||||
|
||||
React 19 + Vite 8 (middleware mode in dev). File-based routing with TanStack Router.
|
||||
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).
|
||||
|
||||
- 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
|
||||
### Database
|
||||
|
||||
## Dev Tools
|
||||
PostgreSQL via Prisma v6. Client generated to `./generated/prisma` (gitignored — run `bun run db:generate` after checkout or schema changes).
|
||||
|
||||
- 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`.
|
||||
**Schema models:** `User`, `Session`, `App`, `Log`, `Bug`, `BugImage`, `BugLog`
|
||||
|
||||
## Playwright MCP
|
||||
**Enums:** `Role` (ADMIN, DEVELOPER), `BugStatus` (OPEN, ON_HOLD, IN_PROGRESS, RESOLVED, RELEASED, CLOSED), `BugSource` (QC, SYSTEM, USER), `LogType` (CREATE, UPDATE, DELETE, LOGIN, LOGOUT)
|
||||
|
||||
Playwright MCP server enables AI-assisted browser automation for testing and debugging.
|
||||
Import the singleton: `import { prisma } from './lib/db'`
|
||||
|
||||
- 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
|
||||
### 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.
|
||||
|
||||
## Testing
|
||||
|
||||
Tests use `bun:test`. Three levels:
|
||||
- Unit tests: env, DB connection, bcrypt — in `tests/unit/`
|
||||
- Integration tests: `createApp().handle(new Request(...))` — no running server needed, use these for mutations
|
||||
- E2E tests: 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()`
|
||||
|
||||
```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
|
||||
- **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`).
|
||||
|
||||
74
bun.lock
74
bun.lock
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "bun-react-template",
|
||||
@@ -8,9 +7,11 @@
|
||||
"@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",
|
||||
@@ -26,11 +27,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 +109,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 +199,18 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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 +261,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 +335,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=="],
|
||||
@@ -443,6 +465,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=="],
|
||||
@@ -495,7 +519,7 @@
|
||||
|
||||
"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=="],
|
||||
"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 +539,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=="],
|
||||
@@ -571,6 +597,8 @@
|
||||
|
||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||
|
||||
"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=="],
|
||||
@@ -591,6 +619,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 +631,7 @@
|
||||
|
||||
"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=="],
|
||||
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
@@ -613,6 +643,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 +667,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=="],
|
||||
@@ -665,6 +701,8 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
@@ -789,6 +827,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 +839,58 @@
|
||||
|
||||
"@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=="],
|
||||
|
||||
"@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=="],
|
||||
|
||||
@@ -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",
|
||||
|
||||
374
src/app.ts
374
src/app.ts
@@ -1,12 +1,56 @@
|
||||
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'
|
||||
|
||||
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 +69,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 +96,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 +120,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 +142,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 +160,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 +181,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 +214,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 +249,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 +298,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 +380,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 +410,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 +427,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 +448,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 +471,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 +486,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 +532,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 +590,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 +619,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,
|
||||
userId: reporterUserId,
|
||||
images: body.imageUrl ? {
|
||||
create: {
|
||||
imageUrl: body.imageUrl
|
||||
}
|
||||
create: { imageUrl: body.imageUrl }
|
||||
} : undefined,
|
||||
logs: {
|
||||
create: {
|
||||
userId: actingUserId,
|
||||
status: body.status || 'OPEN',
|
||||
status: 'OPEN',
|
||||
description: 'Bug reported initially.',
|
||||
},
|
||||
},
|
||||
@@ -458,9 +638,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',
|
||||
})),
|
||||
imageUrl: t.Optional(t.String({ description: 'URL gambar screenshot bug (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 +670,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 +683,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 +709,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 +731,29 @@ 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'],
|
||||
},
|
||||
})
|
||||
|
||||
// ─── System Status API ─────────────────────────────
|
||||
@@ -549,18 +778,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,
|
||||
},
|
||||
},
|
||||
})}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -80,7 +80,6 @@ function AppErrorsPage() {
|
||||
const [createForm, setCreateForm] = useState({
|
||||
description: '',
|
||||
app: appId,
|
||||
status: 'OPEN',
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
device: '',
|
||||
@@ -212,8 +211,7 @@ function AppErrorsPage() {
|
||||
close()
|
||||
setCreateForm({
|
||||
description: '',
|
||||
app: 'desa_plus',
|
||||
status: 'OPEN',
|
||||
app: appId,
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
device: '',
|
||||
@@ -368,25 +366,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
|
||||
@@ -525,7 +511,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()} • {bug.appId?.toUpperCase()} • v{bug.affectedVersion}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -55,10 +55,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 }],
|
||||
@@ -78,7 +82,6 @@ function ListErrorsPage() {
|
||||
const [createForm, setCreateForm] = useState({
|
||||
description: '',
|
||||
app: 'desa-plus',
|
||||
status: 'OPEN',
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
device: '',
|
||||
@@ -211,7 +214,6 @@ function ListErrorsPage() {
|
||||
setCreateForm({
|
||||
description: '',
|
||||
app: 'desa-plus',
|
||||
status: 'OPEN',
|
||||
source: 'USER',
|
||||
affectedVersion: '',
|
||||
device: '',
|
||||
@@ -377,25 +379,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
|
||||
@@ -545,7 +535,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()} • {bug.appId?.toUpperCase()} • v{bug.affectedVersion}
|
||||
</Text>
|
||||
</Group>
|
||||
</Box>
|
||||
@@ -598,19 +588,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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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,5 @@ 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'),
|
||||
} as const
|
||||
|
||||
Reference in New Issue
Block a user