diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..a0f0128f6d8c73d1521d84eecf6e39a831efc95a --- /dev/null +++ b/.gitignore @@ -0,0 +1,35 @@ +# Dependency directories +node_modules/ + +# Optional npm cache directory +.npm/ + +# Output directory +dist/ + +# Local env files +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# Log files +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* +trace.txt + +# Editor / IDE configuration folders +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.swp +*~ + +# TypeScript build info cache +*.tsbuildinfo diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..6c0f47814477ed3587d18fbd68639b0541813a34 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM node:18-alpine + +WORKDIR /app + +# Copy package files and install dependencies +COPY package*.json tsconfig.json ./ +RUN npm ci + +# Copy project files +COPY . . + +# Generate Prisma Client +RUN npx prisma generate + +# Build TypeScript to JavaScript +RUN npm run build + +# Expose port 7860 (required by Hugging Face) +EXPOSE 7860 + +# Override PORT environment variable +ENV PORT=7860 + +# Run the backend server +CMD ["npm", "start"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..85c6d2bfd1c3fd6b3b7132184ade6b62d3881ddf --- /dev/null +++ b/README.md @@ -0,0 +1,181 @@ +# Tokiva POS Backend (Node.js & TypeScript Edition) + +Backend ini dibangun menggunakan **Node.js**, **Express**, **TypeScript**, ORM **Prisma**, dan database **MySQL**. Aplikasi ini dirancang untuk mendukung operasional sistem POS (Point of Sales) ritel modern, lengkap dengan fitur manajemen stok berbasis FEFO (First Expired First Out), kasir shift, audit logging, dan integrasi penyimpanan cloud (Supabase Storage). + +--- + +## ๐Ÿ› ๏ธ Persyaratan System (Prerequisites) + +- Node.js (v18 atau lebih tinggi) +- npm atau yarn +- Database MySQL + +--- + +## ๐Ÿš€ Cara Menjalankan secara Lokal + +### 1. Install Dependensi +Masuk ke direktori `backend-node` dan jalankan instalasi: +```bash +cd backend-node +npm install +``` + +### 2. Setup Environment Variable +Buat file `.env` di dalam folder root `backend-node` dan isi sesuai dengan kebutuhan Anda: +```env +PORT=5000 +DATABASE_URL="mysql://username:password@127.0.0.1:3306/tokiva" + +# JWT Configuration +JWT_SECRET_KEY="rahasia_super_kuat_anda" +JWT_EXPIRES_IN="24h" + +# Supabase Storage Configuration (Opsional, untuk upload gambar produk/member) +SUPABASE_URL="https://your-supabase-url.supabase.co" +SUPABASE_ANON_KEY="your-anon-key" +SUPABASE_SERVICE_KEY="your-service-role-key" +``` + +### 3. Sinkronisasi Skema Database & Generate Prisma Client +Jalankan perintah berikut untuk mensinkronkan skema database lokal Anda dengan Prisma Client: +```bash +npx prisma generate +``` + +### 4. Jalankan Server Development +Jalankan server dalam mode development (menggunakan `ts-node-dev` untuk auto-reload): +```bash +npm run dev +``` +Server akan berjalan di: **`http://localhost:5000`** + +--- + +## ๐Ÿ“‚ Struktur Direktori Proyek + +```text +backend-node/ +โ”œโ”€โ”€ prisma/ # Skema database Prisma +โ”‚ โ””โ”€โ”€ schema.prisma +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ controllers/ # Menangani HTTP request dan format response +โ”‚ โ”œโ”€โ”€ middlewares/ # Middleware Express (Auth, Zod Validation, Error handler) +โ”‚ โ”œโ”€โ”€ routes/ # Defini endpoint API Express +โ”‚ โ”œโ”€โ”€ schemas/ # Validator skema request input menggunakan Zod +โ”‚ โ”œโ”€โ”€ services/ # Logika bisnis inti aplikasi +โ”‚ โ”œโ”€โ”€ utils/ # Helper (Supabase, JWT, response formatter, audit logger) +โ”‚ โ”œโ”€โ”€ app.ts # Setup Express application +โ”‚ โ””โ”€โ”€ server.ts # Entrypoint API server +โ”œโ”€โ”€ .env # File konfigurasi environment +โ”œโ”€โ”€ package.json # File dependensi proyek +โ””โ”€โ”€ tsconfig.json # Konfigurasi TypeScript compiler +``` + +--- + +## ๐Ÿ“˜ Referensi Endpoint API + +Seluruh endpoint API memiliki prefix `/api`. +Endpoint yang membutuhkan header **Authorization** harus melampirkan token JWT: `Bearer `. + +### 1. Autentikasi (`/api/auth`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `POST` | `/auth/login` | Publik | Login pengguna menggunakan username dan password | +| `POST` | `/auth/refresh` | Publik | Melakukan refresh JWT token | + +### 2. Manajemen Pengguna / Karyawan (`/api/users`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `GET` | `/users` | Admin | Memuat daftar semua pengguna | +| `POST` | `/users` | Admin | Membuat akun pengguna baru | +| `PUT` | `/users/:id` | Admin | Mengedit informasi akun pengguna | +| `DELETE` | `/users/:id` | Admin | Menghapus akun pengguna (Soft-Delete) | + +### 3. Kategori Produk (`/api/kategori`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `GET` | `/kategori` | Token | Memuat seluruh kategori produk | +| `POST` | `/kategori` | Token | Menambah kategori baru | +| `PUT` | `/kategori/:id` | Token | Mengubah kategori | +| `DELETE` | `/kategori/:id` | Token | Menghapus kategori | + +### 4. Supplier / Pemasok (`/api/supplier`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `GET` | `/supplier` | Token | Memuat seluruh supplier aktif | +| `POST` | `/supplier` | Token | Menambah supplier baru | +| `PUT` | `/supplier/:id` | Token | Mengubah data supplier | +| `DELETE` | `/supplier/:id` | Token | Menghapus supplier | + +### 5. Member / Pelanggan Loyalitas (`/api/member`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `GET` | `/member` | Token | Memuat daftar member | +| `POST` | `/member` | Token | Mendaftarkan member baru | +| `PUT` | `/member/:id` | Token | Memperbarui profil member | +| `DELETE` | `/member/:id` | Token | Menghapus member | + +### 6. Produk (`/api/produk`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `GET` | `/produk` | Token | Memuat daftar produk (mendukung pencarian & filter) | +| `GET` | `/produk/:id` | Token | Memuat detail lengkap satu produk | +| `GET` | `/produk/scan/:barcode_or_kode` | Token | Mencari produk berdasarkan barcode/kode untuk scan cepat | +| `POST` | `/produk` | Token | Menambah produk baru | +| `PUT` | `/produk/:id` | Token | Memperbarui produk | +| `DELETE` | `/produk/:id` | Token | Menghapus produk (Soft-Delete) | + +### 7. Manajemen Stok & Batch FEFO (`/api/stok`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `POST` | `/stok/masuk` | Token | Mencatat barang masuk/restock ke batch tertentu | +| `POST` | `/stok/opname` | Token | Membuat draf stok opname (penyesuaian fisik) | +| `POST` | `/stok/opname/:opname_id/approve` | Token | Menyetujui opname dan menyesuaikan stok utama produk | +| `GET` | `/stok/mutasi` | Token | Memuat kartu stok / riwayat mutasi stok produk | +| `GET` | `/stok/batches` | Token | Memuat batch stok produk yang masih aktif (qty sisa > 0) | + +### 8. Transaksi Penjualan (`/api/transaksi`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `POST` | `/transaksi` | Token | Mencatat transaksi kasir baru (FEFO auto-deduction) | +| `GET` | `/transaksi` | Token | Memuat riwayat transaksi penjualan | +| `POST` | `/transaksi/retur` | Token | Melakukan retur barang dari suatu transaksi (refund/exchange) | + +### 9. Bon & Piutang Member (`/api/bon`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `GET` | `/bon` | Token | Memuat semua daftar bon piutang member | +| `POST` | `/bon/:bon_id/cicil` | Token | Mencatat pembayaran cicilan bon member | + +### 10. Laporan Finansial & Operasional (`/api`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `GET` | `/laporan/ringkasan` | Admin | Memuat ringkasan keuangan (pendapatan, laba kotor, dll) | +| `GET` | `/laporan/penjualan` | Admin | Laporan detail penjualan berdasarkan rentang tanggal | +| `GET` | `/laporan/stok` | Admin | Laporan analisis stok produk saat ini | + +### 11. Manajemen Shift Kasir (`/api`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `POST` | `/shift/buka` | Token | Membuka shift kasir baru dan mencatat modal awal | +| `POST` | `/shift/tutup` | Token | Menutup shift kasir, mencatat total fisik uang, & mendeteksi selisih | +| `GET` | `/shift/aktif` | Token | Mendapatkan informasi shift yang sedang berjalan | + +### 12. Upload File Media (`/api/upload`) +| Method | Endpoint | Auth | Deskripsi | +|---|---|---|---| +| `POST` | `/upload/produk` | Token | Upload gambar produk ke Supabase Storage (Base64/Form-Data) | +| `POST` | `/upload/member` | Token | Upload foto member ke Supabase Storage | + +--- + +## ๐Ÿงช Jalankan Tes Integrasi CRUD + +Untuk memverifikasi fungsionalitas CRUD secara end-to-end terhadap database MySQL lokal Anda: +1. Pastikan server backend sedang berjalan (`npm run dev`). +2. Jalankan perintah script uji coba: + ```bash + node scratch/test-crud.js + ``` diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..f8bd76498cb78cf2fe8ec5fba16d5f054767bd12 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1833 @@ +{ + "name": "tokiva-pos-backend-node", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "tokiva-pos-backend-node", + "version": "1.0.0", + "dependencies": { + "@prisma/client": "^5.19.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "jsonwebtoken": "^9.0.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.5.0", + "prisma": "^5.19.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.5.4" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@prisma/client": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-5.22.0.tgz", + "integrity": "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA==", + "hasInstallScript": true, + "engines": { + "node": ">=16.13" + }, + "peerDependencies": { + "prisma": "*" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + } + } + }, + "node_modules/@prisma/debug": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-5.22.0.tgz", + "integrity": "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ==", + "devOptional": true + }, + "node_modules/@prisma/engines": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-5.22.0.tgz", + "integrity": "sha512-UNjfslWhAt06kVL3CjkuYpHAWSO6L4kDCVPegV6itt7nD1kSJavd3vhgAEhjglLJJKEdJ7oIqDJ+yHk6qO8gPA==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/fetch-engine": "5.22.0", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/engines-version": { + "version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2.tgz", + "integrity": "sha512-2PTmxFR2yHW/eB3uqWtcgRcgAbG1rwG9ZriSvQw+nnb7c4uCr3RAcGMb6/zfE88SKlC1Nj2ziUvc96Z379mHgQ==", + "devOptional": true + }, + "node_modules/@prisma/fetch-engine": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-5.22.0.tgz", + "integrity": "sha512-bkrD/Mc2fSvkQBV5EpoFcZ87AvOgDxbG99488a5cexp5Ccny+UM6MAe/UFkUC0wLYD9+9befNOqGiIJhhq+HbA==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "5.22.0", + "@prisma/engines-version": "5.22.0-44.605197351a3c8bdd595af2d2a9bc3025bca48ea2", + "@prisma/get-platform": "5.22.0" + } + }, + "node_modules/@prisma/get-platform": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-5.22.0.tgz", + "integrity": "sha512-pHhpQdr1UPFpt+zFfnPazhulaZYCUqeIcPpJViYoq9R+D/yw4fjE+CtnsnKzPYm0ddUbeXUzjGVGIRVgPDCk4Q==", + "devOptional": true, + "dependencies": { + "@prisma/debug": "5.22.0" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true + }, + "node_modules/@types/jsonwebtoken": { + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/@types/jsonwebtoken/-/jsonwebtoken-9.0.10.tgz", + "integrity": "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA==", + "dev": true, + "dependencies": { + "@types/ms": "*", + "@types/node": "*" + } + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "dev": true + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/@types/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-xevGOReSYGM7g/kUBZzPqCrR/KYAo+F0yiPc85WFTJa0MSLtyFTVTU6cJu/aV4mid7IffDIWqo69THF2o4JiEQ==", + "dev": true + }, + "node_modules/@types/strip-json-comments": { + "version": "0.0.30", + "resolved": "https://registry.npmjs.org/@types/strip-json-comments/-/strip-json-comments-0.0.30.tgz", + "integrity": "sha512-7NQmHra/JILCd1QqpSzl8+mJRc8ZHz3uDm8YV1Ks9IhK0epEiTw8aIErbvH9PI+6XbqhyIQy3462nEsn7UVzjQ==", + "dev": true + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==" + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz", + "integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==" + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/dynamic-dedupe": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/dynamic-dedupe/-/dynamic-dedupe-0.3.0.tgz", + "integrity": "sha512-ssuANeD+z97meYOqd50e04Ze5qp4bPqo8cCkI4TRjZkzAUgIDTrXV1R8QCdINpiI+hw14+rYazvTRdQrz0/rFQ==", + "dev": true, + "dependencies": { + "xtend": "^4.0.0" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/jsonwebtoken": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz", + "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==", + "dependencies": { + "jws": "^4.0.1", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1", + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12", + "npm": ">=6" + } + }, + "node_modules/jsonwebtoken/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==" + }, + "node_modules/lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==" + }, + "node_modules/lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==" + }, + "node_modules/lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==" + }, + "node_modules/lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==" + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "dev": true, + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/prisma": { + "version": "5.22.0", + "resolved": "https://registry.npmjs.org/prisma/-/prisma-5.22.0.tgz", + "integrity": "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A==", + "devOptional": true, + "hasInstallScript": true, + "dependencies": { + "@prisma/engines": "5.22.0" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=16.13" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tree-kill": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz", + "integrity": "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==", + "dev": true, + "bin": { + "tree-kill": "cli.js" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/ts-node-dev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-node-dev/-/ts-node-dev-2.0.0.tgz", + "integrity": "sha512-ywMrhCfH6M75yftYvrvNarLEY+SUXtUvU8/0Z6llrHQVBx12GiFk5sStF8UdfE/yfzk9IAq7O5EEbTQsxlBI8w==", + "dev": true, + "dependencies": { + "chokidar": "^3.5.1", + "dynamic-dedupe": "^0.3.0", + "minimist": "^1.2.6", + "mkdirp": "^1.0.4", + "resolve": "^1.0.0", + "rimraf": "^2.6.1", + "source-map-support": "^0.5.12", + "tree-kill": "^1.2.2", + "ts-node": "^10.4.0", + "tsconfig": "^7.0.0" + }, + "bin": { + "ts-node-dev": "lib/bin.js", + "tsnd": "lib/bin.js" + }, + "engines": { + "node": ">=0.8.0" + }, + "peerDependencies": { + "node-notifier": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } + } + }, + "node_modules/tsconfig": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz", + "integrity": "sha512-vZXmzPrL+EmC4T/4rVlT2jNVMWCi/O4DIiSj3UHg1OE5kCKbk4mfrXc6dZksLgRM/TZlKnousKH9bbTazUWRRw==", + "dev": true, + "dependencies": { + "@types/strip-bom": "^3.0.0", + "@types/strip-json-comments": "0.0.30", + "strip-bom": "^3.0.0", + "strip-json-comments": "^2.0.0" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..720c62b33216fc40f450af62250bdc016d2441cd --- /dev/null +++ b/package.json @@ -0,0 +1,32 @@ +{ + "name": "tokiva-pos-backend-node", + "version": "1.0.0", + "description": "Node.js Backend for Tokiva POS using Express and TypeScript", + "main": "dist/server.js", + "scripts": { + "dev": "ts-node-dev --respawn --transpile-only src/server.ts", + "build": "tsc", + "start": "node dist/server.js", + "prisma:pull": "npx prisma db pull", + "prisma:generate": "npx prisma generate" + }, + "dependencies": { + "@prisma/client": "^5.19.0", + "bcryptjs": "^2.4.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", + "jsonwebtoken": "^9.0.2", + "zod": "^3.23.8" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/cors": "^2.8.17", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.7", + "@types/node": "^22.5.0", + "prisma": "^5.19.0", + "ts-node-dev": "^2.0.0", + "typescript": "^5.5.4" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000000000000000000000000000000000000..161f2dbb7bfff4dae3c1b36af0bf878b363406be --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,538 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "mysql" + url = env("DATABASE_URL") +} + +model alembic_version { + version_num String @id @db.VarChar(32) +} + +model audit_log { + id Int @id @default(autoincrement()) + user_id Int? + aksi String @db.VarChar(50) + entitas String @db.VarChar(50) + entitas_id Int? + data_lama Json? + data_baru Json? + ip_address String? @db.VarChar(45) + user_agent String? @db.VarChar(255) + created_at DateTime @default(now()) @db.DateTime(0) + user user? @relation(fields: [user_id], references: [id], onUpdate: NoAction, map: "audit_log_ibfk_1") + + @@index([created_at], map: "idx_audit_created") + @@index([entitas, entitas_id], map: "idx_audit_entitas_id") + @@index([user_id], map: "idx_audit_user") +} + +model bon { + id Int @id @default(autoincrement()) + transaksi_id Int + member_id Int + total_bon Decimal @db.Decimal(15, 2) + sisa_bon Decimal @db.Decimal(15, 2) + jatuh_tempo DateTime? @db.Date + status bon_status @default(aktif) + reminder_sent Int @default(0) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + updated_at DateTime @default(now()) @db.DateTime(0) + member member @relation(fields: [member_id], references: [id], onUpdate: NoAction, map: "bon_ibfk_1") + transaksi transaksi @relation(fields: [transaksi_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "bon_ibfk_2") + bon_cicilan bon_cicilan[] + + @@index([jatuh_tempo], map: "idx_bon_jatuh_tempo") + @@index([member_id], map: "idx_bon_member") + @@index([status], map: "idx_bon_status") + @@index([transaksi_id], map: "idx_bon_transaksi") +} + +model bon_cicilan { + id Int @id @default(autoincrement()) + bon_id Int + nominal Decimal @db.Decimal(15, 2) + metode bon_cicilan_metode + user_id Int + created_at DateTime @default(now()) @db.DateTime(0) + bon bon @relation(fields: [bon_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "bon_cicilan_ibfk_1") + user user @relation(fields: [user_id], references: [id], onUpdate: NoAction, map: "bon_cicilan_ibfk_2") + + @@index([bon_id], map: "idx_cicilan_bon") + @@index([user_id], map: "idx_cicilan_user") +} + +model diskon { + id Int @id @default(autoincrement()) + nama String @db.VarChar(200) + tipe diskon_tipe + nilai Decimal @db.Decimal(15, 2) + min_belanja Decimal @default(0.00) @db.Decimal(15, 2) + produk_ids Json? + member_only Int @default(0) @db.SmallInt + tgl_mulai DateTime @db.DateTime(0) + tgl_selesai DateTime @db.DateTime(0) + jam_mulai DateTime? @db.Time(0) + jam_selesai DateTime? @db.Time(0) + is_aktif Int @default(1) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + diskon_bundling diskon_bundling[] + transaksi_detail transaksi_detail[] + voucher voucher[] + + @@index([tgl_mulai, tgl_selesai], map: "idx_diskon_periode") + @@index([is_aktif], map: "idx_diskon_status") +} + +model diskon_bundling { + id Int @id @default(autoincrement()) + diskon_id Int + produk_id Int + qty Decimal @db.Decimal(10, 2) + diskon diskon @relation(fields: [diskon_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "diskon_bundling_ibfk_1") + produk produk @relation(fields: [produk_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "diskon_bundling_ibfk_2") + + @@index([diskon_id], map: "idx_bundling_diskon") + @@index([produk_id], map: "idx_bundling_produk") +} + +model harga_tingkat { + id Int @id @default(autoincrement()) + produk_id Int + tingkat harga_tingkat_tingkat + min_qty Decimal @db.Decimal(10, 2) + harga Decimal @db.Decimal(15, 2) + created_at DateTime @default(now()) @db.DateTime(0) + produk produk @relation(fields: [produk_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "harga_tingkat_ibfk_1") + + @@unique([produk_id, tingkat], map: "uq_produk_tingkat") +} + +model kategori { + id Int @id @default(autoincrement()) + nama String @unique(map: "nama") @db.VarChar(100) + icon String? @db.VarChar(50) + aktif Int @default(1) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + updated_at DateTime @default(now()) @db.DateTime(0) + deleted_at DateTime? @db.DateTime(0) + produk produk[] + + @@index([nama], map: "idx_kategori_nama") +} + +model member { + id Int @id @default(autoincrement()) + kode String @unique(map: "kode") @db.VarChar(20) + nama String @db.VarChar(200) + nomor_hp String @unique(map: "nomor_hp") @db.VarChar(20) + alamat String? @db.Text + total_poin Int @default(0) + limit_bon Decimal @default(0.00) @db.Decimal(15, 2) + total_bon Decimal @default(0.00) @db.Decimal(15, 2) + foto_url String? @db.VarChar(500) + aktif Int @default(1) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + updated_at DateTime @default(now()) @db.DateTime(0) + deleted_at DateTime? @db.DateTime(0) + bon bon[] + transaksi transaksi[] + + @@index([kode], map: "idx_pelanggan_kode") + @@index([nomor_hp], map: "idx_pelanggan_nomor_hp") +} + +model notifikasi { + id Int @id @default(autoincrement()) + user_id Int? + tipe notifikasi_tipe + judul String @db.VarChar(255) + pesan String @db.Text + referensi_id Int? + is_dibaca Int @default(0) @db.SmallInt + was_sent Int @default(0) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + user user? @relation(fields: [user_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "notifikasi_ibfk_1") + + @@index([created_at], map: "idx_notif_created") + @@index([user_id, is_dibaca], map: "idx_notif_user_dibaca") +} + +model pembayaran { + id Int @id @default(autoincrement()) + transaksi_id Int + metode pembayaran_metode + nominal Decimal @db.Decimal(15, 2) + referensi String? @db.VarChar(100) + created_at DateTime @default(now()) @db.DateTime(0) + transaksi transaksi @relation(fields: [transaksi_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "pembayaran_ibfk_1") + + @@index([metode], map: "idx_pembayaran_metode") + @@index([transaksi_id], map: "idx_pembayaran_transaksi") +} + +model pengaturan { + id Int @id @default(autoincrement()) + kunci String @unique(map: "kunci") @db.VarChar(100) + nilai String @db.Text + deskripsi String? @db.VarChar(255) + updated_at DateTime @default(now()) @db.DateTime(0) + + @@index([kunci], map: "idx_pengaturan_kunci") +} + +model produk { + id Int @id @default(autoincrement()) + kode String @unique(map: "kode") @db.VarChar(50) + barcode String? @unique(map: "barcode") @db.VarChar(50) + nama String @db.VarChar(200) + kategori_id Int + supplier_id Int? + satuan String @default("pcs") @db.VarChar(20) + harga_beli Decimal @db.Decimal(15, 2) + harga_jual Decimal @db.Decimal(15, 2) + stok Decimal @default(0.00) @db.Decimal(12, 2) + stok_min Decimal @default(5.00) @db.Decimal(12, 2) + expired_date DateTime? @db.Date + foto_url String? @db.LongText + qr_url String? @db.VarChar(500) + deskripsi String? @db.Text + is_ecer Int @default(1) @db.SmallInt + is_aktif Int @default(1) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + updated_at DateTime @default(now()) @db.DateTime(0) + deleted_at DateTime? @db.DateTime(0) + diskon_bundling diskon_bundling[] + harga_tingkat harga_tingkat[] + kategori kategori @relation(fields: [kategori_id], references: [id], onUpdate: NoAction, map: "produk_ibfk_1") + supplier supplier? @relation(fields: [supplier_id], references: [id], onUpdate: NoAction, map: "produk_ibfk_2") + stok_batch stok_batch[] + stok_log stok_log[] + stok_opname stok_opname[] + transaksi_detail transaksi_detail[] + transaksi_retur transaksi_retur[] + + @@index([barcode], map: "idx_produk_barcode") + @@index([kategori_id], map: "idx_produk_kategori") + @@index([kode], map: "idx_produk_kode") + @@index([nama], map: "idx_produk_nama") + @@index([supplier_id], map: "idx_produk_supplier") +} + +model shift { + id Int @id @default(autoincrement()) + user_id Int + kode String @db.VarChar(30) + modal_awal Decimal @default(0.00) @db.Decimal(15, 2) + waktu_buka DateTime @db.DateTime(0) + waktu_tutup DateTime? @db.DateTime(0) + total_transaksi Int @default(0) + total_tunai Decimal @default(0.00) @db.Decimal(15, 2) + total_qris Decimal @default(0.00) @db.Decimal(15, 2) + total_transfer Decimal @default(0.00) @db.Decimal(15, 2) + total_bon Decimal @default(0.00) @db.Decimal(15, 2) + total_diskon Decimal @default(0.00) @db.Decimal(15, 2) + total_selisih Decimal @default(0.00) @db.Decimal(15, 2) + status shift_status @default(buka) + catatan String? @db.Text + user user @relation(fields: [user_id], references: [id], onUpdate: NoAction, map: "shift_ibfk_1") + transaksi transaksi[] + + @@index([kode], map: "idx_shift_kode") + @@index([user_id], map: "idx_shift_user") +} + +model stok_batch { + id Int @id @default(autoincrement()) + produk_id Int + batch_no String? @db.VarChar(50) + qty_masuk Decimal @db.Decimal(12, 2) + qty_sisa Decimal @db.Decimal(12, 2) + harga_beli Decimal @db.Decimal(15, 2) + expired_date DateTime? @db.Date + supplier_id Int? + catatan String? @db.Text + created_at DateTime @default(now()) @db.DateTime(0) + produk produk @relation(fields: [produk_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "stok_batch_ibfk_1") + supplier supplier? @relation(fields: [supplier_id], references: [id], onUpdate: NoAction, map: "stok_batch_ibfk_2") + stok_log stok_log[] + transaksi_detail transaksi_detail[] + + @@index([expired_date], map: "idx_batch_expired") + @@index([produk_id], map: "idx_batch_produk") + @@index([supplier_id], map: "idx_batch_supplier") +} + +model stok_log { + id Int @id @default(autoincrement()) + produk_id Int + batch_id Int? + tipe stok_log_tipe + qty Decimal @db.Decimal(12, 2) + stok_sebelum Decimal @db.Decimal(12, 2) + stok_sesudah Decimal @db.Decimal(12, 2) + referensi String? @db.VarChar(50) + user_id Int? + catatan String? @db.Text + created_at DateTime @default(now()) @db.DateTime(0) + stok_batch stok_batch? @relation(fields: [batch_id], references: [id], onUpdate: NoAction, map: "stok_log_ibfk_1") + produk produk @relation(fields: [produk_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "stok_log_ibfk_2") + user user? @relation(fields: [user_id], references: [id], onUpdate: NoAction, map: "stok_log_ibfk_3") + + @@index([batch_id], map: "batch_id") + @@index([created_at], map: "idx_stoklog_date") + @@index([produk_id], map: "idx_stoklog_produk") + @@index([tipe], map: "idx_stoklog_tipe") + @@index([user_id], map: "user_id") +} + +model stok_opname { + id Int @id @default(autoincrement()) + produk_id Int + user_id Int + stok_sistem Decimal @db.Decimal(12, 2) + stok_fisik Decimal @db.Decimal(12, 2) + selisih Decimal @db.Decimal(12, 2) + alasan String? @db.VarChar(255) + status stok_opname_status @default(draft) + created_at DateTime @default(now()) @db.DateTime(0) + produk produk @relation(fields: [produk_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "stok_opname_ibfk_1") + user user @relation(fields: [user_id], references: [id], onUpdate: NoAction, map: "stok_opname_ibfk_2") + + @@index([produk_id], map: "idx_opname_produk") + @@index([status], map: "idx_opname_status") + @@index([user_id], map: "idx_opname_user") +} + +model supplier { + id Int @id @default(autoincrement()) + nama String @db.VarChar(200) + kontak String? @db.VarChar(100) + nomor_hp String? @db.VarChar(20) + alamat String? @db.Text + hutang Decimal @default(0.00) @db.Decimal(15, 2) + aktif Int @default(1) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + updated_at DateTime @default(now()) @db.DateTime(0) + deleted_at DateTime? @db.DateTime(0) + produk produk[] + stok_batch stok_batch[] + + @@index([nama], map: "idx_pemasok_nama") +} + +model transaksi { + id Int @id @default(autoincrement()) + no_transaksi String @unique(map: "no_transaksi") @db.VarChar(30) + shift_id Int + user_id Int + member_id Int? + tanggal DateTime @db.Date + waktu DateTime @db.Time(0) + subtotal Decimal @db.Decimal(15, 2) + diskon_total Decimal @default(0.00) @db.Decimal(15, 2) + diskon_member Decimal @default(0.00) @db.Decimal(15, 2) + pajak Decimal @default(0.00) @db.Decimal(15, 2) + total Decimal @db.Decimal(15, 2) + bayar Decimal @default(0.00) @db.Decimal(15, 2) + kembalian Decimal @default(0.00) @db.Decimal(15, 2) + status transaksi_status @default(lunas) + is_synced Int @default(0) @db.SmallInt + sync_at DateTime? @db.DateTime(0) + local_id String? @db.VarChar(50) + catatan String? @db.Text + created_at DateTime @default(now()) @db.DateTime(0) + deleted_at DateTime? @db.DateTime(0) + bon bon[] + pembayaran pembayaran[] + member member? @relation(fields: [member_id], references: [id], onUpdate: NoAction, map: "transaksi_ibfk_1") + shift shift @relation(fields: [shift_id], references: [id], onUpdate: NoAction, map: "transaksi_ibfk_2") + user user @relation(fields: [user_id], references: [id], onUpdate: NoAction, map: "transaksi_ibfk_3") + transaksi_detail transaksi_detail[] + transaksi_retur transaksi_retur[] + + @@index([member_id], map: "idx_transaksi_member") + @@index([no_transaksi], map: "idx_transaksi_no") + @@index([shift_id], map: "idx_transaksi_shift") + @@index([tanggal], map: "idx_transaksi_tanggal") + @@index([user_id], map: "idx_transaksi_user") +} + +model transaksi_detail { + id Int @id @default(autoincrement()) + transaksi_id Int + produk_id Int + nama_produk String @db.VarChar(200) + qty Decimal @db.Decimal(10, 2) + satuan String @db.VarChar(20) + harga_satuan Decimal @db.Decimal(15, 2) + diskon_item Decimal @default(0.00) @db.Decimal(15, 2) + subtotal Decimal @db.Decimal(15, 2) + diskon_id Int? + batch_id Int? + created_at DateTime @default(now()) @db.DateTime(0) + stok_batch stok_batch? @relation(fields: [batch_id], references: [id], onUpdate: NoAction, map: "transaksi_detail_ibfk_1") + diskon diskon? @relation(fields: [diskon_id], references: [id], onUpdate: NoAction, map: "transaksi_detail_ibfk_2") + produk produk @relation(fields: [produk_id], references: [id], onUpdate: NoAction, map: "transaksi_detail_ibfk_3") + transaksi transaksi @relation(fields: [transaksi_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "transaksi_detail_ibfk_4") + + @@index([batch_id], map: "batch_id") + @@index([diskon_id], map: "diskon_id") + @@index([produk_id], map: "idx_detail_produk") + @@index([transaksi_id], map: "idx_detail_transaksi") +} + +model transaksi_retur { + id Int @id @default(autoincrement()) + transaksi_id Int + no_retur String @unique(map: "no_retur") @db.VarChar(30) + user_id Int + produk_id Int + qty Decimal @db.Decimal(10, 2) + alasan String @db.VarChar(255) + tipe transaksi_retur_tipe + nominal_kembali Decimal @default(0.00) @db.Decimal(15, 2) + stok_kembali Int @default(1) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + produk produk @relation(fields: [produk_id], references: [id], onUpdate: NoAction, map: "transaksi_retur_ibfk_1") + transaksi transaksi @relation(fields: [transaksi_id], references: [id], onUpdate: NoAction, map: "transaksi_retur_ibfk_2") + user user @relation(fields: [user_id], references: [id], onUpdate: NoAction, map: "transaksi_retur_ibfk_3") + + @@index([no_retur], map: "idx_retur_no") + @@index([produk_id], map: "idx_retur_produk") + @@index([transaksi_id], map: "idx_retur_transaksi") + @@index([user_id], map: "user_id") +} + +model trash { + id Int @id @default(autoincrement()) + entity_type String @db.VarChar(50) + entity_id Int + data String @db.Text + deleted_by Int? + deleted_at DateTime @default(now()) @db.DateTime(0) + + @@index([deleted_at], map: "idx_trash_date") + @@index([entity_type, entity_id], map: "idx_trash_entity") +} + +model user { + id Int @id @default(autoincrement()) + nama String @db.VarChar(100) + username String @unique(map: "username") @db.VarChar(50) + password_hash String @db.VarChar(255) + role user_role + nomor_hp String? @db.VarChar(20) + foto_url String? @db.VarChar(500) + aktif Int @default(1) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + updated_at DateTime @default(now()) @db.DateTime(0) + audit_log audit_log[] + bon_cicilan bon_cicilan[] + notifikasi notifikasi[] + shift shift[] + stok_log stok_log[] + stok_opname stok_opname[] + transaksi transaksi[] + transaksi_retur transaksi_retur[] + + @@index([username], map: "idx_pengguna_username") +} + +model voucher { + id Int @id @default(autoincrement()) + kode String @unique(map: "kode") @db.VarChar(50) + diskon_id Int + kuota Int @default(1) + terpakai Int @default(0) + tgl_kadaluarsa DateTime @db.Date + is_aktif Int @default(1) @db.SmallInt + created_at DateTime @default(now()) @db.DateTime(0) + diskon diskon @relation(fields: [diskon_id], references: [id], onDelete: Cascade, onUpdate: NoAction, map: "voucher_ibfk_1") + + @@index([diskon_id], map: "idx_voucher_diskon") + @@index([kode], map: "idx_voucher_kode") +} + +enum diskon_tipe { + per_item_persen + per_item_nominal + total_persen + total_nominal + bundling + buy_x_get_y + member + flash_sale +} + +enum harga_tingkat_tingkat { + ecer + grosir + partai +} + +enum notifikasi_tipe { + stok_rendah + stok_habis + expired + bon_jatuh_tempo + shift_tutup + transaksi_void + info +} + +enum pembayaran_metode { + tunai + qris + transfer + bon + voucher +} + +enum bon_cicilan_metode { + tunai + transfer +} + +enum stok_log_tipe { + masuk + keluar + opname + retur + expired + adjustment +} + +enum user_role { + admin + kasir +} + +enum bon_status { + aktif + lunas + macet +} + +enum stok_opname_status { + draft + approved +} + +enum transaksi_retur_tipe { + refund + exchange +} + +enum shift_status { + buka + tutup +} + +enum transaksi_status { + lunas + bon + void +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000000000000000000000000000000000000..d4f723124e433689bbc32b95d63c0b7cfa4040e9 --- /dev/null +++ b/src/app.ts @@ -0,0 +1,24 @@ +import express, { Application, Request, Response } from 'express'; +import cors from 'cors'; +import router from './routes'; +import { errorHandler } from './middlewares/error.middleware'; + +const app: Application = express(); + +// Global Middleware +app.use(cors()); +app.use(express.json({ limit: '50mb' })); // Allow larger payloads (useful for base64 images) +app.use(express.urlencoded({ extended: true, limit: '50mb' })); + +// Routing API +app.use('/api', router); + +// Default Route +app.get('/', (req: Request, res: Response) => { + res.json({ message: 'Welcome to Tokiva POS Node.js API Server' }); +}); + +// Global Error Handler +app.use(errorHandler); + +export default app; diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..9663741cb55655375c8eada55b6b4568c880ebf2 --- /dev/null +++ b/src/controllers/auth.controller.ts @@ -0,0 +1,69 @@ +import { Request, Response, NextFunction } from 'express'; +import { AuthService } from '../services/auth.service'; +import { sukses } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; + +export class AuthController { + static async login(req: Request, res: Response, next: NextFunction) { + try { + const { username, password } = req.body; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const result = await AuthService.login({ + username, + passwordSecret: password, + ip, + userAgent, + }); + + return sukses( + res, + { + access_token: result.accessToken, + refresh_token: result.refreshToken, + token_type: 'bearer', + role: result.user.role, + nama: result.user.nama, + }, + 'Login berhasil' + ); + } catch (error) { + next(error); + } + } + + static async refresh(req: Request, res: Response, next: NextFunction) { + try { + const { refresh_token } = req.body; + const result = await AuthService.refresh(refresh_token); + + return sukses( + res, + { + access_token: result.accessToken, + refresh_token: result.refreshToken, + token_type: 'bearer', + role: result.role, + }, + 'Token berhasil diperbarui' + ); + } catch (error) { + next(error); + } + } + + static async logout(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + const userId = req.user.id; + + await AuthService.logout(userId, ip, userAgent); + + return sukses(res, null, 'Logout berhasil'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/bon.controller.ts b/src/controllers/bon.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..0daa6f4120fd910aa27d8a78b11bebb9a2bd3960 --- /dev/null +++ b/src/controllers/bon.controller.ts @@ -0,0 +1,63 @@ +import { Request, Response, NextFunction } from 'express'; +import { BonService } from '../services/bon.service'; +import { sukses } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; + +export class BonController { + static async list(req: Request, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 10; + const statusFilter = (req.query.status_filter as any) || undefined; + const memberId = req.query.member_id ? parseInt(req.query.member_id as string) : undefined; + + const bonList = await BonService.getBon(skip, limit, statusFilter, memberId); + return sukses(res, bonList, 'Daftar kasbon berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async get(req: Request, res: Response, next: NextFunction) { + try { + const bonId = parseInt(req.params.id); + const bon = await BonService.getBonById(bonId); + return sukses(res, bon, 'Detail kasbon berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async payInstallment(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const bonId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const { nominal, metode } = req.body; + const bon = await BonService.bayarCicilan({ + bonId, + nominal, + metode, + actorId, + ip, + userAgent, + }); + + return sukses(res, bon, 'Cicilan kasbon berhasil dibayarkan'); + } catch (error) { + next(error); + } + } + + static async sendDebtReminder(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const bonId = parseInt(req.params.id); + const waLink = await BonService.kirimReminderWA(bonId); + return sukses(res, { wa_link: waLink }, 'WhatsApp reminder link generated successfully'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/kategori.controller.ts b/src/controllers/kategori.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..affa3a4a88aaea892e7e7401e23dd4d954865668 --- /dev/null +++ b/src/controllers/kategori.controller.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from 'express'; +import { KategoriService } from '../services/kategori.service'; +import { sukses } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; + +export class KategoriController { + static async list(req: Request, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 100; + const pencarian = req.query.pencarian as string || undefined; + + const categories = await KategoriService.getKategori(skip, limit, pencarian); + return sukses(res, categories, 'Daftar kategori berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async create(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const category = await KategoriService.create(req.body, actorId, ip, userAgent); + return sukses(res, category, 'Kategori berhasil dibuat'); + } catch (error) { + next(error); + } + } + + static async update(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const kategoriId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const category = await KategoriService.update(kategoriId, req.body, actorId, ip, userAgent); + return sukses(res, category, 'Kategori berhasil diperbarui'); + } catch (error) { + next(error); + } + } + + static async delete(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const kategoriId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + await KategoriService.delete(kategoriId, actorId, ip, userAgent); + return sukses(res, null, 'Kategori berhasil dinonaktifkan'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/member.controller.ts b/src/controllers/member.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..ea7e48f88f43fc6f51a4e69e078d99eb883899f1 --- /dev/null +++ b/src/controllers/member.controller.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from 'express'; +import { MemberService } from '../services/member.service'; +import { sukses } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; + +export class MemberController { + static async list(req: Request, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 10; + const pencarian = req.query.pencarian as string || undefined; + + const members = await MemberService.getMembers(skip, limit, pencarian); + return sukses(res, members, 'Daftar member berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async create(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const member = await MemberService.create(req.body, actorId, ip, userAgent); + return sukses(res, member, 'Member berhasil didaftarkan'); + } catch (error) { + next(error); + } + } + + static async update(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const memberId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const member = await MemberService.update(memberId, req.body, actorId, ip, userAgent); + return sukses(res, member, 'Member berhasil diperbarui'); + } catch (error) { + next(error); + } + } + + static async delete(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const memberId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + await MemberService.delete(memberId, actorId, ip, userAgent); + return sukses(res, null, 'Member berhasil dinonaktifkan'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/ops.controller.ts b/src/controllers/ops.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..1bd9cc9f3e17127a3c7162252a456bb18056fa4c --- /dev/null +++ b/src/controllers/ops.controller.ts @@ -0,0 +1,159 @@ +import { Request, Response, NextFunction } from 'express'; +import { OpsService } from '../services/ops.service'; +import { sukses } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; + +export class OpsController { + // Shift Kasir + static async getActiveShift(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const userId = req.user.id; + const shift = await OpsService.getActiveShift(userId); + if (!shift) { + return sukses(res, null, 'Tidak ada shift aktif untuk kasir saat ini'); + } + return sukses(res, shift, 'Shift aktif berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async openShift(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + const { modal_awal, catatan } = req.body; + + const shift = await OpsService.bukaShift(modal_awal, catatan, actorId, ip, userAgent); + return sukses(res, shift, 'Shift berhasil dibuka'); + } catch (error) { + next(error); + } + } + + static async closeShift(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + const { modal_akhir_fisik, catatan } = req.body; + + const shift = await OpsService.tutupShift(modal_akhir_fisik, catatan, actorId, ip, userAgent); + return sukses(res, shift, 'Shift kasir ditutup, selisih kas fisik berhasil dicatat'); + } catch (error) { + next(error); + } + } + + // Dashboard & Laporan + static async getDashboardSummary(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const summary = await OpsService.getDashboardSummary(); + return sukses(res, summary, 'Dashboard summary berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async getSalesReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const tglMulai = new Date(req.query.tgl_mulai as string); + const tglSelesai = new Date(req.query.tgl_selesai as string); + + const report = await OpsService.buatLaporanPenjualan(tglMulai, tglSelesai); + return sukses(res, report, 'Laporan penjualan berhasil dibuat'); + } catch (error) { + next(error); + } + } + + static async getProfitLossReport(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const tglMulai = new Date(req.query.tgl_mulai as string); + const tglSelesai = new Date(req.query.tgl_selesai as string); + + const report = await OpsService.buatLaporanLabaRugi(tglMulai, tglSelesai); + return sukses(res, report, 'Laporan laba rugi berhasil dibuat'); + } catch (error) { + next(error); + } + } + + // Settings/Pengaturan + static async getSettings(req: Request, res: Response, next: NextFunction) { + try { + const settings = await OpsService.getSettings(); + return sukses(res, settings, 'Pengaturan toko berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async getSettingByKey(req: Request, res: Response, next: NextFunction) { + try { + const { kunci } = req.params; + const value = await OpsService.getSettingByKey(kunci); + return sukses(res, { kunci, nilai: value }, `Pengaturan '${kunci}' berhasil dimuat`); + } catch (error) { + next(error); + } + } + + static async updateSetting(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const { kunci } = req.params; + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + await OpsService.updateSetting(kunci, req.body, actorId, ip, userAgent); + const value = await OpsService.getSettingByKey(kunci); + return sukses(res, { kunci, nilai: value }, 'Pengaturan berhasil diperbarui'); + } catch (error) { + next(error); + } + } + + // Notifikasi + static async listNotifications(req: Request, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 20; + const dibaca = req.query.dibaca !== undefined ? parseInt(req.query.dibaca as string) : undefined; + + const notifs = await OpsService.getNotifications(skip, limit, dibaca); + return sukses(res, notifs, 'Notifikasi berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async readAllNotifications(req: Request, res: Response, next: NextFunction) { + try { + const affected = await OpsService.markAllNotifAsRead(); + return sukses(res, { jumlah_diubah: affected }, 'Semua notifikasi ditandai dibaca'); + } catch (error) { + next(error); + } + } + + static async readNotification(req: Request, res: Response, next: NextFunction) { + try { + const notifId = parseInt(req.params.notif_id); + const notif = await OpsService.markNotifAsRead(notifId); + return sukses(res, notif, 'Notifikasi ditandai dibaca'); + } catch (error) { + next(error); + } + } + + static async getUnreadNotificationCount(req: Request, res: Response, next: NextFunction) { + try { + const count = await OpsService.getUnreadNotifCount(); + return sukses(res, { unread_count: count }, 'Jumlah notifikasi belum dibaca berhasil dihitung'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/pengguna.controller.ts b/src/controllers/pengguna.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..aae160499b0891ec2586be13f583f5866ead4e90 --- /dev/null +++ b/src/controllers/pengguna.controller.ts @@ -0,0 +1,84 @@ +import { Request, Response, NextFunction } from 'express'; +import { PenggunaService } from '../services/pengguna.service'; +import { sukses } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; + +export class PenggunaController { + private static formatUser(user: any) { + const { password_hash, ...rest } = user; + return rest; + } + + static async list(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 10; + const aktif = req.query.aktif !== undefined ? parseInt(req.query.aktif as string) : undefined; + + const users = await PenggunaService.getUsers(skip, limit, aktif); + const data = users.map((u: any) => PenggunaController.formatUser(u)); + + return sukses(res, data, 'Daftar pengguna berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async get(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const userId = parseInt(req.params.id); + const user = await PenggunaService.getUserById(userId); + const data = PenggunaController.formatUser(user); + + return sukses(res, data, 'Detail pengguna berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async create(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const user = await PenggunaService.create(req.body, actorId, ip, userAgent); + const data = PenggunaController.formatUser(user); + + return sukses(res, data, 'Pengguna berhasil dibuat', null, 200); + } catch (error) { + next(error); + } + } + + static async update(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const userId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const user = await PenggunaService.update(userId, req.body, actorId, ip, userAgent); + const data = PenggunaController.formatUser(user); + + return sukses(res, data, 'Pengguna berhasil diperbarui'); + } catch (error) { + next(error); + } + } + + static async delete(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const userId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + await PenggunaService.delete(userId, actorId, ip, userAgent); + + return sukses(res, null, 'Pengguna berhasil dinonaktifkan'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/produk.controller.ts b/src/controllers/produk.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..ee05bea2729a1c79d85f6592feb8082f4810954a --- /dev/null +++ b/src/controllers/produk.controller.ts @@ -0,0 +1,88 @@ +import { Request, Response, NextFunction } from 'express'; +import { ProdukService } from '../services/produk.service'; +import { sukses } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; + +export class ProdukController { + static async list(req: Request, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 10; + const kategoriId = req.query.kategori_id ? parseInt(req.query.kategori_id as string) : undefined; + const supplierId = req.query.supplier_id ? parseInt(req.query.supplier_id as string) : undefined; + const pencarian = req.query.pencarian as string || undefined; + + const { products, total } = await ProdukService.getProduk(skip, limit, kategoriId, supplierId, pencarian); + const page = Math.floor(skip / limit) + 1; + + return sukses(res, products, 'Daftar produk berhasil dimuat', { + total, + page, + per_page: limit, + }); + } catch (error) { + next(error); + } + } + + static async get(req: Request, res: Response, next: NextFunction) { + try { + const produkId = parseInt(req.params.id); + const product = await ProdukService.getProdukById(produkId); + return sukses(res, product, 'Detail produk berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async scan(req: Request, res: Response, next: NextFunction) { + try { + const { barcode_or_kode } = req.params; + const product = await ProdukService.getProdukByBarcodeOrKode(barcode_or_kode); + return sukses(res, product, 'Produk terdeteksi'); + } catch (error) { + next(error); + } + } + + static async create(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const product = await ProdukService.create(req.body, actorId, ip, userAgent); + return sukses(res, product, 'Produk berhasil dibuat'); + } catch (error) { + next(error); + } + } + + static async update(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const produkId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const product = await ProdukService.update(produkId, req.body, actorId, ip, userAgent); + return sukses(res, product, 'Produk berhasil diperbarui'); + } catch (error) { + next(error); + } + } + + static async delete(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const produkId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + await ProdukService.delete(produkId, actorId, ip, userAgent); + return sukses(res, null, 'Produk berhasil dihapus (soft-delete)'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/stok.controller.ts b/src/controllers/stok.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..ade442bb918936c5d81919e10c0ac9260c2f582a --- /dev/null +++ b/src/controllers/stok.controller.ts @@ -0,0 +1,73 @@ +import { Request, Response, NextFunction } from 'express'; +import { StokService } from '../services/stok.service'; +import { sukses } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; + +export class StokController { + static async addIncomingStock(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const batch = await StokService.tambahBarangMasuk(req.body, actorId, ip, userAgent); + return sukses(res, batch, 'Barang masuk berhasil dicatat'); + } catch (error) { + next(error); + } + } + + static async createStockOpname(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const opname = await StokService.buatStokOpname(req.body, actorId, ip, userAgent); + return sukses(res, opname, 'Draft stok opname berhasil dibuat'); + } catch (error) { + next(error); + } + } + + static async approveStockOpname(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const opnameId = parseInt(req.params.opname_id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const opname = await StokService.approveStokOpname(opnameId, actorId, ip, userAgent); + return sukses(res, opname, 'Stok opname disetujui, kuantitas stok produk disesuaikan'); + } catch (error) { + next(error); + } + } + + static async getStockMutations(req: Request, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 20; + const produkId = req.query.produk_id ? parseInt(req.query.produk_id as string) : undefined; + const tipe = req.query.tipe as string || undefined; + + const logs = await StokService.getStokLogs(skip, limit, produkId, tipe); + return sukses(res, logs, 'Kartu stok berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async getStockBatches(req: Request, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 20; + const produkId = req.query.produk_id ? parseInt(req.query.produk_id as string) : undefined; + + const batches = await StokService.getStokBatches(skip, limit, produkId); + return sukses(res, batches, 'Batch stok berhasil dimuat'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/supplier.controller.ts b/src/controllers/supplier.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..ef676201bf994ab7d988442fcc46bde2de69aeec --- /dev/null +++ b/src/controllers/supplier.controller.ts @@ -0,0 +1,60 @@ +import { Request, Response, NextFunction } from 'express'; +import { SupplierService } from '../services/supplier.service'; +import { sukses } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; + +export class SupplierController { + static async list(req: Request, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 10; + const pencarian = req.query.pencarian as string || undefined; + + const suppliers = await SupplierService.getSuppliers(skip, limit, pencarian); + return sukses(res, suppliers, 'Daftar supplier berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async create(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const supplier = await SupplierService.create(req.body, actorId, ip, userAgent); + return sukses(res, supplier, 'Supplier berhasil ditambahkan'); + } catch (error) { + next(error); + } + } + + static async update(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const supplierId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const supplier = await SupplierService.update(supplierId, req.body, actorId, ip, userAgent); + return sukses(res, supplier, 'Supplier berhasil diperbarui'); + } catch (error) { + next(error); + } + } + + static async delete(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const supplierId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + await SupplierService.delete(supplierId, actorId, ip, userAgent); + return sukses(res, null, 'Supplier berhasil dinonaktifkan'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/transaksi.controller.ts b/src/controllers/transaksi.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9502a0d684328c1547f1732d79963a2819eb3a7 --- /dev/null +++ b/src/controllers/transaksi.controller.ts @@ -0,0 +1,118 @@ +import { Request, Response, NextFunction } from 'express'; +import { TransaksiService } from '../services/transaksi.service'; +import { sukses, gagal } from '../utils/response'; +import { AuthenticatedRequest } from '../middlewares/auth.middleware'; +import prisma from '../utils/db'; + +export class TransaksiController { + static async create(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const trans = await TransaksiService.prosesTransaksi({ + data: req.body, + actorId, + ip, + userAgent, + }); + + return sukses(res, trans, 'Transaksi berhasil diproses'); + } catch (error) { + next(error); + } + } + + static async list(req: Request, res: Response, next: NextFunction) { + try { + const skip = parseInt(req.query.skip as string) || 0; + const limit = parseInt(req.query.limit as string) || 20; + const statusFilter = (req.query.status_filter as any) || undefined; + const memberId = req.query.member_id ? parseInt(req.query.member_id as string) : undefined; + + const where: any = {}; + if (statusFilter) where.status = statusFilter; + if (memberId) where.member_id = memberId; + + const total = await prisma.transaksi.count({ where }); + const trans = await prisma.transaksi.findMany({ + where, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + include: { + transaksi_detail: true, + pembayaran: true, + }, + }); + + const page = Math.floor(skip / limit) + 1; + + return sukses(res, trans, 'Daftar transaksi berhasil dimuat', { + total, + page, + per_page: limit, + }); + } catch (error) { + next(error); + } + } + + static async get(req: Request, res: Response, next: NextFunction) { + try { + const transaksiId = parseInt(req.params.id); + const trans = await prisma.transaksi.findUnique({ + where: { id: transaksiId }, + include: { + transaksi_detail: true, + pembayaran: true, + user: { + select: { + id: true, + nama: true, + username: true, + }, + }, + member: true, + }, + }); + + if (!trans) { + return gagal(res, 404, 'Transaksi tidak ditemukan'); + } + + return sukses(res, trans, 'Detail transaksi berhasil dimuat'); + } catch (error) { + next(error); + } + } + + static async void(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const transaksiId = parseInt(req.params.id); + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + const alasan = req.body.alasan || 'Pembatalan transaksi oleh kasir'; + + const trans = await TransaksiService.prosesVoidTransaksi(transaksiId, alasan, actorId, ip, userAgent); + return sukses(res, trans, 'Transaksi berhasil di-void'); + } catch (error) { + next(error); + } + } + + static async retur(req: AuthenticatedRequest, res: Response, next: NextFunction) { + try { + const actorId = req.user.id; + const ip = (req.ip || req.socket.remoteAddress || 'unknown').replace('::ffff:', ''); + const userAgent = (req.headers['user-agent'] || 'unknown') as string; + + const retur = await TransaksiService.prosesReturBarang(req.body, actorId, ip, userAgent); + return sukses(res, retur, 'Retur barang berhasil dicatat'); + } catch (error) { + next(error); + } + } +} diff --git a/src/controllers/upload.controller.ts b/src/controllers/upload.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..fcc38305a5b410635cf19d2049bb29c127dd52cb --- /dev/null +++ b/src/controllers/upload.controller.ts @@ -0,0 +1,41 @@ +import { Request, Response, NextFunction } from 'express'; +import { uploadBase64ToSupabase } from '../utils/supabase'; +import { sukses, gagal } from '../utils/response'; +import crypto from 'crypto'; + +export class UploadController { + static async uploadProdukFoto(req: Request, res: Response, next: NextFunction) { + try { + const { images, prefix } = req.body; + const cleanPrefix = (prefix || 'img').replace(/[^a-zA-Z0-9-_]/g, '').toLowerCase(); + const uploadedUrls: string[] = []; + + for (let i = 0; i < images.length; i++) { + const imgBase64 = images[i]; + + if (imgBase64.startsWith('http')) { + uploadedUrls.push(imgBase64); + continue; + } + + // Generate unique path/filename + const uniqueId = crypto.randomBytes(4).toString('hex'); // 8 chars + const timestamp = Math.floor(Date.now() / 1000); + const fileName = `${cleanPrefix}_${timestamp}_${uniqueId}_${i}.jpg`; + const filePath = `produk/${fileName}`; + + const url = await uploadBase64ToSupabase('produk', filePath, imgBase64); + + if (url) { + uploadedUrls.push(url); + } else { + return gagal(res, 500, `Gagal mengunggah foto ke-${i + 1} ke Supabase`); + } + } + + return sukses(res, { urls: uploadedUrls }, 'Foto berhasil diunggah ke Supabase'); + } catch (error) { + next(error); + } + } +} diff --git a/src/middlewares/auth.middleware.ts b/src/middlewares/auth.middleware.ts new file mode 100644 index 0000000000000000000000000000000000000000..7e2459296e1962f797b2133e829062754c1dc488 --- /dev/null +++ b/src/middlewares/auth.middleware.ts @@ -0,0 +1,56 @@ +import { Request, Response, NextFunction } from 'express'; +import { verifyToken } from '../utils/jwt'; +import prisma from '../utils/db'; +import { gagal } from '../utils/response'; + +export interface AuthenticatedRequest extends Request { + user?: any; +} + +export const authenticate = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) => { + try { + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return gagal(res, 401, 'Token tidak ditemukan atau tidak valid'); + } + + const token = authHeader.split(' ')[1]; + const decoded = verifyToken(token); + + if (!decoded || decoded.type !== 'access') { + return gagal(res, 401, 'Token tidak valid atau kedaluwarsa'); + } + + const username = decoded.sub; + const user = await prisma.user.findFirst({ + where: { + username, + aktif: 1, + }, + }); + + if (!user) { + return gagal(res, 401, 'Pengguna tidak aktif atau tidak ditemukan'); + } + + req.user = user; + next(); + } catch (error) { + next(error); + } +}; + +export const requireAdmin = ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction +) => { + if (!req.user || req.user.role !== 'admin') { + return gagal(res, 403, 'Akses ditolak. Peran Admin diperlukan.'); + } + next(); +}; diff --git a/src/middlewares/error.middleware.ts b/src/middlewares/error.middleware.ts new file mode 100644 index 0000000000000000000000000000000000000000..c754d8af9d4c261cd0caf4fd8366e8bd5e5dffad --- /dev/null +++ b/src/middlewares/error.middleware.ts @@ -0,0 +1,17 @@ +import { Request, Response, NextFunction } from 'express'; +import { gagal } from '../utils/response'; + +export const errorHandler = ( + err: any, + req: Request, + res: Response, + next: NextFunction +) => { + console.error('[Error Handler]:', err); + + const statusCode = err.statusCode || 500; + const message = err.message || 'Terjadi kesalahan pada server'; + const detail = err.detail || err.errors || message; + + return gagal(res, statusCode, message, err.stack || null, detail); +}; diff --git a/src/middlewares/validate.middleware.ts b/src/middlewares/validate.middleware.ts new file mode 100644 index 0000000000000000000000000000000000000000..4486b2a485906824bda199b1501099b52425956a --- /dev/null +++ b/src/middlewares/validate.middleware.ts @@ -0,0 +1,32 @@ +import { Request, Response, NextFunction } from 'express'; +import { AnyZodObject, ZodError } from 'zod'; + +export const validate = (schema: AnyZodObject) => { + return async (req: Request, res: Response, next: NextFunction) => { + try { + const parsed = await schema.parseAsync({ + body: req.body, + query: req.query, + params: req.params, + }); + + if (parsed.body !== undefined) req.body = parsed.body; + if (parsed.query !== undefined) req.query = parsed.query; + if (parsed.params !== undefined) req.params = parsed.params; + + return next(); + } catch (error) { + if (error instanceof ZodError) { + return res.status(400).json({ + sukses: false, + pesan: 'Validasi input gagal', + errors: error.errors.map((err) => ({ + field: err.path.slice(1).join('.'), + message: err.message, + })), + }); + } + return next(error); + } + }; +}; diff --git a/src/routes/auth.route.ts b/src/routes/auth.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..72661d03dbf92cdfcfec210af1d8ae28aeca466c --- /dev/null +++ b/src/routes/auth.route.ts @@ -0,0 +1,13 @@ +import { Router } from 'express'; +import { AuthController } from '../controllers/auth.controller'; +import { validate } from '../middlewares/validate.middleware'; +import { loginSchema, refreshSchema } from '../schemas/auth.schema'; +import { authenticate } from '../middlewares/auth.middleware'; + +const authRouter = Router(); + +authRouter.post('/login', validate(loginSchema), AuthController.login); +authRouter.post('/refresh', validate(refreshSchema), AuthController.refresh); +authRouter.post('/logout', authenticate, AuthController.logout); + +export default authRouter; diff --git a/src/routes/bon.route.ts b/src/routes/bon.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..71cb9a746bda6c17b72bd9d6231cc8f6c4bc37f3 --- /dev/null +++ b/src/routes/bon.route.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { BonController } from '../controllers/bon.controller'; +import { authenticate } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { bayarBonSchema } from '../schemas/bon.schema'; + +const bonRouter = Router(); + +// Rute Publik +bonRouter.get('/', BonController.list); +bonRouter.get('/:id', BonController.get); + +// Rute Terproteksi Token +bonRouter.post('/:id/bayar', authenticate, validate(bayarBonSchema), BonController.payInstallment); +bonRouter.post('/:id/reminder', authenticate, BonController.sendDebtReminder); + +export default bonRouter; diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..dd87f6f42b0cd5d7c1238a44a7604bbdf3f417bb --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,28 @@ +import { Router } from 'express'; +import authRouter from './auth.route'; +import penggunaRouter from './pengguna.route'; +import uploadRouter from './upload.route'; +import kategoriRouter from './kategori.route'; +import supplierRouter from './supplier.route'; +import memberRouter from './member.route'; +import produkRouter from './produk.route'; +import stokRouter from './stok.route'; +import bonRouter from './bon.route'; +import transaksiRouter from './transaksi.route'; +import opsRouter from './ops.route'; + +const router = Router(); + +router.use('/auth', authRouter); +router.use('/users', penggunaRouter); +router.use('/upload', uploadRouter); +router.use('/kategori', kategoriRouter); +router.use('/supplier', supplierRouter); +router.use('/member', memberRouter); +router.use('/produk', produkRouter); +router.use('/stok', stokRouter); +router.use('/bon', bonRouter); +router.use('/transaksi', transaksiRouter); +router.use('/', opsRouter); + +export default router; diff --git a/src/routes/kategori.route.ts b/src/routes/kategori.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..3ceeb27ba34cd3eb5b5ed69b0304576eb801acd1 --- /dev/null +++ b/src/routes/kategori.route.ts @@ -0,0 +1,17 @@ +import { Router } from 'express'; +import { KategoriController } from '../controllers/kategori.controller'; +import { authenticate } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { createKategoriSchema, updateKategoriSchema } from '../schemas/kategori.schema'; + +const kategoriRouter = Router(); + +// Rute Publik +kategoriRouter.get('/', KategoriController.list); + +// Rute Terproteksi Token +kategoriRouter.post('/', authenticate, validate(createKategoriSchema), KategoriController.create); +kategoriRouter.put('/:id', authenticate, validate(updateKategoriSchema), KategoriController.update); +kategoriRouter.delete('/:id', authenticate, KategoriController.delete); + +export default kategoriRouter; diff --git a/src/routes/member.route.ts b/src/routes/member.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..3af3dd415882cf43a8bf8d691dfe4b95e1eeed9d --- /dev/null +++ b/src/routes/member.route.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { MemberController } from '../controllers/member.controller'; +import { authenticate } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { createMemberSchema, updateMemberSchema } from '../schemas/member.schema'; + +const memberRouter = Router(); + +memberRouter.get('/', MemberController.list); + +// Protected routes below +memberRouter.post('/', authenticate, validate(createMemberSchema), MemberController.create); +memberRouter.put('/:id', authenticate, validate(updateMemberSchema), MemberController.update); +memberRouter.delete('/:id', authenticate, MemberController.delete); + +export default memberRouter; diff --git a/src/routes/ops.route.ts b/src/routes/ops.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..a2e6b922450aba0d4f6b863a8485f5572ad384c4 --- /dev/null +++ b/src/routes/ops.route.ts @@ -0,0 +1,30 @@ +import { Router } from 'express'; +import { OpsController } from '../controllers/ops.controller'; +import { authenticate } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { bukaShiftSchema, tutupShiftSchema, updatePengaturanSchema, laporanQuerySchema } from '../schemas/ops.schema'; + +const opsRouter = Router(); + +// ================= SHIFT KASIR ================= +opsRouter.get('/shift/aktif', authenticate, OpsController.getActiveShift); +opsRouter.post('/shift/buka', authenticate, validate(bukaShiftSchema), OpsController.openShift); +opsRouter.post('/shift/tutup', authenticate, validate(tutupShiftSchema), OpsController.closeShift); + +// ================= REPORT & DASHBOARD ================= +opsRouter.get('/dashboard/summary', authenticate, OpsController.getDashboardSummary); +opsRouter.get('/laporan/penjualan', authenticate, validate(laporanQuerySchema), OpsController.getSalesReport); +opsRouter.get('/laporan/laba', authenticate, validate(laporanQuerySchema), OpsController.getProfitLossReport); + +// ================= SETTINGS ================= +opsRouter.get('/pengaturan', OpsController.getSettings); +opsRouter.get('/pengaturan/:kunci', OpsController.getSettingByKey); +opsRouter.put('/pengaturan/:kunci', authenticate, validate(updatePengaturanSchema), OpsController.updateSetting); + +// ================= NOTIFIKASI ================= +opsRouter.get('/notifikasi', OpsController.listNotifications); +opsRouter.get('/notifikasi/unread-count', OpsController.getUnreadNotificationCount); +opsRouter.post('/notifikasi/read-all', OpsController.readAllNotifications); +opsRouter.post('/notifikasi/:notif_id/read', OpsController.readNotification); + +export default opsRouter; diff --git a/src/routes/pengguna.route.ts b/src/routes/pengguna.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..39311a2c6720592d692788ad932a01b89b7c9b2f --- /dev/null +++ b/src/routes/pengguna.route.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { PenggunaController } from '../controllers/pengguna.controller'; +import { authenticate, requireAdmin } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { createPenggunaSchema, updatePenggunaSchema } from '../schemas/pengguna.schema'; + +const penggunaRouter = Router(); + +// Proteksi Admin untuk semua rute pengguna +penggunaRouter.use(authenticate); +penggunaRouter.use(requireAdmin); + +penggunaRouter.get('/', PenggunaController.list); +penggunaRouter.get('/:id', PenggunaController.get); +penggunaRouter.post('/', validate(createPenggunaSchema), PenggunaController.create); +penggunaRouter.put('/:id', validate(updatePenggunaSchema), PenggunaController.update); +penggunaRouter.delete('/:id', PenggunaController.delete); + +export default penggunaRouter; diff --git a/src/routes/produk.route.ts b/src/routes/produk.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..dddb891916e8f3b08f9ab26dd6c8081bc1cbac6e --- /dev/null +++ b/src/routes/produk.route.ts @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { ProdukController } from '../controllers/produk.controller'; +import { authenticate } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { createProdukSchema, updateProdukSchema } from '../schemas/produk.schema'; + +const produkRouter = Router(); + +// Rute Publik +produkRouter.get('/', ProdukController.list); +produkRouter.get('/:id', ProdukController.get); +produkRouter.get('/scan/:barcode_or_kode', ProdukController.scan); + +// Rute Terproteksi Token +produkRouter.post('/', authenticate, validate(createProdukSchema), ProdukController.create); +produkRouter.put('/:id', authenticate, validate(updateProdukSchema), ProdukController.update); +produkRouter.delete('/:id', authenticate, ProdukController.delete); + +export default produkRouter; diff --git a/src/routes/stok.route.ts b/src/routes/stok.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..2dfb3cb4984e20a8530a568ddb31d9c00ed548e4 --- /dev/null +++ b/src/routes/stok.route.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { StokController } from '../controllers/stok.controller'; +import { authenticate } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { barangMasukSchema, stokOpnameSchema } from '../schemas/stok.schema'; + +const stokRouter = Router(); + +// Rute Publik +stokRouter.get('/mutasi', StokController.getStockMutations); +stokRouter.get('/batches', StokController.getStockBatches); + +// Rute Terproteksi Token +stokRouter.post('/masuk', authenticate, validate(barangMasukSchema), StokController.addIncomingStock); +stokRouter.post('/opname', authenticate, validate(stokOpnameSchema), StokController.createStockOpname); +stokRouter.post('/opname/:opname_id/approve', authenticate, StokController.approveStockOpname); + +export default stokRouter; diff --git a/src/routes/supplier.route.ts b/src/routes/supplier.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..6db21b620c8bba0ec04f6a2f3bedeb8afdf0280a --- /dev/null +++ b/src/routes/supplier.route.ts @@ -0,0 +1,16 @@ +import { Router } from 'express'; +import { SupplierController } from '../controllers/supplier.controller'; +import { authenticate } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { createSupplierSchema, updateSupplierSchema } from '../schemas/supplier.schema'; + +const supplierRouter = Router(); + +supplierRouter.get('/', SupplierController.list); + +// Protected routes below +supplierRouter.post('/', authenticate, validate(createSupplierSchema), SupplierController.create); +supplierRouter.put('/:id', authenticate, validate(updateSupplierSchema), SupplierController.update); +supplierRouter.delete('/:id', authenticate, SupplierController.delete); + +export default supplierRouter; diff --git a/src/routes/transaksi.route.ts b/src/routes/transaksi.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..5319c47ee93b459ec6e3d20a0646fe93c62aadab --- /dev/null +++ b/src/routes/transaksi.route.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { TransaksiController } from '../controllers/transaksi.controller'; +import { authenticate } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { createTransaksiSchema, createTransaksiReturSchema } from '../schemas/transaksi.schema'; + +const transaksiRouter = Router(); + +// Rute Publik +transaksiRouter.get('/', TransaksiController.list); +transaksiRouter.get('/:id', TransaksiController.get); + +// Rute Terproteksi Token +transaksiRouter.post('/', authenticate, validate(createTransaksiSchema), TransaksiController.create); +transaksiRouter.post('/:id/void', authenticate, TransaksiController.void); +transaksiRouter.post('/retur', authenticate, validate(createTransaksiReturSchema), TransaksiController.retur); + +export default transaksiRouter; diff --git a/src/routes/upload.route.ts b/src/routes/upload.route.ts new file mode 100644 index 0000000000000000000000000000000000000000..d765ea2d8278eb9d231cbeca5538e1063e8a5a91 --- /dev/null +++ b/src/routes/upload.route.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { UploadController } from '../controllers/upload.controller'; +import { authenticate } from '../middlewares/auth.middleware'; +import { validate } from '../middlewares/validate.middleware'; +import { uploadSchema } from '../schemas/upload.schema'; + +const uploadRouter = Router(); + +// Proteksi token untuk semua rute upload +uploadRouter.use(authenticate); + +uploadRouter.post('/produk', validate(uploadSchema), UploadController.uploadProdukFoto); + +export default uploadRouter; diff --git a/src/schemas/auth.schema.ts b/src/schemas/auth.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..39bec50ef251b1fe25997758336951792a8e2d80 --- /dev/null +++ b/src/schemas/auth.schema.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const loginSchema = z.object({ + body: z.object({ + username: z.string({ required_error: 'Username wajib diisi' }).min(1, 'Username tidak boleh kosong'), + password: z.string({ required_error: 'Password wajib diisi' }).min(1, 'Password tidak boleh kosong'), + }), +}); + +export const refreshSchema = z.object({ + body: z.object({ + refresh_token: z.string({ required_error: 'Refresh token wajib diisi' }).min(1, 'Refresh token tidak boleh kosong'), + }), +}); diff --git a/src/schemas/bon.schema.ts b/src/schemas/bon.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..35e0f2a7ba286d20a752b77a782de12be807fd79 --- /dev/null +++ b/src/schemas/bon.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const bayarBonSchema = z.object({ + body: z.object({ + nominal: z.number({ required_error: 'Nominal pembayaran wajib diisi' }).positive('Nominal harus lebih besar dari 0'), + metode: z.enum(['tunai', 'transfer'], { required_error: 'Metode cicilan wajib diisi (tunai/transfer)' }), + }), +}); diff --git a/src/schemas/kategori.schema.ts b/src/schemas/kategori.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd3fb197129da313e54ab8c826356e4bf8d789b1 --- /dev/null +++ b/src/schemas/kategori.schema.ts @@ -0,0 +1,20 @@ +import { z } from 'zod'; + +const booleanOrNumber = z.union([z.boolean(), z.number()]) + .transform((val) => (typeof val === 'boolean' ? (val ? 1 : 0) : val)); + +export const createKategoriSchema = z.object({ + body: z.object({ + nama: z.string({ required_error: 'Nama kategori wajib diisi' }).min(1, 'Nama kategori tidak boleh kosong'), + icon: z.string().optional().nullable(), + aktif: booleanOrNumber.optional().default(1), + }), +}); + +export const updateKategoriSchema = z.object({ + body: z.object({ + nama: z.string().optional(), + icon: z.string().optional().nullable(), + aktif: booleanOrNumber.optional(), + }), +}); diff --git a/src/schemas/member.schema.ts b/src/schemas/member.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..b17b1e85e217fee6bedaafe577c71391cad1bc6b --- /dev/null +++ b/src/schemas/member.schema.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +const booleanOrNumber = z.union([z.boolean(), z.number()]) + .transform((val) => (typeof val === 'boolean' ? (val ? 1 : 0) : val)); + +export const createMemberSchema = z.object({ + body: z.object({ + kode: z.string({ required_error: 'Kode member wajib diisi' }).min(1, 'Kode member tidak boleh kosong'), + nama: z.string({ required_error: 'Nama member wajib diisi' }).min(1, 'Nama member tidak boleh kosong'), + nomor_hp: z.string({ required_error: 'Nomor HP wajib diisi' }).min(1, 'Nomor HP tidak boleh kosong'), + alamat: z.string().optional().nullable(), + limit_bon: z.number().optional().default(0), + foto_url: z.string().optional().nullable(), + aktif: booleanOrNumber.optional().default(1), + }), +}); + +export const updateMemberSchema = z.object({ + body: z.object({ + nama: z.string().optional(), + nomor_hp: z.string().optional(), + alamat: z.string().optional().nullable(), + total_poin: z.number().optional(), + limit_bon: z.number().optional(), + total_bon: z.number().optional(), + foto_url: z.string().optional().nullable(), + aktif: booleanOrNumber.optional(), + }), +}); diff --git a/src/schemas/ops.schema.ts b/src/schemas/ops.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..0fa7bffc86ea9eb7847b1497551bd0f5e9c1c633 --- /dev/null +++ b/src/schemas/ops.schema.ts @@ -0,0 +1,33 @@ +import { z } from 'zod'; + +export const bukaShiftSchema = z.object({ + body: z.object({ + modal_awal: z.number({ required_error: 'Modal awal wajib diisi' }).nonnegative('Modal awal tidak boleh negatif'), + catatan: z.string().optional().nullable(), + }), +}); + +export const tutupShiftSchema = z.object({ + body: z.object({ + modal_akhir_fisik: z.number({ required_error: 'Modal akhir fisik wajib diisi' }).nonnegative('Modal akhir fisik tidak boleh negatif'), + catatan: z.string().optional().nullable(), + }), +}); + +export const updatePengaturanSchema = z.object({ + body: z.object({ + nilai: z.string({ required_error: 'Nilai pengaturan wajib diisi' }), + deskripsi: z.string().optional().nullable(), + }), +}); + +export const laporanQuerySchema = z.object({ + query: z.object({ + tgl_mulai: z.string({ required_error: 'tgl_mulai wajib diisi' }).refine((val) => !isNaN(Date.parse(val)), { + message: 'Format tgl_mulai tidak valid (harus YYYY-MM-DD)', + }), + tgl_selesai: z.string({ required_error: 'tgl_selesai wajib diisi' }).refine((val) => !isNaN(Date.parse(val)), { + message: 'Format tgl_selesai tidak valid (harus YYYY-MM-DD)', + }), + }), +}); diff --git a/src/schemas/pengguna.schema.ts b/src/schemas/pengguna.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab4806dcafe25bd5ac891df888d88acd6d5e7958 --- /dev/null +++ b/src/schemas/pengguna.schema.ts @@ -0,0 +1,28 @@ +import { z } from 'zod'; + +const booleanOrNumber = z.union([z.boolean(), z.number()]) + .transform((val) => (typeof val === 'boolean' ? (val ? 1 : 0) : val)); + +export const createPenggunaSchema = z.object({ + body: z.object({ + nama: z.string({ required_error: 'Nama wajib diisi' }).min(1, 'Nama tidak boleh kosong'), + username: z.string({ required_error: 'Username wajib diisi' }).min(1, 'Username tidak boleh kosong'), + password: z.string({ required_error: 'Password wajib diisi' }).min(6, 'Password minimal 6 karakter'), + role: z.enum(['admin', 'kasir'], { required_error: 'Role wajib diisi (admin/kasir)' }), + nomor_hp: z.string().optional().nullable(), + foto_url: z.string().optional().nullable(), + aktif: booleanOrNumber.default(1), + }), +}); + +export const updatePenggunaSchema = z.object({ + body: z.object({ + nama: z.string().optional(), + username: z.string().optional(), + password: z.string().min(6, 'Password minimal 6 karakter').optional(), + role: z.enum(['admin', 'kasir']).optional(), + nomor_hp: z.string().optional().nullable(), + foto_url: z.string().optional().nullable(), + aktif: booleanOrNumber.optional(), + }), +}); diff --git a/src/schemas/produk.schema.ts b/src/schemas/produk.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..b5ab02dddd133ccce3da20aac2367a134d671cd0 --- /dev/null +++ b/src/schemas/produk.schema.ts @@ -0,0 +1,55 @@ +import { z } from 'zod'; + +const booleanOrNumber = z.union([z.boolean(), z.number()]) + .transform((val) => (typeof val === 'boolean' ? (val ? 1 : 0) : val)); + +export const createProdukSchema = z.object({ + body: z.object({ + kode: z.string({ required_error: 'Kode produk wajib diisi' }).min(1, 'Kode produk tidak boleh kosong'), + barcode: z.string().optional().nullable(), + nama: z.string({ required_error: 'Nama produk wajib diisi' }).min(1, 'Nama produk tidak boleh kosong'), + kategori_id: z.number({ required_error: 'Kategori ID wajib diisi' }), + supplier_id: z.number().optional().nullable(), + satuan: z.string().optional().default('pcs'), + harga_beli: z.number({ required_error: 'Harga beli wajib diisi' }).nonnegative(), + harga_jual: z.number({ required_error: 'Harga jual wajib diisi' }).nonnegative(), + stok: z.number().optional().default(0), + stok_min: z.number().optional().default(5), + expired_date: z.string().optional().nullable().transform((val) => (val ? new Date(val) : null)), + foto_url: z.string().optional().nullable(), + qr_url: z.string().optional().nullable(), + deskripsi: z.string().optional().nullable(), + is_ecer: booleanOrNumber.optional().default(1), + is_aktif: booleanOrNumber.optional().default(1), + harga_tingkat: z.array(z.object({ + tingkat: z.enum(['ecer', 'grosir', 'partai']), + min_qty: z.number().positive(), + harga: z.number().nonnegative(), + })).optional(), + }), +}); + +export const updateProdukSchema = z.object({ + body: z.object({ + kode: z.string().optional(), + barcode: z.string().optional().nullable(), + nama: z.string().optional(), + kategori_id: z.number().optional(), + supplier_id: z.number().optional().nullable(), + satuan: z.string().optional(), + harga_beli: z.number().nonnegative().optional(), + harga_jual: z.number().nonnegative().optional(), + stok_min: z.number().optional(), + expired_date: z.string().optional().nullable().transform((val) => (val ? new Date(val) : null)), + foto_url: z.string().optional().nullable(), + qr_url: z.string().optional().nullable(), + deskripsi: z.string().optional().nullable(), + is_ecer: booleanOrNumber.optional(), + is_aktif: booleanOrNumber.optional(), + harga_tingkat: z.array(z.object({ + tingkat: z.enum(['ecer', 'grosir', 'partai']), + min_qty: z.number().positive(), + harga: z.number().nonnegative(), + })).optional().nullable(), + }), +}); diff --git a/src/schemas/stok.schema.ts b/src/schemas/stok.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..f42ddd1af618c050c7bb0c2319af067a8fdfd1ca --- /dev/null +++ b/src/schemas/stok.schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod'; + +export const barangMasukSchema = z.object({ + body: z.object({ + produk_id: z.number({ required_error: 'produk_id wajib diisi' }), + qty: z.number({ required_error: 'Kuantitas (qty) wajib diisi' }).positive(), + harga_beli: z.number({ required_error: 'Harga beli wajib diisi' }).positive(), + batch_no: z.string().optional().nullable(), + expired_date: z.string().optional().nullable().transform((val) => (val ? new Date(val) : null)), + supplier_id: z.number().optional().nullable(), + catatan: z.string().optional().nullable(), + }), +}); + +export const stokOpnameSchema = z.object({ + body: z.object({ + produk_id: z.number({ required_error: 'produk_id wajib diisi' }), + stok_fisik: z.number({ required_error: 'stok_fisik wajib diisi' }).nonnegative(), + alasan: z.string().optional().nullable(), + }), +}); diff --git a/src/schemas/supplier.schema.ts b/src/schemas/supplier.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..f7e4a7920d1e28f51b5c6d4c9a363c6963fab768 --- /dev/null +++ b/src/schemas/supplier.schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +const booleanOrNumber = z.union([z.boolean(), z.number()]) + .transform((val) => (typeof val === 'boolean' ? (val ? 1 : 0) : val)); + +export const createSupplierSchema = z.object({ + body: z.object({ + nama: z.string({ required_error: 'Nama supplier wajib diisi' }).min(1, 'Nama supplier tidak boleh kosong'), + kontak: z.string().optional().nullable(), + nomor_hp: z.string().optional().nullable(), + alamat: z.string().optional().nullable(), + hutang: z.number().optional().default(0), + aktif: booleanOrNumber.optional().default(1), + }), +}); + +export const updateSupplierSchema = z.object({ + body: z.object({ + nama: z.string().optional(), + kontak: z.string().optional().nullable(), + nomor_hp: z.string().optional().nullable(), + alamat: z.string().optional().nullable(), + hutang: z.number().optional(), + aktif: booleanOrNumber.optional(), + }), +}); diff --git a/src/schemas/transaksi.schema.ts b/src/schemas/transaksi.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..734c511cf15b77e7258d3dd3a717c9001168e956 --- /dev/null +++ b/src/schemas/transaksi.schema.ts @@ -0,0 +1,30 @@ +import { z } from 'zod'; + +export const createTransaksiSchema = z.object({ + body: z.object({ + member_id: z.number().optional().nullable(), + local_id: z.string().optional().nullable(), + catatan: z.string().optional().nullable(), + items: z.array(z.object({ + produk_id: z.number({ required_error: 'produk_id item wajib diisi' }), + qty: z.number({ required_error: 'qty item wajib diisi' }).positive(), + })).min(1, 'Transaksi minimal harus memiliki 1 item'), + pembayaran: z.array(z.object({ + metode: z.enum(['tunai', 'qris', 'transfer', 'bon', 'voucher'], { required_error: 'metode pembayaran wajib diisi' }), + nominal: z.number({ required_error: 'nominal pembayaran wajib diisi' }).nonnegative(), + referensi: z.string().optional().nullable(), + })).min(1, 'Transaksi minimal harus memiliki 1 metode pembayaran'), + }), +}); + +export const createTransaksiReturSchema = z.object({ + body: z.object({ + transaksi_id: z.number({ required_error: 'transaksi_id wajib diisi' }), + produk_id: z.number({ required_error: 'produk_id wajib diisi' }), + qty: z.number({ required_error: 'Kuantitas retur (qty) wajib diisi' }).positive(), + alasan: z.string({ required_error: 'Alasan retur wajib diisi' }).min(1), + tipe: z.enum(['refund', 'exchange'], { required_error: 'Tipe retur wajib diisi (refund/exchange)' }), + nominal_kembali: z.number().optional().default(0), + stok_kembali: z.number().optional().default(1), + }), +}); diff --git a/src/schemas/upload.schema.ts b/src/schemas/upload.schema.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b8c52a3821e2bfeaf823fafb9f7b120b72b5eca --- /dev/null +++ b/src/schemas/upload.schema.ts @@ -0,0 +1,8 @@ +import { z } from 'zod'; + +export const uploadSchema = z.object({ + body: z.object({ + images: z.array(z.string({ required_error: 'Images array is required' })), + prefix: z.string().optional().default('img'), + }), +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4948ec7c735ecfde2e1b2ceb6cc0b22286311ef --- /dev/null +++ b/src/server.ts @@ -0,0 +1,10 @@ +import app from './app'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const PORT = process.env.PORT || 5000; + +app.listen(PORT, () => { + console.log(`[Server]: Server sedang berjalan di http://localhost:${PORT}`); +}); diff --git a/src/services/auth.service.ts b/src/services/auth.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b09670d373842f5bfdbaa587517c790b5375eb3b --- /dev/null +++ b/src/services/auth.service.ts @@ -0,0 +1,115 @@ +import prisma from '../utils/db'; +import bcrypt from 'bcryptjs'; +import { createAccessToken, createRefreshToken, verifyToken } from '../utils/jwt'; +import { catatAudit } from '../utils/audit'; +import { APIError } from '../utils/errors'; + +export class AuthService { + static async login(params: { + username: string; + passwordSecret: string; + ip: string; + userAgent: string; + }) { + const { username, passwordSecret, ip, userAgent } = params; + + // Search for user + const user = await prisma.user.findFirst({ + where: { + username, + aktif: 1, + }, + }); + + if (!user) { + // Log failed audit + await catatAudit({ + aksi: 'login_gagal', + entitas: 'user', + ipAddress: ip, + userAgent: userAgent, + dataBaru: { username }, + }); + throw new APIError('Username atau password salah', 401); + } + + const isMatch = await bcrypt.compare(passwordSecret, user.password_hash); + if (!isMatch) { + // Log failed audit + await catatAudit({ + userId: user.id, + aksi: 'login_gagal', + entitas: 'user', + entitasId: user.id, + ipAddress: ip, + userAgent: userAgent, + dataBaru: { username }, + }); + throw new APIError('Username atau password salah', 401); + } + + // Log successful login + await catatAudit({ + userId: user.id, + aksi: 'login_sukses', + entitas: 'user', + entitasId: user.id, + ipAddress: ip, + userAgent: userAgent, + }); + + // Generate tokens + const userData = { sub: user.username, role: user.role }; + const accessToken = createAccessToken(userData); + const refreshToken = createRefreshToken(userData); + + return { + user, + accessToken, + refreshToken, + }; + } + + static async refresh(refreshToken: string) { + const payload = verifyToken(refreshToken); + if (!payload || payload.type !== 'refresh') { + throw new APIError('Refresh token tidak valid atau kedaluwarsa', 401); + } + + const username = payload.sub; + const role = payload.role; + + const user = await prisma.user.findFirst({ + where: { + username, + aktif: 1, + }, + }); + + if (!user) { + throw new APIError('Pengguna tidak aktif atau tidak ditemukan', 401); + } + + const userData = { sub: username, role }; + const newAccessToken = createAccessToken(userData); + const newRefreshToken = createRefreshToken(userData); + + return { + role: user.role, + accessToken: newAccessToken, + refreshToken: newRefreshToken, + }; + } + + static async logout(userId: number, ip: string, userAgent: string) { + await catatAudit({ + userId, + aksi: 'logout', + entitas: 'user', + entitasId: userId, + ipAddress: ip, + userAgent, + }); + return true; + } +} diff --git a/src/services/bon.service.ts b/src/services/bon.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..9758376a32d4381c68299634b56e966357b6b3b6 --- /dev/null +++ b/src/services/bon.service.ts @@ -0,0 +1,188 @@ +import prisma from '../utils/db'; +import { catatAudit } from '../utils/audit'; +import { APIError } from '../utils/errors'; + +export class BonService { + static async getBon(skip = 0, limit = 10, statusFilter?: 'aktif' | 'lunas' | 'macet', memberId?: number) { + const where: any = {}; + if (statusFilter) where.status = statusFilter; + if (memberId) where.member_id = memberId; + + return await prisma.bon.findMany({ + where, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + include: { + member: true, + transaksi: true, + }, + }); + } + + static async getBonById(id: number) { + const bon = await prisma.bon.findUnique({ + where: { id }, + include: { + member: true, + transaksi: true, + bon_cicilan: true, + }, + }); + if (!bon) { + throw new APIError('Pencatatan kasbon tidak ditemukan', 404); + } + return bon; + } + + static async bayarCicilan(params: { + bonId: number; + nominal: number; + metode: 'tunai' | 'transfer'; + actorId: number; + ip: string; + userAgent: string; + }) { + const { bonId, nominal, metode, actorId, ip, userAgent } = params; + + const bon = await this.getBonById(bonId); + if (bon.status === 'lunas') { + throw new APIError('Kasbon ini sudah lunas didebet sebelumnya', 400); + } + + const sisaBon = Number(bon.sisa_bon); + if (nominal > sisaBon) { + throw new APIError(`Nominal pembayaran Rp ${nominal.toLocaleString('id-ID')} melebihi sisa tagihan Rp ${sisaBon.toLocaleString('id-ID')}`, 400); + } + + // Pastikan kasir memiliki shift aktif + const shift = await prisma.shift.findFirst({ + where: { + user_id: actorId, + status: 'buka', + }, + }); + + if (!shift) { + throw new APIError('Shift kasir Anda belum dibuka. Harus membuka shift terlebih dahulu untuk menerima pembayaran.', 400); + } + + // 1. Buat record cicilan + await prisma.bon_cicilan.create({ + data: { + bon_id: bon.id, + nominal: nominal, + metode: metode, + user_id: actorId, + }, + }); + + // 2. Kurangi sisa bon + const sisaLama = sisaBon; + const sisaBaru = sisaLama - nominal; + const statusBaru = sisaBaru <= 0 ? 'lunas' : bon.status; + + await prisma.bon.update({ + where: { id: bonId }, + data: { + sisa_bon: sisaBaru, + status: statusBaru as any, + }, + }); + + // 3. Kurangi total bon member + const totalBonMemberLama = Number(bon.member.total_bon); + const totalBonMemberBaru = Math.max(0, totalBonMemberLama - nominal); + + await prisma.member.update({ + where: { id: bon.member_id }, + data: { total_bon: totalBonMemberBaru }, + }); + + // 4. Masukkan ke kas shift + const updateShift: any = {}; + if (metode === 'tunai') { + updateShift.total_tunai = Number(shift.total_tunai) + nominal; + } else if (metode === 'transfer') { + updateShift.total_transfer = Number(shift.total_transfer) + nominal; + } + + await prisma.shift.update({ + where: { id: shift.id }, + data: updateShift, + }); + + // 5. Audit log + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'bon', + entitasId: bon.id, + dataLama: { sisa_bon: sisaLama, status: bon.status }, + dataBaru: { sisa_bon: sisaBaru, status: statusBaru, pembayaran_cicilan: nominal }, + ipAddress: ip, + userAgent, + }); + + return await this.getBonById(bonId); + } + + static async kirimReminderWA(bonId: number) { + const bon = await prisma.bon.findUnique({ + where: { id: bonId }, + include: { member: true }, + }); + + if (!bon || !bon.member) { + throw new APIError('Gagal membuat pengingat WhatsApp. Data member tidak ditemukan.', 400); + } + + const { nama, nomor_hp } = bon.member; + if (!nomor_hp) { + throw new APIError('Gagal membuat pengingat WhatsApp. Nomor HP member tidak terisi.', 400); + } + + // Bersihkan nomor HP + let cleanedNo = nomor_hp.trim(); + if (cleanedNo.startsWith('0')) { + cleanedNo = '62' + cleanedNo.slice(1); + } else if (cleanedNo.startsWith('+')) { + cleanedNo = cleanedNo.slice(1); + } + + // Format pesan + const jatuhTempoStr = bon.jatuh_tempo + ? new Date(bon.jatuh_tempo).toLocaleDateString('id-ID', { day: '2-digit', month: '2-digit', year: 'numeric' }) + : 'secepatnya'; + const sisaRupiah = Math.floor(Number(bon.sisa_bon)).toLocaleString('id-ID').replace(/,/g, '.'); + + const pesan = + `Halo *${nama}*,\n\n` + + `Kami dari *Tokiva POS* ingin mengingatkan bahwa Anda memiliki tagihan kasbon ` + + `sebesar *Rp ${sisaRupiah}* yang jatuh tempo pada tanggal *{jatuh_tempo_str}*.\n\n` + + `Silakan melakukan pembayaran cicilan atau pelunasan melalui kasir Tokiva. ` + + `Terima kasih atas kerjasamanya!`; + const formatPesan = pesan.replace('{jatuh_tempo_str}', jatuhTempoStr); + + const encodedMessage = encodeURIComponent(formatPesan); + const waLink = `https://wa.me/${cleanedNo}?text=${encodedMessage}`; + + // Tandai reminder terkirim + await prisma.bon.update({ + where: { id: bonId }, + data: { reminder_sent: 1 }, + }); + + // Catat notifikasi + await prisma.notifikasi.create({ + data: { + tipe: 'bon_jatuh_tempo', + judul: `WhatsApp Tagihan Dikirim: ${nama}`, + pesan: `Link WhatsApp pengingat tagihan Rp ${sisaRupiah} untuk ${nama} telah dibuat.`, + referensi_id: bon.id, + }, + }); + + return waLink; + } +} diff --git a/src/services/diskon.service.ts b/src/services/diskon.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..abb3e1344a5a5c660be971c974919836a1a878d7 --- /dev/null +++ b/src/services/diskon.service.ts @@ -0,0 +1,214 @@ +import prisma from '../utils/db'; +import { APIError } from '../utils/errors'; + +export class DiskonService { + static async getActiveDiscounts(isMember: boolean) { + const now = new Date(); + + // Filter diskon yang aktif dan periode tanggalnya cocok + const discounts = await prisma.diskon.findMany({ + where: { + is_aktif: 1, + tgl_mulai: { lte: now }, + tgl_selesai: { gte: now }, + ...(isMember ? {} : { member_only: 0 }), + }, + }); + + const currentTimeString = now.toTimeString().split(' ')[0]; // "HH:MM:SS" + + const activeDiscounts = discounts.filter((d) => { + if (d.tipe === 'flash_sale') { + if (d.jam_mulai && d.jam_selesai) { + const jamMulai = new Date(d.jam_mulai).toTimeString().split(' ')[0]; + const jamSelesai = new Date(d.jam_selesai).toTimeString().split(' ')[0]; + return currentTimeString >= jamMulai && currentTimeString <= jamSelesai; + } + return true; + } + return true; + }); + + return activeDiscounts; + } + + static async hitungDiskonDanHarga(items: Array<{ produk_id: number; qty: number }>, memberId?: number | null) { + const isMember = memberId !== undefined && memberId !== null; + const activePromos = await this.getActiveDiscounts(isMember); + + // 1. Hitung harga dasar bertingkat per item (volume pricing) + const cartItems: any[] = []; + let subtotalDasar = 0; + + for (const item of items) { + const produkId = item.produk_id; + const qty = item.qty; + + const produk = await prisma.produk.findUnique({ + where: { id: produkId }, + include: { harga_tingkat: true }, + }); + + if (!produk) { + throw new APIError(`Produk ID ${produkId} tidak ditemukan.`, 404); + } + + // Tentukan harga dasar berdasarkan volume pricing (harga_tingkat) + let hargaSatuan = Number(produk.harga_jual); // Default ecer + + if (produk.harga_tingkat && produk.harga_tingkat.length > 0) { + // Urutkan berdasarkan min_qty desc untuk mencari batas tertinggi yang terpenuhi + const sortedTingkat = [...produk.harga_tingkat].sort((a, b) => Number(b.min_qty) - Number(a.min_qty)); + for (const ht of sortedTingkat) { + if (qty >= Number(ht.min_qty)) { + hargaSatuan = Number(ht.harga); + break; + } + } + } + + const itemSubtotal = qty * hargaSatuan; + subtotalDasar += itemSubtotal; + + cartItems.push({ + produk, + qty, + harga_original: hargaSatuan, + diskon_item: 0, + diskon_id: null, + subtotal: itemSubtotal, + }); + } + + // 2. Terapkan diskon per item (per_item_persen, per_item_nominal, flash_sale) + for (const item of cartItems) { + const produk = item.produk; + const qty = item.qty; + const hargaOriginal = item.harga_original; + + let bestDiscount = 0; + let bestDiskonId: number | null = null; + + for (const promo of activePromos) { + // Cek apakah promo berlaku untuk produk ini + let isApplicable = false; + if (!promo.produk_ids) { + // Berlaku untuk semua produk + isApplicable = true; + } else { + try { + let pIds: number[] = []; + if (typeof promo.produk_ids === 'string') { + pIds = JSON.parse(promo.produk_ids); + } else if (Array.isArray(promo.produk_ids)) { + pIds = promo.produk_ids as number[]; + } + if (pIds.includes(produk.id)) { + isApplicable = true; + } + } catch (e) { + isApplicable = false; + } + } + + if (!isApplicable) continue; + + // Hitung nilai diskon + let currentDiscount = 0; + if (promo.tipe === 'per_item_persen' || promo.tipe === 'flash_sale') { + currentDiscount = hargaOriginal * (Number(promo.nilai) / 100) * qty; + } else if (promo.tipe === 'per_item_nominal') { + currentDiscount = Number(promo.nilai) * qty; + } + + if (currentDiscount > bestDiscount) { + bestDiscount = currentDiscount; + bestDiskonId = promo.id; + } + } + + if (bestDiscount > 0) { + item.diskon_item = bestDiscount; + item.diskon_id = bestDiskonId; + item.subtotal = (hargaOriginal * qty) - bestDiscount; + } + } + + // Kalkulasi subtotal setelah item-level discounts + let subtotalSetelahItem = cartItems.reduce((acc, item) => acc + item.subtotal, 0); + const totalDiskonItem = cartItems.reduce((acc, item) => acc + item.diskon_item, 0); + + // 3. Terapkan diskon bundling (paket promo) + let totalDiskonBundling = 0; + const bundlingPromos = activePromos.filter((p) => p.tipe === 'bundling'); + + for (const promo of bundlingPromos) { + const bundleItems = await prisma.diskon_bundling.findMany({ + where: { diskon_id: promo.id }, + }); + if (bundleItems.length === 0) continue; + + // Hitung berapa banyak paket bundle yang bisa dibuat dari keranjang + let maxPossibleBundles = 999999; + for (const bItem of bundleItems) { + const matchingCartItem = cartItems.find((item) => item.produk.id === bItem.produk_id); + if (!matchingCartItem) { + maxPossibleBundles = 0; + break; + } + const availableMultiples = Math.floor(matchingCartItem.qty / Number(bItem.qty)); + if (availableMultiples < maxPossibleBundles) { + maxPossibleBundles = availableMultiples; + } + } + + if (maxPossibleBundles > 0) { + const diskonBundleTotal = Number(promo.nilai) * maxPossibleBundles; + totalDiskonBundling += diskonBundleTotal; + subtotalSetelahItem -= diskonBundleTotal; + } + } + + // 4. Terapkan diskon tingkat transaksi (total_persen, total_nominal, member) + const transactionPromos = activePromos.filter((p) => + ['total_persen', 'total_nominal', 'member'].includes(p.tipe) + ); + + let bestTransDiscount = 0; + for (const promo of transactionPromos) { + // Cek minimal belanja + if (subtotalSetelahItem < Number(promo.min_belanja)) { + continue; + } + + let currentTransDiscount = 0; + if (promo.tipe === 'total_persen' || promo.tipe === 'member') { + currentTransDiscount = subtotalSetelahItem * (Number(promo.nilai) / 100); + } else if (promo.tipe === 'total_nominal') { + currentTransDiscount = Number(promo.nilai); + } + + if (currentTransDiscount > bestTransDiscount) { + bestTransDiscount = currentTransDiscount; + } + } + + const totalDiskonTransaksi = bestTransDiscount; + + // Hitung total akhir + let totalAkhir = subtotalSetelahItem - totalDiskonTransaksi; + if (totalAkhir < 0) { + totalAkhir = 0; + } + + return { + cart_items: cartItems, + subtotal_dasar: subtotalDasar, + total_diskon_item: totalDiskonItem, + total_diskon_bundling: totalDiskonBundling, + total_diskon_transaksi: totalDiskonTransaksi, + total_diskon_keseluruhan: totalDiskonItem + totalDiskonBundling + totalDiskonTransaksi, + total_akhir: totalAkhir, + }; + } +} diff --git a/src/services/kategori.service.ts b/src/services/kategori.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..faf9c897bf155c82e6062d10d4b5da82320175e7 --- /dev/null +++ b/src/services/kategori.service.ts @@ -0,0 +1,149 @@ +import prisma from '../utils/db'; +import { catatAudit } from '../utils/audit'; +import { moveToTrash } from '../utils/trash'; +import { APIError } from '../utils/errors'; + +export class KategoriService { + static async getKategori(skip = 0, limit = 100, pencarian?: string) { + const where: any = { + deleted_at: null, + }; + + if (pencarian) { + where.nama = { + contains: pencarian, + }; + } + + return await prisma.kategori.findMany({ + where, + skip, + take: limit, + }); + } + + static async getKategoriById(id: number) { + const kategori = await prisma.kategori.findFirst({ + where: { id, deleted_at: null }, + }); + if (!kategori) { + throw new APIError('Kategori tidak ditemukan', 404); + } + return kategori; + } + + static async create(data: any, actorId: number, ip: string, userAgent: string) { + // Check duplicate + const existing = await prisma.kategori.findFirst({ + where: { nama: data.nama }, + }); + + if (existing) { + if (existing.deleted_at !== null || existing.aktif === 0) { + // Reactivate + const reactivated = await prisma.kategori.update({ + where: { id: existing.id }, + data: { + deleted_at: null, + aktif: 1, + icon: data.icon ?? existing.icon, + }, + }); + return reactivated; + } + throw new APIError('Nama kategori sudah digunakan', 400); + } + + const kategori = await prisma.kategori.create({ + data: { + nama: data.nama, + icon: data.icon ?? null, + aktif: data.aktif ?? 1, + }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'create', + entitas: 'kategori', + entitasId: kategori.id, + dataBaru: { nama: kategori.nama, icon: kategori.icon }, + ipAddress: ip, + userAgent, + }); + + return kategori; + } + + static async update(id: number, data: any, actorId: number, ip: string, userAgent: string) { + const kategori = await this.getKategoriById(id); + + const dataLama = { nama: kategori.nama, icon: kategori.icon, aktif: kategori.aktif }; + + if (data.nama !== undefined) { + const dup = await prisma.kategori.findFirst({ + where: { nama: data.nama, id: { not: id } }, + }); + if (dup) { + throw new APIError('Nama kategori sudah digunakan oleh kategori lain', 400); + } + } + + const updateData: any = {}; + if (data.nama !== undefined) updateData.nama = data.nama; + if (data.icon !== undefined) updateData.icon = data.icon; + if (data.aktif !== undefined) updateData.aktif = data.aktif; + + const updated = await prisma.kategori.update({ + where: { id }, + data: updateData, + }); + + const dataBaru = { nama: updated.nama, icon: updated.icon, aktif: updated.aktif }; + + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'kategori', + entitasId: updated.id, + dataLama, + dataBaru, + ipAddress: ip, + userAgent, + }); + + return updated; + } + + static async delete(id: number, actorId: number, ip: string, userAgent: string) { + const kategori = await this.getKategoriById(id); + + // Check if active products reference this category + const productCount = await prisma.produk.count({ + where: { kategori_id: id, deleted_at: null }, + }); + + if (productCount > 0) { + throw new APIError(`Tidak dapat menghapus kategori. Masih digunakan oleh ${productCount} produk.`, 400); + } + + await moveToTrash({ + instance: kategori, + entityType: 'kategori', + actorId, + }); + + await catatAudit({ + userId: actorId, + aksi: 'delete', + entitas: 'kategori', + entitasId: kategori.id, + dataLama: { nama: kategori.nama, aktif: 1 }, + dataBaru: { nama: kategori.nama, aktif: 0 }, + ipAddress: ip, + userAgent, + }); + + return true; + } +} diff --git a/src/services/member.service.ts b/src/services/member.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b2866fe7125091b55d6b22450c40639bd5e8377 --- /dev/null +++ b/src/services/member.service.ts @@ -0,0 +1,196 @@ +import prisma from '../utils/db'; +import { catatAudit } from '../utils/audit'; +import { moveToTrash } from '../utils/trash'; +import { APIError } from '../utils/errors'; + +export class MemberService { + static async getMembers(skip = 0, limit = 10, pencarian?: string) { + const where: any = { + deleted_at: null, + }; + + if (pencarian) { + where.OR = [ + { nama: { contains: pencarian } }, + { kode: { contains: pencarian } }, + { nomor_hp: { contains: pencarian } }, + ]; + } + + return await prisma.member.findMany({ + where, + skip, + take: limit, + }); + } + + static async getMemberById(id: number) { + const member = await prisma.member.findFirst({ + where: { id, deleted_at: null }, + }); + if (!member) { + throw new APIError('Member tidak ditemukan', 404); + } + return member; + } + + static async create(data: any, actorId: number, ip: string, userAgent: string) { + const existingPhone = await prisma.member.findFirst({ + where: { nomor_hp: data.nomor_hp }, + }); + const existingCode = await prisma.member.findUnique({ + where: { kode: data.kode }, + }); + + const existing = existingPhone || existingCode; + if (existing) { + if (existing.deleted_at === null) { + const fieldType = existing.id === existingPhone?.id ? 'Nomor HP' : 'Kode'; + const fieldVal = existing.id === existingPhone?.id ? data.nomor_hp : data.kode; + throw new APIError(`${fieldType} member '${fieldVal}' sudah terdaftar/digunakan`, 400); + } else { + // Soft deleted duplicate. Reactivate and update! + const restored = await prisma.member.update({ + where: { id: existing.id }, + data: { + deleted_at: null, + aktif: 1, + kode: data.kode, + nama: data.nama, + nomor_hp: data.nomor_hp, + alamat: data.alamat ?? null, + limit_bon: data.limit_bon ?? 0, + foto_url: data.foto_url ?? null, + }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'restore', + entitas: 'member', + entitasId: restored.id, + dataBaru: { kode: restored.kode, nama: restored.nama, nomor_hp: restored.nomor_hp, limit_bon: Number(restored.limit_bon) }, + ipAddress: ip, + userAgent, + }); + + return restored; + } + } + + const member = await prisma.member.create({ + data: { + kode: data.kode, + nama: data.nama, + nomor_hp: data.nomor_hp, + alamat: data.alamat ?? null, + limit_bon: data.limit_bon ?? 0, + total_bon: 0, + foto_url: data.foto_url ?? null, + aktif: data.aktif ?? 1, + }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'create', + entitas: 'member', + entitasId: member.id, + dataBaru: { kode: member.kode, nama: member.nama, nomor_hp: member.nomor_hp, limit_bon: Number(member.limit_bon) }, + ipAddress: ip, + userAgent, + }); + + return member; + } + + static async update(id: number, data: any, actorId: number, ip: string, userAgent: string) { + const member = await this.getMemberById(id); + + const dataLama = { + nama: member.nama, + nomor_hp: member.nomor_hp, + alamat: member.alamat, + total_poin: member.total_poin, + limit_bon: Number(member.limit_bon), + total_bon: Number(member.total_bon), + foto_url: member.foto_url, + aktif: member.aktif, + }; + + if (data.nomor_hp !== undefined) { + const dup = await prisma.member.findFirst({ + where: { nomor_hp: data.nomor_hp, id: { not: id } }, + }); + if (dup) { + throw new APIError('Nomor HP tersebut sudah digunakan oleh member lain', 400); + } + } + + const updateData: any = {}; + if (data.nama !== undefined) updateData.nama = data.nama; + if (data.nomor_hp !== undefined) updateData.nomor_hp = data.nomor_hp; + if (data.alamat !== undefined) updateData.alamat = data.alamat; + if (data.total_poin !== undefined) updateData.total_poin = data.total_poin; + if (data.limit_bon !== undefined) updateData.limit_bon = data.limit_bon; + if (data.total_bon !== undefined) updateData.total_bon = data.total_bon; + if (data.foto_url !== undefined) updateData.foto_url = data.foto_url; + if (data.aktif !== undefined) updateData.aktif = data.aktif; + + const updated = await prisma.member.update({ + where: { id }, + data: updateData, + }); + + const dataBaru = { + nama: updated.nama, + nomor_hp: updated.nomor_hp, + alamat: updated.alamat, + total_poin: updated.total_poin, + limit_bon: Number(updated.limit_bon), + total_bon: Number(updated.total_bon), + foto_url: updated.foto_url, + aktif: updated.aktif, + }; + + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'member', + entitasId: updated.id, + dataLama, + dataBaru, + ipAddress: ip, + userAgent, + }); + + return updated; + } + + static async delete(id: number, actorId: number, ip: string, userAgent: string) { + const member = await this.getMemberById(id); + + if (Number(member.total_bon) > 0) { + throw new APIError(`Tidak dapat menghapus member. Masih memiliki tanggungan kasbon sebesar Rp ${Number(member.total_bon).toLocaleString('id-ID')}`, 400); + } + + await moveToTrash({ + instance: member, + entityType: 'member', + actorId, + }); + + await catatAudit({ + userId: actorId, + aksi: 'delete', + entitas: 'member', + entitasId: member.id, + dataLama: { kode: member.kode, nama: member.nama, aktif: 1 }, + dataBaru: { kode: member.kode, nama: member.nama, aktif: 0 }, + ipAddress: ip, + userAgent, + }); + + return true; + } +} diff --git a/src/services/ops.service.ts b/src/services/ops.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bde3ea6315e2b7f7a7d0bdaa58617183d5a0495 --- /dev/null +++ b/src/services/ops.service.ts @@ -0,0 +1,368 @@ +import prisma from '../utils/db'; +import { catatAudit } from '../utils/audit'; +import { generateKodeShift } from '../utils/kodeGenerator'; +import { APIError } from '../utils/errors'; + +export class OpsService { + static async getActiveShift(userId: number) { + return await prisma.shift.findFirst({ + where: { + user_id: userId, + status: 'buka', + }, + }); + } + + static async bukaShift(modalAwal: number, catatan: string | null, actorId: number, ip: string, userAgent: string) { + const existing = await this.getActiveShift(actorId); + if (existing) { + throw new APIError(`Anda sudah memiliki shift yang aktif (${existing.kode}). Tutup terlebih dahulu.`, 400); + } + + const kode = await generateKodeShift(); + + const shift = await prisma.shift.create({ + data: { + user_id: actorId, + kode: kode, + modal_awal: modalAwal, + waktu_buka: new Date(), + status: 'buka', + catatan: catatan, + }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'shift', + entitasId: shift.id, + dataBaru: { kode, modal_awal: modalAwal }, + ipAddress: ip, + userAgent, + }); + + return shift; + } + + static async tutupShift(modalAkhirFisik: number, catatan: string | null, actorId: number, ip: string, userAgent: string) { + const shift = await this.getActiveShift(actorId); + if (!shift) { + throw new APIError('Tidak ada shift aktif yang dapat ditutup.', 400); + } + + const saldoSistem = Number(shift.modal_awal) + Number(shift.total_tunai); + const selisih = modalAkhirFisik - saldoSistem; + + let newCatatan = shift.catatan || ''; + if (catatan) { + newCatatan = `${newCatatan} | Penutupan: ${catatan}`.trim().replace(/^ \| /, ''); + } + + const updated = await prisma.shift.update({ + where: { id: shift.id }, + data: { + total_selisih: selisih, + waktu_tutup: new Date(), + status: 'tutup', + catatan: newCatatan, + }, + }); + + // Buat notifikasi + const selisihRupiah = Math.floor(selisih).toLocaleString('id-ID').replace(/,/g, '.'); + const pesanNotif = `Kasir (User ID ${actorId}) telah menutup shift ${shift.kode}. Modal Awal: Rp ${Number(shift.modal_awal).toLocaleString('id-ID')}. Selisih Kas Fisik vs Sistem: Rp ${selisihRupiah}.`; + + await prisma.notifikasi.create({ + data: { + tipe: 'shift_tutup', + judul: `Shift Ditutup: ${shift.kode}`, + pesan: pesanNotif, + referensi_id: shift.id, + }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'shift', + entitasId: shift.id, + dataLama: { status: 'buka' }, + dataBaru: { status: 'tutup', total_selisih: selisih, fisik: modalAkhirFisik }, + ipAddress: ip, + userAgent, + }); + + return updated; + } + + // Settings + static async getSettings() { + return await prisma.pengaturan.findMany(); + } + + static async getSettingByKey(key: string) { + const setting = await prisma.pengaturan.findUnique({ + where: { kunci: key }, + }); + if (!setting) { + throw new APIError(`Pengaturan '${key}' tidak ditemukan`, 404); + } + return setting.nilai; + } + + static async updateSetting(key: string, data: any, actorId: number, ip: string, userAgent: string) { + const existing = await prisma.pengaturan.findUnique({ + where: { kunci: key }, + }); + + const dataLama = existing ? { nilai: existing.nilai } : null; + + const setting = await prisma.pengaturan.upsert({ + where: { kunci: key }, + create: { + kunci: key, + nilai: data.nilai, + deskripsi: data.deskripsi ?? null, + }, + update: { + nilai: data.nilai, + deskripsi: data.deskripsi !== undefined ? data.deskripsi : undefined, + }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'pengaturan', + entitasId: setting.id, + dataLama, + dataBaru: { nilai: data.nilai }, + ipAddress: ip, + userAgent, + }); + + return setting; + } + + // Notifikasi + static async getNotifications(skip = 0, limit = 20, isDibaca?: number) { + const where: any = {}; + if (isDibaca !== undefined) { + where.is_dibaca = isDibaca; + } + + return await prisma.notifikasi.findMany({ + where, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + }); + } + + static async markAllNotifAsRead() { + const result = await prisma.notifikasi.updateMany({ + where: { is_dibaca: 0 }, + data: { is_dibaca: 1 }, + }); + return result.count; + } + + static async markNotifAsRead(id: number) { + const notif = await prisma.notifikasi.findUnique({ where: { id } }); + if (!notif) { + throw new APIError('Notifikasi tidak ditemukan', 404); + } + + return await prisma.notifikasi.update({ + where: { id }, + data: { is_dibaca: 1 }, + }); + } + + static async getUnreadNotifCount() { + return await prisma.notifikasi.count({ + where: { is_dibaca: 0 }, + }); + } + + // Dashboard Reports + static async getDashboardSummary() { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + + // 1. Total Penjualan Hari Ini + const sumResult = await prisma.transaksi.aggregate({ + where: { + tanggal: { gte: today, lt: tomorrow }, + status: { not: 'void' }, + }, + _sum: { total: true }, + }); + const penjualanHariIni = Number(sumResult._sum.total ?? 0); + + // 2. Total Transaksi Hari Ini + const jumlahTransaksiHariIni = await prisma.transaksi.count({ + where: { + tanggal: { gte: today, lt: tomorrow }, + status: { not: 'void' }, + }, + }); + + // 3. Total Produk Terdaftar + const totalProduk = await prisma.produk.count({ + where: { deleted_at: null }, + }); + + // 4. Produk Stok Rendah + const products = await prisma.produk.findMany({ + where: { deleted_at: null }, + }); + const stokRendah = products.filter((p) => Number(p.stok) <= Number(p.stok_min)).length; + + // 5. Total Piutang + const sumPiutang = await prisma.member.aggregate({ + where: { aktif: 1 }, + _sum: { total_bon: true }, + }); + const totalPiutang = Number(sumPiutang._sum.total_bon ?? 0); + + // 6. Grafik penjualan 7 hari terakhir + const grafik7Hari: any[] = []; + const todayForGraph = new Date(); + todayForGraph.setHours(0, 0, 0, 0); + + for (let i = 6; i >= 0; i--) { + const targetDay = new Date(todayForGraph); + targetDay.setDate(targetDay.getDate() - i); + const nextDay = new Date(targetDay); + nextDay.setDate(nextDay.getDate() + 1); + + const daySum = await prisma.transaksi.aggregate({ + where: { + tanggal: { gte: targetDay, lt: nextDay }, + status: { not: 'void' }, + }, + _sum: { total: true }, + }); + + const dayStr = `${String(targetDay.getDate()).padStart(2, '0')}/${String(targetDay.getMonth() + 1).padStart(2, '0')}`; + grafik7Hari.push({ + tanggal: dayStr, + nominal: Number(daySum._sum.total ?? 0), + }); + } + + return { + penjualan_hari_ini: penjualanHariIni, + jumlah_transaksi_hari_ini: jumlahTransaksiHariIni, + total_produk: totalProduk, + stok_rendah: stokRendah, + total_piutang: totalPiutang, + grafik_penjualan_7_hari: grafik7Hari, + }; + } + + static async buatLaporanPenjualan(tglMulai: Date, tglSelesai: Date) { + const endOfDay = new Date(tglSelesai); + endOfDay.setHours(23, 59, 59, 999); + + const transaksiList = await prisma.transaksi.findMany({ + where: { + tanggal: { gte: tglMulai, lte: endOfDay }, + status: { not: 'void' }, + }, + include: { + pembayaran: true, + }, + }); + + let totalOmset = 0; + let totalDiskon = 0; + const metodePembayaranSummary: Record = { + tunai: 0, + qris: 0, + transfer: 0, + bon: 0, + voucher: 0, + }; + + for (const t of transaksiList) { + totalOmset += Number(t.total); + totalDiskon += Number(t.diskon_total); + for (const p of t.pembayaran) { + if (metodePembayaranSummary[p.metode] !== undefined) { + metodePembayaranSummary[p.metode] += Number(p.nominal); + } + } + } + + const kontribusiPersen: Record = {}; + for (const k in metodePembayaranSummary) { + if (totalOmset > 0) { + kontribusiPersen[k] = Number(((metodePembayaranSummary[k] / totalOmset) * 100).toFixed(2)); + } else { + kontribusiPersen[k] = 0; + } + } + + return { + periode: { + mulai: tglMulai.toISOString().slice(0, 10), + selesai: tglSelesai.toISOString().slice(0, 10), + }, + total_transaksi: transaksiList.length, + total_omset: totalOmset, + total_diskon: totalDiskon, + metode_pembayaran: metodePembayaranSummary, + kontribusi_pembayaran_persen: kontribusiPersen, + }; + } + + static async buatLaporanLabaRugi(tglMulai: Date, tglSelesai: Date) { + const endOfDay = new Date(tglSelesai); + endOfDay.setHours(23, 59, 59, 999); + + const sumResult = await prisma.transaksi.aggregate({ + where: { + tanggal: { gte: tglMulai, lte: endOfDay }, + status: { not: 'void' }, + }, + _sum: { total: true }, + }); + const omset = Number(sumResult._sum.total ?? 0); + + const detailList = await prisma.transaksi_detail.findMany({ + where: { + transaksi: { + tanggal: { gte: tglMulai, lte: endOfDay }, + status: { not: 'void' }, + }, + }, + include: { + produk: true, + }, + }); + + const hpp = detailList.reduce((acc, detail) => { + const hargaBeli = Number(detail.produk?.harga_beli ?? detail.harga_satuan); + return acc + Number(detail.qty) * hargaBeli; + }, 0); + + const labaKotor = omset - hpp; + const marginLaba = omset > 0 ? Number(((labaKotor / omset) * 100).toFixed(2)) : 0; + + return { + periode: { + mulai: tglMulai.toISOString().slice(0, 10), + selesai: tglSelesai.toISOString().slice(0, 10), + }, + pendapatan_omset: omset, + harga_pokok_penjualan: hpp, + laba_kotor: labaKotor, + margin_laba_persen: marginLaba, + }; + } +} diff --git a/src/services/pengguna.service.ts b/src/services/pengguna.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..8092c907272eb184db5174b22a41d0b1a7993684 --- /dev/null +++ b/src/services/pengguna.service.ts @@ -0,0 +1,138 @@ +import prisma from '../utils/db'; +import bcrypt from 'bcryptjs'; +import { catatAudit } from '../utils/audit'; +import { APIError } from '../utils/errors'; + +export class PenggunaService { + static async getUsers(skip = 0, limit = 10, aktif?: number) { + const where: any = {}; + if (aktif !== undefined) { + where.aktif = aktif; + } + + return await prisma.user.findMany({ + where, + skip, + take: limit, + }); + } + + static async getUserById(userId: number) { + const user = await prisma.user.findUnique({ + where: { id: userId }, + }); + if (!user) { + throw new APIError('Pengguna tidak ditemukan', 404); + } + return user; + } + + static async create(data: any, actorId: number, ip: string, userAgent: string) { + const existing = await prisma.user.findUnique({ + where: { username: data.username }, + }); + if (existing) { + throw new APIError('Username sudah digunakan', 400); + } + + const hashedPassword = await bcrypt.hash(data.password, 10); + const user = await prisma.user.create({ + data: { + nama: data.nama, + username: data.username, + password_hash: hashedPassword, + role: data.role, + nomor_hp: data.nomor_hp, + foto_url: data.foto_url, + aktif: data.aktif ?? 1, + }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'create', + entitas: 'user', + entitasId: user.id, + dataBaru: { username: user.username, nama: user.nama, role: user.role }, + ipAddress: ip, + userAgent, + }); + + return user; + } + + static async update(userId: number, data: any, actorId: number, ip: string, userAgent: string) { + const user = await this.getUserById(userId); + + const dataLama = { + nama: user.nama, + role: user.role, + nomor_hp: user.nomor_hp, + foto_url: user.foto_url, + aktif: user.aktif, + }; + + const updateData: any = {}; + if (data.nama !== undefined) updateData.nama = data.nama; + if (data.role !== undefined) updateData.role = data.role; + if (data.nomor_hp !== undefined) updateData.nomor_hp = data.nomor_hp; + if (data.foto_url !== undefined) updateData.foto_url = data.foto_url; + if (data.aktif !== undefined) updateData.aktif = data.aktif; + + if (data.password !== undefined) { + updateData.password_hash = await bcrypt.hash(data.password, 10); + } + + const updatedUser = await prisma.user.update({ + where: { id: userId }, + data: updateData, + }); + + const dataBaru = { + nama: updatedUser.nama, + role: updatedUser.role, + nomor_hp: updatedUser.nomor_hp, + foto_url: updatedUser.foto_url, + aktif: updatedUser.aktif, + }; + + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'user', + entitasId: updatedUser.id, + dataLama, + dataBaru, + ipAddress: ip, + userAgent, + }); + + return updatedUser; + } + + static async delete(userId: number, actorId: number, ip: string, userAgent: string) { + const user = await this.getUserById(userId); + + if (user.id === actorId) { + throw new APIError('Anda tidak dapat menonaktifkan akun Anda sendiri', 400); + } + + await prisma.user.update({ + where: { id: userId }, + data: { aktif: 0 }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'delete', + entitas: 'user', + entitasId: user.id, + dataLama: { username: user.username, aktif: 1 }, + dataBaru: { username: user.username, aktif: 0 }, + ipAddress: ip, + userAgent, + }); + + return true; + } +} diff --git a/src/services/produk.service.ts b/src/services/produk.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..948af5cb25a65ed886a9f33a393c7c74554237d8 --- /dev/null +++ b/src/services/produk.service.ts @@ -0,0 +1,384 @@ +import prisma from '../utils/db'; +import { catatAudit } from '../utils/audit'; +import { moveToTrash } from '../utils/trash'; +import { StokService } from './stok.service'; +import { deleteFromSupabase } from '../utils/supabase'; +import { APIError } from '../utils/errors'; + +export class ProdukService { + static async getProduk(skip = 0, limit = 10, kategoriId?: number, supplierId?: number, pencarian?: string) { + const where: any = { + deleted_at: null, + }; + + if (kategoriId) where.kategori_id = kategoriId; + if (supplierId) where.supplier_id = supplierId; + + if (pencarian) { + where.OR = [ + { kode: { contains: pencarian } }, + { barcode: { contains: pencarian } }, + { nama: { contains: pencarian } }, + ]; + } + + const total = await prisma.produk.count({ where }); + + const products = await prisma.produk.findMany({ + where, + skip, + take: limit, + include: { + kategori: true, + supplier: true, + harga_tingkat: true, + }, + }); + + return { products, total }; + } + + static async getProdukById(id: number) { + const product = await prisma.produk.findFirst({ + where: { id, deleted_at: null }, + include: { + kategori: true, + supplier: true, + harga_tingkat: true, + }, + }); + if (!product) { + throw new APIError('Produk tidak ditemukan', 404); + } + return product; + } + + static async getProdukByBarcodeOrKode(term: string) { + const product = await prisma.produk.findFirst({ + where: { + OR: [ + { barcode: term }, + { kode: term }, + ], + deleted_at: null, + }, + include: { + kategori: true, + supplier: true, + harga_tingkat: true, + }, + }); + if (!product) { + throw new APIError('Produk dengan kode/barcode tersebut tidak ditemukan', 404); + } + return product; + } + + static async create(data: any, actorId: number, ip: string, userAgent: string) { + // Check duplicate + const existingCode = await prisma.produk.findFirst({ + where: { kode: data.kode, deleted_at: null }, + }); + const existingBarcode = data.barcode ? await prisma.produk.findFirst({ + where: { barcode: data.barcode, deleted_at: null }, + }) : null; + + const existing = existingCode || existingBarcode; + if (existing) { + const fieldType = existing.id === existingCode?.id ? 'Kode' : 'Barcode'; + const fieldVal = existing.id === existingCode?.id ? data.kode : data.barcode; + throw new APIError(`${fieldType} produk '${fieldVal}' sudah digunakan`, 400); + } + + // Check soft-deleted code or barcode + const softDeletedCode = await prisma.produk.findFirst({ + where: { kode: data.kode, NOT: { deleted_at: null } }, + }); + const softDeletedBarcode = data.barcode ? await prisma.produk.findFirst({ + where: { barcode: data.barcode, NOT: { deleted_at: null } }, + }) : null; + + const softDeleted = softDeletedCode || softDeletedBarcode; + if (softDeleted) { + // Reactivate + const restored = await prisma.produk.update({ + where: { id: softDeleted.id }, + data: { + deleted_at: null, + is_aktif: 1, + nama: data.nama, + kategori_id: data.kategori_id, + supplier_id: data.supplier_id ?? null, + satuan: data.satuan ?? 'pcs', + harga_beli: data.harga_beli, + harga_jual: data.harga_jual, + stok_min: data.stok_min ?? 5, + expired_date: data.expired_date ?? null, + foto_url: data.foto_url ?? null, + qr_url: data.qr_url ?? null, + deskripsi: data.deskripsi ?? null, + is_ecer: data.is_ecer ?? 1, + }, + }); + + // Reset pricing levels + await prisma.harga_tingkat.deleteMany({ + where: { produk_id: restored.id }, + }); + + if (data.harga_tingkat && data.harga_tingkat.length > 0) { + for (const ht of data.harga_tingkat) { + await prisma.harga_tingkat.create({ + data: { + produk_id: restored.id, + tingkat: ht.tingkat, + min_qty: ht.min_qty, + harga: ht.harga, + }, + }); + } + } + + await catatAudit({ + userId: actorId, + aksi: 'restore', + entitas: 'produk', + entitasId: restored.id, + dataBaru: { + kode: restored.kode, + nama: restored.nama, + harga_beli: Number(restored.harga_beli), + harga_jual: Number(restored.harga_jual), + }, + ipAddress: ip, + userAgent, + }); + + return restored; + } + + // Create new product + const product = await prisma.produk.create({ + data: { + kode: data.kode, + barcode: data.barcode ?? null, + nama: data.nama, + kategori_id: data.kategori_id, + supplier_id: data.supplier_id ?? null, + satuan: data.satuan ?? 'pcs', + harga_beli: data.harga_beli, + harga_jual: data.harga_jual, + stok: 0.00, + stok_min: data.stok_min ?? 5, + expired_date: data.expired_date ?? null, + foto_url: data.foto_url ?? null, + qr_url: data.qr_url ?? null, + deskripsi: data.deskripsi ?? null, + is_ecer: data.is_ecer ?? 1, + is_aktif: data.is_aktif ?? 1, + }, + }); + + // Create pricing levels + if (data.harga_tingkat && data.harga_tingkat.length > 0) { + for (const ht of data.harga_tingkat) { + await prisma.harga_tingkat.create({ + data: { + produk_id: product.id, + tingkat: ht.tingkat, + min_qty: ht.min_qty, + harga: ht.harga, + }, + }); + } + } + + // Tambah stok awal jika disediakan > 0 + if (data.stok && data.stok > 0) { + await StokService.tambahStok({ + produkId: product.id, + qty: data.stok, + hargaBeli: data.harga_beli, + batchNo: 'BATCH-AWAL', + expiredDate: data.expired_date ?? null, + supplierId: data.supplier_id ?? null, + userId: actorId, + catatan: 'Stok awal produk baru', + referensi: 'STOK-AWAL', + }); + } + + await catatAudit({ + userId: actorId, + aksi: 'create', + entitas: 'produk', + entitasId: product.id, + dataBaru: { + kode: product.kode, + nama: product.nama, + harga_jual: Number(product.harga_jual), + stok: Number(data.stok ?? 0), + }, + ipAddress: ip, + userAgent, + }); + + return product; + } + + static async update(id: number, data: any, actorId: number, ip: string, userAgent: string) { + const product = await this.getProdukById(id); + + const dataLama = { + kode: product.kode, + barcode: product.barcode, + nama: product.nama, + kategori_id: product.kategori_id, + supplier_id: product.supplier_id, + satuan: product.satuan, + harga_beli: Number(product.harga_beli), + harga_jual: Number(product.harga_jual), + stok: Number(product.stok), + stok_min: Number(product.stok_min), + is_ecer: product.is_ecer, + is_aktif: product.is_aktif, + }; + + const updateData: any = {}; + if (data.kode !== undefined) { + const dup = await prisma.produk.findFirst({ + where: { kode: data.kode, id: { not: id }, deleted_at: null }, + }); + if (dup) throw new APIError(`Kode produk '${data.kode}' sudah digunakan oleh produk lain`, 400); + updateData.kode = data.kode; + } + + if (data.barcode !== undefined) { + if (data.barcode) { + const dup = await prisma.produk.findFirst({ + where: { barcode: data.barcode, id: { not: id }, deleted_at: null }, + }); + if (dup) throw new APIError(`Barcode '${data.barcode}' sudah digunakan oleh produk lain`, 400); + } + updateData.barcode = data.barcode; + } + + if (data.nama !== undefined) updateData.nama = data.nama; + if (data.kategori_id !== undefined) updateData.kategori_id = data.kategori_id; + if (data.supplier_id !== undefined) updateData.supplier_id = data.supplier_id; + if (data.satuan !== undefined) updateData.satuan = data.satuan; + if (data.harga_beli !== undefined) updateData.harga_beli = data.harga_beli; + if (data.harga_jual !== undefined) updateData.harga_jual = data.harga_jual; + if (data.stok_min !== undefined) updateData.stok_min = data.stok_min; + if (data.expired_date !== undefined) updateData.expired_date = data.expired_date; + if (data.qr_url !== undefined) updateData.qr_url = data.qr_url; + if (data.deskripsi !== undefined) updateData.deskripsi = data.deskripsi; + if (data.is_ecer !== undefined) updateData.is_ecer = data.is_ecer; + if (data.is_aktif !== undefined) updateData.is_aktif = data.is_aktif; + + // Handle foto_url change and delete old images from Supabase + if (data.foto_url !== undefined && data.foto_url !== product.foto_url) { + const oldFoto = product.foto_url; + if (oldFoto) { + let urls: string[] = []; + try { + const parsed = JSON.parse(oldFoto); + if (Array.isArray(parsed)) { + urls = parsed; + } else { + urls = [String(parsed)]; + } + } catch (e) { + urls = [oldFoto]; + } + + const searchStr = '/storage/v1/object/public/produk/'; + for (const url of urls) { + if (url.includes(searchStr)) { + const filePath = url.split(searchStr).pop(); + if (filePath) { + await deleteFromSupabase('produk', filePath); + } + } + } + } + updateData.foto_url = data.foto_url; + } + + const updated = await prisma.produk.update({ + where: { id }, + data: updateData, + }); + + // Update pricing levels if provided + if (data.harga_tingkat !== undefined) { + await prisma.harga_tingkat.deleteMany({ + where: { produk_id: id }, + }); + + if (data.harga_tingkat && data.harga_tingkat.length > 0) { + for (const ht of data.harga_tingkat) { + await prisma.harga_tingkat.create({ + data: { + produk_id: id, + tingkat: ht.tingkat, + min_qty: ht.min_qty, + harga: ht.harga, + }, + }); + } + } + } + + const dataBaru = { + kode: updated.kode, + barcode: updated.barcode, + nama: updated.nama, + kategori_id: updated.kategori_id, + supplier_id: updated.supplier_id, + satuan: updated.satuan, + harga_beli: Number(updated.harga_beli), + harga_jual: Number(updated.harga_jual), + stok: Number(updated.stok), + stok_min: Number(updated.stok_min), + is_ecer: updated.is_ecer, + is_aktif: updated.is_aktif, + }; + + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'produk', + entitasId: updated.id, + dataLama, + dataBaru, + ipAddress: ip, + userAgent, + }); + + return updated; + } + + static async delete(id: number, actorId: number, ip: string, userAgent: string) { + const product = await this.getProdukById(id); + + await moveToTrash({ + instance: product, + entityType: 'produk', + actorId, + }); + + await catatAudit({ + userId: actorId, + aksi: 'delete', + entitas: 'produk', + entitasId: product.id, + dataLama: { kode: product.kode, nama: product.nama, deleted: false }, + dataBaru: { kode: product.kode, nama: product.nama, deleted: true }, + ipAddress: ip, + userAgent, + }); + + return true; + } +} diff --git a/src/services/stok.service.ts b/src/services/stok.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..b4d2ea271cbd33a5cb9e99ed10136e489f34c478 --- /dev/null +++ b/src/services/stok.service.ts @@ -0,0 +1,421 @@ +import prisma from '../utils/db'; +import { catatAudit } from '../utils/audit'; +import { APIError } from '../utils/errors'; + +export class StokService { + static async tambahStok(params: { + produkId: number; + qty: number; + hargaBeli: number; + batchNo?: string | null; + expiredDate?: Date | null; + supplierId?: number | null; + userId?: number | null; + catatan?: string | null; + referensi?: string | null; + }) { + const { produkId, qty, hargaBeli, batchNo, expiredDate, supplierId, userId, catatan, referensi } = params; + + // 1. Ambil data produk + const produk = await prisma.produk.findUnique({ + where: { id: produkId }, + }); + if (!produk) { + throw new APIError(`Produk dengan ID ${produkId} tidak ditemukan.`, 404); + } + + // 2. Buat StokBatch baru + const batch = await prisma.stok_batch.create({ + data: { + produk_id: produkId, + batch_no: batchNo ?? null, + qty_masuk: qty, + qty_sisa: qty, + harga_beli: hargaBeli, + expired_date: expiredDate ?? null, + supplier_id: supplierId ?? null, + catatan: catatan ?? null, + }, + }); + + // 3. Update stok di tabel produk + const stokSebelum = Number(produk.stok); + const stokSesudah = stokSebelum + qty; + + // Update harga beli rata-rata produk (Moving Average) + let newHargaBeli = hargaBeli; + if (stokSebelum > 0) { + const totalNilaiLama = stokSebelum * Number(produk.harga_beli); + const totalNilaiBaru = qty * hargaBeli; + newHargaBeli = (totalNilaiLama + totalNilaiBaru) / stokSesudah; + } + + await prisma.produk.update({ + where: { id: produkId }, + data: { + stok: stokSesudah, + harga_beli: newHargaBeli, + }, + }); + + // 4. Catat mutasi di stok_log + const log = await prisma.stok_log.create({ + data: { + produk_id: produkId, + batch_id: batch.id, + tipe: 'masuk', + qty: qty, + stok_sebelum: stokSebelum, + stok_sesudah: stokSesudah, + referensi: referensi ?? null, + user_id: userId ?? null, + catatan: catatan ?? null, + }, + }); + + return { batch, log }; + } + + static async kurangiStokFEFO(params: { + produkId: number; + qtyToDeduct: number; + userId?: number | null; + referensi?: string | null; + catatan?: string | null; + }) { + const { produkId, qtyToDeduct, userId, referensi, catatan } = params; + + const produk = await prisma.produk.findUnique({ + where: { id: produkId }, + }); + if (!produk) { + throw new APIError(`Produk dengan ID ${produkId} tidak ditemukan.`, 404); + } + + const stokSebelum = Number(produk.stok); + if (stokSebelum < qtyToDeduct) { + throw new APIError(`Stok produk '${produk.nama}' tidak mencukupi. Dibutuhkan: ${qtyToDeduct}, Tersedia: ${stokSebelum}`, 400); + } + + const stokSesudah = stokSebelum - qtyToDeduct; + + // Update stok produk di database + await prisma.produk.update({ + where: { id: produkId }, + data: { stok: stokSesudah }, + }); + + // Ambil batch stok yang aktif (qty_sisa > 0) terurut FEFO: + // 1. Expired date terdekat (yang tidak null) + // 2. Batch tanpa expired date (null) ditaruh di paling belakang + const batches = (await prisma.$queryRawUnsafe( + `SELECT * FROM stok_batch + WHERE produk_id = ? AND qty_sisa > 0 + ORDER BY (expired_date IS NULL) ASC, expired_date ASC`, + produkId + )) as any[]; + + let remainingQty = qtyToDeduct; + const deductedBatches: Array<{ batchId: number; qty: number }> = []; + + for (const batch of batches) { + if (remainingQty <= 0) break; + + const qtySisa = Number(batch.qty_sisa); + if (qtySisa >= remainingQty) { + // Kurangi dari batch ini sepenuhnya + await prisma.stok_batch.update({ + where: { id: batch.id }, + data: { qty_sisa: qtySisa - remainingQty }, + }); + + deductedBatches.push({ batchId: batch.id, qty: remainingQty }); + + // Catat log mutasi + await prisma.stok_log.create({ + data: { + produk_id: produkId, + batch_id: batch.id, + tipe: 'keluar', + qty: -remainingQty, + stok_sebelum: stokSebelum, + stok_sesudah: stokSesudah, + referensi: referensi ?? null, + user_id: userId ?? null, + catatan: catatan ?? null, + }, + }); + + remainingQty = 0; + } else { + // Kurangi semua qty sisa di batch ini + const deductAmount = qtySisa; + await prisma.stok_batch.update({ + where: { id: batch.id }, + data: { qty_sisa: 0 }, + }); + + deductedBatches.push({ batchId: batch.id, qty: deductAmount }); + remainingQty -= deductAmount; + + // Catat log mutasi + await prisma.stok_log.create({ + data: { + produk_id: produkId, + batch_id: batch.id, + tipe: 'keluar', + qty: -deductAmount, + stok_sebelum: stokSebelum, + stok_sesudah: stokSesudah, + referensi: referensi ?? null, + user_id: userId ?? null, + catatan: catatan ?? null, + }, + }); + } + } + + // Cek minimum threshold stok + const updatedProduk = await prisma.produk.findUnique({ where: { id: produkId } }); + if (updatedProduk) { + await this.cekThresholdStok(updatedProduk); + } + + return deductedBatches; + } + + static async cekThresholdStok(produk: any) { + const stokVal = Number(produk.stok); + const stokMinVal = Number(produk.stok_min); + + if (stokVal <= stokMinVal) { + const tipeNotif = stokVal <= 0 ? 'stok_habis' : 'stok_rendah'; + const judul = stokVal <= 0 ? `Stok Habis: ${produk.nama}` : `Stok Rendah: ${produk.nama}`; + const pesan = `Stok produk ${produk.nama} tersisa ${stokVal} ${produk.satuan}. Batas minimum: ${stokMinVal} ${produk.satuan}.`; + + // Cek apakah notifikasi serupa yang belum dibaca sudah ada + const exists = await prisma.notifikasi.findFirst({ + where: { + tipe: tipeNotif, + referensi_id: produk.id, + is_dibaca: 0, + }, + }); + + if (!exists) { + await prisma.notifikasi.create({ + data: { + tipe: tipeNotif, + judul, + pesan, + referensi_id: produk.id, + }, + }); + } + } + } + + static async tambahBarangMasuk(data: any, actorId: number, ip: string, userAgent: string) { + const { batch } = await this.tambahStok({ + produkId: data.produk_id, + qty: data.qty, + hargaBeli: data.harga_beli, + batchNo: data.batch_no, + expiredDate: data.expired_date, + supplierId: data.supplier_id, + userId: actorId, + catatan: data.catatan, + referensi: 'INCOMING', + }); + + await catatAudit({ + userId: actorId, + aksi: 'create', + entitas: 'stok_batch', + entitasId: batch.id, + dataBaru: { produk_id: data.produk_id, qty: Number(data.qty), batch_no: data.batch_no }, + ipAddress: ip, + userAgent, + }); + + return batch; + } + + static async buatStokOpname(data: any, actorId: number, ip: string, userAgent: string) { + const produk = await prisma.produk.findFirst({ + where: { id: data.produk_id, deleted_at: null }, + }); + if (!produk) { + throw new APIError('Produk tidak ditemukan', 404); + } + + const stokSistem = Number(produk.stok); + const selisih = data.stok_fisik - stokSistem; + + const opname = await prisma.stok_opname.create({ + data: { + produk_id: data.produk_id, + user_id: actorId, + stok_sistem: stokSistem, + stok_fisik: data.stok_fisik, + selisih: selisih, + alasan: data.alasan ?? null, + status: 'draft', + }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'create', + entitas: 'stok_opname', + entitasId: opname.id, + dataBaru: { produk_id: data.produk_id, stok_fisik: Number(data.stok_fisik), selisih: Number(selisih) }, + ipAddress: ip, + userAgent, + }); + + return opname; + } + + static async approveStokOpname(opnameId: number, actorId: number, ip: string, userAgent: string) { + const opname = await prisma.stok_opname.findUnique({ + where: { id: opnameId }, + }); + if (!opname) { + throw new APIError('Pencatatan stok opname tidak ditemukan', 404); + } + + if (opname.status === 'approved') { + throw new APIError('Stok opname ini sudah disetujui sebelumnya', 400); + } + + const produk = await prisma.produk.findUnique({ + where: { id: opname.produk_id }, + }); + if (!produk) { + throw new APIError('Produk terkait tidak ditemukan', 404); + } + + const stokSebelum = Number(produk.stok); + const stokSesudah = Number(opname.stok_fisik); + + // Apply adjustments in main product stock + await prisma.produk.update({ + where: { id: opname.produk_id }, + data: { stok: stokSesudah }, + }); + + // Log stock change + await prisma.stok_log.create({ + data: { + produk_id: opname.produk_id, + tipe: 'opname', + qty: opname.selisih, + stok_sebelum: stokSebelum, + stok_sesudah: stokSesudah, + referensi: `OPNAME-${opname.id}`, + user_id: actorId, + catatan: `Persetujuan Opname. Alasan: ${opname.alasan || '-'}`, + }, + }); + + const selisihVal = Number(opname.selisih); + + if (selisihVal < 0) { + // Negative discrepancy, reduce from latest batch + let remainingReduction = Math.abs(selisihVal); + const batches = await prisma.stok_batch.findMany({ + where: { produk_id: opname.produk_id, qty_sisa: { gt: 0 } }, + orderBy: { created_at: 'desc' }, + }); + + for (const batch of batches) { + if (remainingReduction <= 0) break; + const qtySisa = Number(batch.qty_sisa); + if (qtySisa >= remainingReduction) { + await prisma.stok_batch.update({ + where: { id: batch.id }, + data: { qty_sisa: qtySisa - remainingReduction }, + }); + remainingReduction = 0; + } else { + await prisma.stok_batch.update({ + where: { id: batch.id }, + data: { qty_sisa: 0 }, + }); + remainingReduction -= qtySisa; + } + } + } else if (selisihVal > 0) { + // Excess stock, add as an adjustment batch + await prisma.stok_batch.create({ + data: { + produk_id: opname.produk_id, + batch_no: 'OPNAME-ADJ', + qty_masuk: selisihVal, + qty_sisa: selisihVal, + harga_beli: produk.harga_beli, + catatan: `Penyesuaian lebih stok dari opname #${opname.id}`, + }, + }); + } + + // Update opname status + const updatedOpname = await prisma.stok_opname.update({ + where: { id: opnameId }, + data: { status: 'approved' }, + }); + + // Check threshold again + const updatedProduk = await prisma.produk.findUnique({ where: { id: opname.produk_id } }); + if (updatedProduk) { + await this.cekThresholdStok(updatedProduk); + } + + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'stok_opname', + entitasId: opname.id, + dataLama: { status: 'draft' }, + dataBaru: { status: 'approved' }, + ipAddress: ip, + userAgent, + }); + + return updatedOpname; + } + + static async getStokLogs(skip = 0, limit = 20, produkId?: number, tipe?: string) { + const where: any = {}; + if (produkId) where.produk_id = produkId; + if (tipe) where.tipe = tipe; + + return await prisma.stok_log.findMany({ + where, + orderBy: { created_at: 'desc' }, + skip, + take: limit, + include: { + produk: true, + }, + }); + } + + static async getStokBatches(skip = 0, limit = 20, produkId?: number) { + const where: any = { + qty_sisa: { gt: 0 }, + }; + if (produkId) where.produk_id = produkId; + + return await prisma.stok_batch.findMany({ + where, + orderBy: { expired_date: 'asc' }, + skip, + take: limit, + include: { + produk: true, + }, + }); + } +} diff --git a/src/services/supplier.service.ts b/src/services/supplier.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..14c10d86e93e79fd916395a9a876fcb718aef312 --- /dev/null +++ b/src/services/supplier.service.ts @@ -0,0 +1,130 @@ +import prisma from '../utils/db'; +import { catatAudit } from '../utils/audit'; +import { moveToTrash } from '../utils/trash'; +import { APIError } from '../utils/errors'; + +export class SupplierService { + static async getSuppliers(skip = 0, limit = 10, pencarian?: string) { + const where: any = { + deleted_at: null, + }; + + if (pencarian) { + where.nama = { + contains: pencarian, + }; + } + + return await prisma.supplier.findMany({ + where, + skip, + take: limit, + }); + } + + static async getSupplierById(id: number) { + const supplier = await prisma.supplier.findFirst({ + where: { id, deleted_at: null }, + }); + if (!supplier) { + throw new APIError('Supplier tidak ditemukan', 404); + } + return supplier; + } + + static async create(data: any, actorId: number, ip: string, userAgent: string) { + const supplier = await prisma.supplier.create({ + data: { + nama: data.nama, + kontak: data.kontak ?? null, + nomor_hp: data.nomor_hp ?? null, + alamat: data.alamat ?? null, + hutang: data.hutang ?? 0, + aktif: data.aktif ?? 1, + }, + }); + + await catatAudit({ + userId: actorId, + aksi: 'create', + entitas: 'supplier', + entitasId: supplier.id, + dataBaru: { nama: supplier.nama, kontak: supplier.kontak, hutang: Number(supplier.hutang) }, + ipAddress: ip, + userAgent, + }); + + return supplier; + } + + static async update(id: number, data: any, actorId: number, ip: string, userAgent: string) { + const supplier = await this.getSupplierById(id); + + const dataLama = { + nama: supplier.nama, + kontak: supplier.kontak, + nomor_hp: supplier.nomor_hp, + alamat: supplier.alamat, + hutang: Number(supplier.hutang), + aktif: supplier.aktif, + }; + + const updateData: any = {}; + if (data.nama !== undefined) updateData.nama = data.nama; + if (data.kontak !== undefined) updateData.kontak = data.kontak; + if (data.nomor_hp !== undefined) updateData.nomor_hp = data.nomor_hp; + if (data.alamat !== undefined) updateData.alamat = data.alamat; + if (data.hutang !== undefined) updateData.hutang = data.hutang; + if (data.aktif !== undefined) updateData.aktif = data.aktif; + + const updated = await prisma.supplier.update({ + where: { id }, + data: updateData, + }); + + const dataBaru = { + nama: updated.nama, + kontak: updated.kontak, + nomor_hp: updated.nomor_hp, + alamat: updated.alamat, + hutang: Number(updated.hutang), + aktif: updated.aktif, + }; + + await catatAudit({ + userId: actorId, + aksi: 'update', + entitas: 'supplier', + entitasId: updated.id, + dataLama, + dataBaru, + ipAddress: ip, + userAgent, + }); + + return updated; + } + + static async delete(id: number, actorId: number, ip: string, userAgent: string) { + const supplier = await this.getSupplierById(id); + + await moveToTrash({ + instance: supplier, + entityType: 'supplier', + actorId, + }); + + await catatAudit({ + userId: actorId, + aksi: 'delete', + entitas: 'supplier', + entitasId: supplier.id, + dataLama: { nama: supplier.nama, aktif: 1 }, + dataBaru: { nama: supplier.nama, aktif: 0 }, + ipAddress: ip, + userAgent, + }); + + return true; + } +} diff --git a/src/services/transaksi.service.ts b/src/services/transaksi.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..98ee6f82a16a2d053f32d95922546c2c14cbc5a8 --- /dev/null +++ b/src/services/transaksi.service.ts @@ -0,0 +1,554 @@ +import prisma from '../utils/db'; +import { DiskonService } from './diskon.service'; +import { StokService } from './stok.service'; +import { catatAudit } from '../utils/audit'; +import { generateNoTransaksi, generateNoRetur } from '../utils/kodeGenerator'; +import { APIError } from '../utils/errors'; + +export class TransaksiService { + static async prosesTransaksi(params: { + data: any; + actorId: number; + ip: string; + userAgent: string; + }) { + const { data, actorId, ip, userAgent } = params; + + // 1. Pastikan shift kasir aktif + const shift = await prisma.shift.findFirst({ + where: { + user_id: actorId, + status: 'buka', + }, + }); + + if (!shift) { + throw new APIError('Shift kasir Anda belum dibuka. Silakan buka shift terlebih dahulu.', 400); + } + + // 2. Hitung harga dan diskon menggunakan diskon_service + const itemsInput = data.items.map((item: any) => ({ + produk_id: item.produk_id, + qty: item.qty, + })); + + const calc = await DiskonService.hitungDiskonDanHarga(itemsInput, data.member_id); + + const totalAkhir = calc.total_akhir; + const subtotal = calc.subtotal_dasar; + const diskonTotal = calc.total_diskon_keseluruhan; + + // 3. Validasi Pembayaran + const nominalBayar = data.pembayaran.reduce((acc: number, p: any) => acc + p.nominal, 0); + if (nominalBayar < totalAkhir) { + throw new APIError(`Nominal pembayaran kurang. Dibutuhkan: Rp ${totalAkhir.toLocaleString('id-ID')}, Dibayarkan: Rp ${nominalBayar.toLocaleString('id-ID')}`, 400); + } + + // Cek Pembayaran bertipe BON + const bonPembayaran = data.pembayaran.find((p: any) => p.metode === 'bon'); + let member: any = null; + + if (bonPembayaran) { + if (!data.member_id) { + throw new APIError('Pembayaran tipe Kasbon (Bon) memerlukan pendaftaran Member/Pelanggan.', 400); + } + member = await prisma.member.findFirst({ + where: { id: data.member_id, aktif: 1 }, + }); + if (!member) { + throw new APIError('Member tidak valid atau tidak aktif.', 400); + } + // Validasi limit bon + const sisaLimit = Number(member.limit_bon) - Number(member.total_bon); + if (bonPembayaran.nominal > sisaLimit) { + throw new APIError(`Nominal bon Rp ${bonPembayaran.nominal.toLocaleString('id-ID')} melebihi sisa limit kredit member. Sisa limit: Rp ${sisaLimit.toLocaleString('id-ID')}`, 400); + } + } + + const statusTrans = bonPembayaran ? 'bon' : 'lunas'; + + // 4. Generate Nomor Transaksi + const noTransaksi = await generateNoTransaksi(); + + // Hitung kembalian + const kembalian = nominalBayar - totalAkhir; + + // 5. Buat Transaksi (gunakan transaction block) + const result = await prisma.$transaction(async (tx) => { + const txRecord = await tx.transaksi.create({ + data: { + no_transaksi: noTransaksi, + shift_id: shift.id, + user_id: actorId, + member_id: data.member_id ?? null, + tanggal: new Date(), + waktu: new Date(), + subtotal: subtotal, + diskon_total: diskonTotal, + diskon_member: data.member_id ? calc.total_diskon_transaksi : 0, + pajak: 0, + total: totalAkhir, + bayar: nominalBayar, + kembalian: kembalian, + status: statusTrans as any, + is_synced: 1, + local_id: data.local_id ?? null, + catatan: data.catatan ?? null, + }, + }); + + // 6. Simpan detail transaksi + Potong Stok (FEFO) + for (const calcItem of calc.cart_items) { + const prod = calcItem.produk; + const reqQty = calcItem.qty; + const hargaOriginal = calcItem.harga_original; + const diskonItem = calcItem.diskon_item; + const itemDiskonId = calcItem.diskon_id; + + // Potong stok secara FEFO + const deductedBatches = await StokService.kurangiStokFEFO({ + produkId: prod.id, + qtyToDeduct: reqQty, + userId: actorId, + referensi: noTransaksi, + catatan: `Penjualan #${noTransaksi}`, + }); + + // Simpan detail transaksi per batch untuk keakuratan tracking FEFO + for (const dBatch of deductedBatches) { + const proporsi = dBatch.qty / reqQty; + await tx.transaksi_detail.create({ + data: { + transaksi_id: txRecord.id, + produk_id: prod.id, + nama_produk: prod.nama, + qty: dBatch.qty, + satuan: prod.satuan, + harga_satuan: hargaOriginal, + diskon_item: diskonItem * proporsi, + subtotal: (hargaOriginal * dBatch.qty) - (diskonItem * proporsi), + diskon_id: itemDiskonId ?? null, + batch_id: dBatch.batchId, + }, + }); + } + } + + // 7. Simpan Pembayaran + const shiftUpdate: any = {}; + for (const p of data.pembayaran) { + await tx.pembayaran.create({ + data: { + transaksi_id: txRecord.id, + metode: p.metode, + nominal: p.nominal, + referensi: p.referensi ?? null, + }, + }); + + // Akumulasikan ke totals shift + if (p.metode === 'tunai') { + shiftUpdate.total_tunai = Number(shift.total_tunai) + p.nominal; + } else if (p.metode === 'qris') { + shiftUpdate.total_qris = Number(shift.total_qris) + p.nominal; + } else if (p.metode === 'transfer') { + shiftUpdate.total_transfer = Number(shift.total_transfer) + p.nominal; + } else if (p.metode === 'bon') { + shiftUpdate.total_bon = Number(shift.total_bon) + p.nominal; + } + } + + // Jika kembalian dibayarkan dari tunai, kurangi total_tunai shift + if (kembalian > 0) { + shiftUpdate.total_tunai = (shiftUpdate.total_tunai ?? Number(shift.total_tunai)) - kembalian; + } + + await tx.shift.update({ + where: { id: shift.id }, + data: { + ...shiftUpdate, + total_transaksi: Number(shift.total_transaksi) + 1, + total_diskon: Number(shift.total_diskon) + diskonTotal, + }, + }); + + // 8. Jika status BON, buat pencatatan piutang (bon) + if (statusTrans === 'bon' && bonPembayaran) { + const jatuhTempo = new Date(); + jatuhTempo.setDate(jatuhTempo.getDate() + 30); // Default 30 hari + + await tx.bon.create({ + data: { + transaksi_id: txRecord.id, + member_id: data.member_id, + total_bon: bonPembayaran.nominal, + sisa_bon: bonPembayaran.nominal, + jatuh_tempo: jatuhTempo, + status: 'aktif', + }, + }); + + // Tambahkan nominal bon ke total_bon milik member + await tx.member.update({ + where: { id: data.member_id }, + data: { + total_bon: Number(member.total_bon) + bonPembayaran.nominal, + }, + }); + } + + // 9. Poin Member (Loyalty Program) + if (data.member_id) { + const dbMember = await tx.member.findUnique({ where: { id: data.member_id } }); + if (dbMember) { + const poinSetting = await tx.pengaturan.findUnique({ + where: { kunci: 'poin_member' }, + }); + let perRp = 10000; + let poinPerStep = 1; + if (poinSetting) { + try { + const val = JSON.parse(poinSetting.nilai); + perRp = val.per_rp ?? 10000; + poinPerStep = val.poin ?? 1; + } catch (e) {} + } + + const poinBaru = Math.floor(totalAkhir / perRp) * poinPerStep; + if (poinBaru > 0) { + await tx.member.update({ + where: { id: data.member_id }, + data: { + total_poin: Number(dbMember.total_poin) + poinBaru, + }, + }); + } + } + } + + return txRecord; + }); + + // 10. Audit Log + await catatAudit({ + userId: actorId, + aksi: 'create', + entitas: 'transaksi', + entitasId: result.id, + dataBaru: { no_transaksi: noTransaksi, total: totalAkhir, status: statusTrans }, + ipAddress: ip, + userAgent, + }); + + return await prisma.transaksi.findUnique({ + where: { id: result.id }, + include: { + transaksi_detail: true, + pembayaran: true, + }, + }); + } + + static async prosesVoidTransaksi( + transaksiId: number, + alasan: string, + actorId: number, + ip: string, + userAgent: string + ) { + const transaksi = await prisma.transaksi.findUnique({ + where: { id: transaksiId }, + include: { + transaksi_detail: true, + pembayaran: true, + }, + }); + + if (!transaksi) { + throw new APIError('Transaksi tidak ditemukan', 404); + } + + if (transaksi.status === 'void') { + throw new APIError('Transaksi ini sudah berstatus void sebelumnya', 400); + } + + await prisma.$transaction(async (tx) => { + // 1. Kembalikan stok yang dipotong + for (const detail of transaksi.transaksi_detail) { + const produk = await tx.produk.findUnique({ where: { id: detail.produk_id } }); + if (produk) { + const stokSebelum = Number(produk.stok); + const stokSesudah = stokSebelum + Number(detail.qty); + + await tx.produk.update({ + where: { id: detail.produk_id }, + data: { stok: stokSesudah }, + }); + + // Kembalikan ke batch semula jika ada batch_id + if (detail.batch_id) { + const batch = await tx.stok_batch.findUnique({ where: { id: detail.batch_id } }); + if (batch) { + await tx.stok_batch.update({ + where: { id: detail.batch_id }, + data: { qty_sisa: Number(batch.qty_sisa) + Number(detail.qty) }, + }); + } + } + + // Catat log mutasi retur/penyesuaian stok + await tx.stok_log.create({ + data: { + produk_id: detail.produk_id, + batch_id: detail.batch_id, + tipe: 'retur', + qty: detail.qty, + stok_sebelum: stokSebelum, + stok_sesudah: stokSesudah, + referensi: `VOID-${transaksi.no_transaksi}`, + user_id: actorId, + catatan: `Void Transaksi #${transaksi.no_transaksi}. Alasan: ${alasan}`, + }, + }); + } + } + + // 2. Jika ada BON, batalkan bon-nya + if (transaksi.status === 'bon') { + const bon = await tx.bon.findFirst({ where: { transaksi_id: transaksiId } }); + if (bon) { + await tx.bon.update({ + where: { id: bon.id }, + data: { status: 'lunas' }, + }); + + // Potong total_bon di profile member + const member = await tx.member.findUnique({ where: { id: transaksi.member_id! } }); + if (member) { + await tx.member.update({ + where: { id: transaksi.member_id! }, + data: { + total_bon: Math.max(0, Number(member.total_bon) - Number(bon.sisa_bon)), + }, + }); + } + } + } + + // 3. Potong Poin Member yang didapat dari transaksi ini + if (transaksi.member_id) { + const member = await tx.member.findUnique({ where: { id: transaksi.member_id } }); + if (member) { + const poinSetting = await tx.pengaturan.findUnique({ + where: { kunci: 'poin_member' }, + }); + let perRp = 10000; + let poinPerStep = 1; + if (poinSetting) { + try { + const val = JSON.parse(poinSetting.nilai); + perRp = val.per_rp ?? 10000; + poinPerStep = val.poin ?? 1; + } catch (e) {} + } + const poinVoid = Math.floor(Number(transaksi.total) / perRp) * poinPerStep; + await tx.member.update({ + where: { id: transaksi.member_id }, + data: { + total_poin: Math.max(0, Number(member.total_poin) - poinVoid), + }, + }); + } + } + + // 4. Sesuaikan totals shift kasir + const shift = await tx.shift.findUnique({ where: { id: transaksi.shift_id } }); + if (shift) { + const shiftUpdate: any = {}; + for (const p of transaksi.pembayaran) { + if (p.metode === 'tunai') { + shiftUpdate.total_tunai = Number(shift.total_tunai) - Number(p.nominal); + } else if (p.metode === 'qris') { + shiftUpdate.total_qris = Number(shift.total_qris) - Number(p.nominal); + } else if (p.metode === 'transfer') { + shiftUpdate.total_transfer = Number(shift.total_transfer) - Number(p.nominal); + } else if (p.metode === 'bon') { + shiftUpdate.total_bon = Number(shift.total_bon) - Number(p.nominal); + } + } + + // Kembalikan penyesuaian jika ada kembalian tunai + if (Number(transaksi.kembalian) > 0) { + shiftUpdate.total_tunai = (shiftUpdate.total_tunai ?? Number(shift.total_tunai)) + Number(transaksi.kembalian); + } + + await tx.shift.update({ + where: { id: transaksi.shift_id }, + data: { + ...shiftUpdate, + total_transaksi: Math.max(0, Number(shift.total_transaksi) - 1), + total_diskon: Math.max(0, Number(shift.total_diskon) - Number(transaksi.diskon_total)), + }, + }); + } + + // 5. Set status transaksi menjadi void + await tx.transaksi.update({ + where: { id: transaksiId }, + data: { + status: 'void', + deleted_at: new Date(), + }, + }); + + // 6. Catat Notifikasi + await tx.notifikasi.create({ + data: { + tipe: 'transaksi_void', + judul: 'Transaksi Dibatalkan (Void)', + pesan: `Transaksi #${transaksi.no_transaksi} dibatalkan oleh user ID ${actorId}. Alasan: ${alasan}`, + referensi_id: transaksi.id, + }, + }); + }); + + // 7. Audit Log + await catatAudit({ + userId: actorId, + aksi: 'void', + entitas: 'transaksi', + entitasId: transaksi.id, + dataLama: { status: 'lunas/bon' }, + dataBaru: { status: 'void', alasan }, + ipAddress: ip, + userAgent, + }); + + return await prisma.transaksi.findUnique({ + where: { id: transaksiId }, + include: { + transaksi_detail: true, + pembayaran: true, + }, + }); + } + + static async prosesReturBarang( + data: any, + actorId: number, + ip: string, + userAgent: string + ) { + // Validate transaction + const trans = await prisma.transaksi.findUnique({ + where: { id: data.transaksi_id }, + }); + if (!trans) { + throw new APIError('Transaksi asal tidak ditemukan', 404); + } + + // Validate product is in transaction + const detail = await prisma.transaksi_detail.findFirst({ + where: { + transaksi_id: data.transaksi_id, + produk_id: data.produk_id, + }, + }); + + if (!detail) { + throw new APIError('Produk tersebut tidak ada dalam riwayat transaksi ini', 400); + } + + // Validate quantity retur + const sumResult = await prisma.transaksi_retur.aggregate({ + where: { + transaksi_id: data.transaksi_id, + produk_id: data.produk_id, + }, + _sum: { + qty: true, + }, + }); + + const alreadyReturned = Number(sumResult._sum.qty ?? 0); + const maxReturnable = Number(detail.qty) - alreadyReturned; + + if (data.qty > maxReturnable) { + throw new APIError(`Jumlah retur melebihi jumlah pembelian. Maksimal yang bisa diretur: ${maxReturnable} ${detail.satuan}`, 400); + } + + // Generate retur number + const noRetur = await generateNoRetur(); + + const result = await prisma.$transaction(async (tx) => { + // Create retur record + const retur = await tx.transaksi_retur.create({ + data: { + transaksi_id: data.transaksi_id, + no_retur: noRetur, + user_id: actorId, + produk_id: data.produk_id, + qty: data.qty, + alasan: data.alasan, + tipe: data.tipe, + nominal_kembali: data.nominal_kembali ?? 0, + stok_kembali: data.stok_kembali ?? 1, + }, + }); + + // Restitusi stok jika stok_kembali = 1 + if (data.stok_kembali === 1) { + const produk = await tx.produk.findUnique({ where: { id: data.produk_id } }); + if (produk) { + const stokSebelum = Number(produk.stok); + const stokSesudah = stokSebelum + data.qty; + + await tx.produk.update({ + where: { id: data.produk_id }, + data: { stok: stokSesudah }, + }); + + // Kembalikan ke batch semula + if (detail.batch_id) { + const batch = await tx.stok_batch.findUnique({ where: { id: detail.batch_id } }); + if (batch) { + await tx.stok_batch.update({ + where: { id: detail.batch_id }, + data: { qty_sisa: Number(batch.qty_sisa) + data.qty }, + }); + } + } + + // Catat log + await tx.stok_log.create({ + data: { + produk_id: data.produk_id, + batch_id: detail.batch_id, + tipe: 'retur', + qty: data.qty, + stok_sebelum: stokSebelum, + stok_sesudah: stokSesudah, + referensi: noRetur, + user_id: actorId, + catatan: `Retur Barang dari Transaksi #${trans.no_transaksi}. Alasan: ${data.alasan}`, + }, + }); + } + } + + return retur; + }); + + await catatAudit({ + userId: actorId, + aksi: 'create', + entitas: 'transaksi_retur', + entitasId: result.id, + dataBaru: { no_retur: noRetur, qty: Number(data.qty), tipe: data.tipe }, + ipAddress: ip, + userAgent, + }); + + return result; + } +} diff --git a/src/utils/audit.ts b/src/utils/audit.ts new file mode 100644 index 0000000000000000000000000000000000000000..ed8b3fced45af3b2ab47109377f305b350a8a0fe --- /dev/null +++ b/src/utils/audit.ts @@ -0,0 +1,31 @@ +import prisma from './db'; + +interface AuditParams { + userId?: number | null; + aksi: string; + entitas: string; + entitasId?: number | null; + dataLama?: any; + dataBaru?: any; + ipAddress?: string | null; + userAgent?: string | null; +} + +export const catatAudit = async (params: AuditParams) => { + try { + return await prisma.audit_log.create({ + data: { + user_id: params.userId ?? null, + aksi: params.aksi, + entitas: params.entitas, + entitas_id: params.entitasId ?? null, + data_lama: params.dataLama ?? null, + data_baru: params.dataBaru ?? null, + ip_address: params.ipAddress ?? null, + user_agent: params.userAgent ?? null, + }, + }); + } catch (error) { + console.error('Gagal mencatat audit log:', error); + } +}; diff --git a/src/utils/db.ts b/src/utils/db.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e54f7a77ec7721dca748d064050ad939150ff88 --- /dev/null +++ b/src/utils/db.ts @@ -0,0 +1,5 @@ +import { PrismaClient } from '@prisma/client'; + +const prisma = new PrismaClient(); + +export default prisma; diff --git a/src/utils/errors.ts b/src/utils/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2acc1d3e313eac5bdb3a42fbcf18155b6f75cde --- /dev/null +++ b/src/utils/errors.ts @@ -0,0 +1,11 @@ +export class APIError extends Error { + statusCode: number; + errors: any; + + constructor(message: string, statusCode = 500, errors: any = null) { + super(message); + this.statusCode = statusCode; + this.errors = errors; + Object.setPrototypeOf(this, new.target.prototype); + } +} diff --git a/src/utils/jwt.ts b/src/utils/jwt.ts new file mode 100644 index 0000000000000000000000000000000000000000..185b32e1525038d61abb8208a7d45157e386a68d --- /dev/null +++ b/src/utils/jwt.ts @@ -0,0 +1,30 @@ +import jwt from 'jsonwebtoken'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const JWT_SECRET = process.env.JWT_SECRET_KEY || 'defaultsecret'; + +export const createAccessToken = (data: object): string => { + return jwt.sign( + { ...data, type: 'access' }, + JWT_SECRET, + { expiresIn: '24h' } // Matching python JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 1440 mins (24h) + ); +}; + +export const createRefreshToken = (data: object): string => { + return jwt.sign( + { ...data, type: 'refresh' }, + JWT_SECRET, + { expiresIn: '30d' } // Matching python 30 days + ); +}; + +export const verifyToken = (token: string): any => { + try { + return jwt.verify(token, JWT_SECRET); + } catch (error) { + return null; + } +}; diff --git a/src/utils/kodeGenerator.ts b/src/utils/kodeGenerator.ts new file mode 100644 index 0000000000000000000000000000000000000000..b7963bf13c63d8d2ef4064b467a9fa9353e329ce --- /dev/null +++ b/src/utils/kodeGenerator.ts @@ -0,0 +1,75 @@ +import prisma from './db'; + +export const generateKodeProduk = async (): Promise => { + let count = (await prisma.produk.count()) + 1; + while (true) { + const code = `PLU${String(count).padStart(5, '0')}`; + const exists = await prisma.produk.findUnique({ where: { kode: code } }); + if (!exists) return code; + count++; + } +}; + +export const generateKodeMember = async (): Promise => { + let count = (await prisma.member.count()) + 1; + while (true) { + const code = `TKV-${String(count).padStart(4, '0')}`; + const exists = await prisma.member.findUnique({ where: { kode: code } }); + if (!exists) return code; + count++; + } +}; + +export const generateKodeShift = async (): Promise => { + const todayStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const prefix = `TKV-${todayStr}-`; + let count = + (await prisma.shift.count({ + where: { + kode: { startsWith: prefix }, + }, + })) + 1; + + while (true) { + const code = `${prefix}${String(count).padStart(3, '0')}`; + const exists = await prisma.shift.findFirst({ where: { kode: code } }); + if (!exists) return code; + count++; + } +}; + +export const generateNoTransaksi = async (): Promise => { + const todayStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const prefix = `TKV-${todayStr}-`; + let count = + (await prisma.transaksi.count({ + where: { + no_transaksi: { startsWith: prefix }, + }, + })) + 1; + + while (true) { + const code = `${prefix}${String(count).padStart(4, '0')}`; + const exists = await prisma.transaksi.findUnique({ where: { no_transaksi: code } }); + if (!exists) return code; + count++; + } +}; + +export const generateNoRetur = async (): Promise => { + const todayStr = new Date().toISOString().slice(0, 10).replace(/-/g, ''); + const prefix = `TKV-RET-${todayStr}-`; + let count = + (await prisma.transaksi_retur.count({ + where: { + no_retur: { startsWith: prefix }, + }, + })) + 1; + + while (true) { + const code = `${prefix}${String(count).padStart(4, '0')}`; + const exists = await prisma.transaksi_retur.findUnique({ where: { no_retur: code } }); + if (!exists) return code; + count++; + } +}; diff --git a/src/utils/response.ts b/src/utils/response.ts new file mode 100644 index 0000000000000000000000000000000000000000..ba9dc27b3a634e3eead9a5f0f0c2fd07ee2a8ea3 --- /dev/null +++ b/src/utils/response.ts @@ -0,0 +1,31 @@ +import { Response } from 'express'; + +export const sukses = ( + res: Response, + data: any = null, + pesan: string = 'Berhasil', + meta: any = null, + statusCode = 200 +) => { + return res.status(statusCode).json({ + sukses: true, + data, + pesan, + meta, + }); +}; + +export const gagal = ( + res: Response, + statusCode: number, + pesan: string, + error: string | null = null, + detail: any = null +) => { + return res.status(statusCode).json({ + sukses: false, + pesan, + error, + detail: detail ?? pesan, + }); +}; diff --git a/src/utils/supabase.ts b/src/utils/supabase.ts new file mode 100644 index 0000000000000000000000000000000000000000..3372919b55f5d3aed20cbe9a54d38e6bf4d4d7b3 --- /dev/null +++ b/src/utils/supabase.ts @@ -0,0 +1,90 @@ +import dotenv from 'dotenv'; + +dotenv.config(); + +const SUPABASE_URL = (process.env.SUPABASE_URL || '').replace(/\/$/, ''); +const SUPABASE_SERVICE_KEY = process.env.SUPABASE_SERVICE_KEY || ''; + +interface ParsedImage { + fileBytes: Buffer; + mimeType: string; +} + +export const parseBase64Image = (base64Str: string): ParsedImage => { + if (base64Str.includes(',')) { + const [header, data] = base64Str.split(',', 2); + let mimeType = 'image/jpeg'; + + if (header.includes('data:')) { + const parts = header.split(';'); + if (parts.length > 0) { + mimeType = parts[0].replace('data:', ''); + } + + let fileBytes: Buffer; + if (header.includes('base64')) { + fileBytes = Buffer.from(data, 'base64'); + } else { + const decodedStr = decodeURIComponent(data); + fileBytes = Buffer.from(decodedStr, 'utf-8'); + } + return { fileBytes, mimeType }; + } + const fileBytes = Buffer.from(data, 'base64'); + return { fileBytes, mimeType }; + } else { + const fileBytes = Buffer.from(base64Str, 'base64'); + return { fileBytes, mimeType: 'image/jpeg' }; + } +}; + +export const uploadBase64ToSupabase = async ( + bucket: string, + filePath: string, + base64Str: string +): Promise => { + try { + const { fileBytes, mimeType } = parseBase64Image(base64Str); + const uploadUrl = `${SUPABASE_URL}/storage/v1/object/${bucket}/${filePath}`; + + const response = await fetch(uploadUrl, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}`, + 'Content-Type': mimeType, + 'x-upsert': 'true' + }, + body: fileBytes + }); + + if (response.ok) { + return `${SUPABASE_URL}/storage/v1/object/public/${bucket}/${filePath}`; + } else { + const errorText = await response.text(); + console.error(`Supabase upload failed: ${response.status} - ${errorText}`); + return null; + } + } catch (error) { + console.error('Error uploading to Supabase:', error); + return null; + } +}; + +export const deleteFromSupabase = async ( + bucket: string, + filePath: string +): Promise => { + try { + const deleteUrl = `${SUPABASE_URL}/storage/v1/object/${bucket}/${filePath}`; + const response = await fetch(deleteUrl, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${SUPABASE_SERVICE_KEY}` + } + }); + return response.ok; + } catch (error) { + console.error('Error deleting from Supabase:', error); + return false; + } +}; diff --git a/src/utils/trash.ts b/src/utils/trash.ts new file mode 100644 index 0000000000000000000000000000000000000000..54f8f5bb7f7b46c6a1fa107592cb029492cb5bf4 --- /dev/null +++ b/src/utils/trash.ts @@ -0,0 +1,76 @@ +import prisma from './db'; + +interface MoveTrashParams { + instance: any; + entityType: 'produk' | 'kategori' | 'supplier' | 'member'; + actorId: number; +} + +export const moveToTrash = async (params: MoveTrashParams) => { + const { instance, entityType, actorId } = params; + + const dataPayload: any = { + main_record: instance, + }; + + if (entityType === 'produk') { + // harga_tingkat + const hargaTingkat = await prisma.harga_tingkat.findMany({ + where: { produk_id: instance.id }, + }); + dataPayload.harga_tingkat = hargaTingkat; + + // stok_batch + const stokBatch = await prisma.stok_batch.findMany({ + where: { produk_id: instance.id }, + }); + dataPayload.stok_batch = stokBatch; + + // stok_log + const stokLog = await prisma.stok_log.findMany({ + where: { produk_id: instance.id }, + }); + dataPayload.stok_log = stokLog; + + // stok_opname + const stokOpname = await prisma.stok_opname.findMany({ + where: { produk_id: instance.id }, + }); + dataPayload.stok_opname = stokOpname; + } + + // Simpan payload di tabel trash + await prisma.trash.create({ + data: { + entity_type: entityType, + entity_id: instance.id, + data: JSON.stringify(dataPayload), + deleted_by: actorId, + deleted_at: new Date(), + }, + }); + + // Soft delete / Nonaktifkan record aslinya + const deletedAt = new Date(); + if (entityType === 'produk') { + await prisma.produk.update({ + where: { id: instance.id }, + data: { deleted_at: deletedAt, is_aktif: 0 }, + }); + } else if (entityType === 'kategori') { + await prisma.kategori.update({ + where: { id: instance.id }, + data: { deleted_at: deletedAt, aktif: 0 }, + }); + } else if (entityType === 'supplier') { + await prisma.supplier.update({ + where: { id: instance.id }, + data: { deleted_at: deletedAt, aktif: 0 }, + }); + } else if (entityType === 'member') { + await prisma.member.update({ + where: { id: instance.id }, + data: { deleted_at: deletedAt, aktif: 0 }, + }); + } +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..5e9efed9487158d40fe1b6225c3d4196cad9d5db --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "lib": ["ES2022"], + "allowJs": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "noImplicitAny": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node" + }, + "include": ["src/**/*"] +}