first commit 🎉
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
; https://editorconfig.org
|
||||||
|
|
||||||
|
root = true
|
||||||
|
|
||||||
|
[**]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
ENV=development
|
||||||
|
PORT=9000
|
||||||
|
DB_DSN=postgresql://local:secret@127.0.0.1/mydb
|
||||||
|
BASIC_AUTH_USERNAME=local
|
||||||
|
BASIC_AUTH_PASSWORD=secret
|
||||||
|
|
||||||
|
# Attendance
|
||||||
|
ATTENDANCE_CHECK_IN_HOUR=8
|
||||||
|
|
||||||
|
# Object Storage
|
||||||
|
S3_URL=<se-url>
|
||||||
|
S3_ACCESS_KEY=<s3-access-key>
|
||||||
|
S3_SECRET_KEY=<s3-secret-key>
|
||||||
|
S3_BUCKET_NAME=<s3-bucket-name>
|
||||||
|
|
||||||
|
# OpenTelemetry
|
||||||
|
OTEL_ENABLED=true
|
||||||
|
OTEL_SERVICE_NAME=backend-node-mbg
|
||||||
|
OTEL_TRACES_EXPORTER=otlp
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4319/v1/traces
|
||||||
|
|
||||||
|
# JWT
|
||||||
|
JWT_PUBLIC_KEY=
|
||||||
|
JWT_PRIVATE_KEY=
|
||||||
|
JWT_SECRET=
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- 'develop'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
develop:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
GITEA_ACTOR: ${{ gitea.actor }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- name: Login to Docker Hub
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ vars.REGISTRY_URL }}
|
||||||
|
username: ${{ secrets.AGENT_USER }}
|
||||||
|
password: ${{ secrets.AGENT_TOKEN }}
|
||||||
|
-
|
||||||
|
name: Build and push
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ vars.REPOSITORY_URL }}/${{ gitea.repository }}:develop
|
||||||
|
- name: Cleanup
|
||||||
|
run: |
|
||||||
|
docker rmi ${{ vars.REPOSITORY_URL }}/${{ gitea.repository }}:develop
|
||||||
|
- name: Deploy portainer
|
||||||
|
uses: newarifrh/portainer-service-webhook@v1
|
||||||
|
with:
|
||||||
|
webhook_url: ${{ secrets.WEBHOOK_URL }}
|
||||||
|
- name: Set Discord Content
|
||||||
|
run: |
|
||||||
|
DISCORD_ID="${{ vars[format('DISCORD_ID_{0}', env.GITEA_ACTOR)] }}"
|
||||||
|
echo "DISCORD_ID: $DISCORD_ID"
|
||||||
|
|
||||||
|
# - name: Notify Discord Success
|
||||||
|
# uses: sarisia/actions-status-discord@v1
|
||||||
|
# if: success()
|
||||||
|
# with:
|
||||||
|
# webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
# status: ${{ job.status }}
|
||||||
|
# title: Build and Deploy ${{ job.status }}
|
||||||
|
# content: "test the app"
|
||||||
|
# description: Build and deploy ${{ job.status }}
|
||||||
|
# color: 0x00FF00
|
||||||
|
# username: MBG-AGENT
|
||||||
|
# nodetail: true
|
||||||
|
# avatar_url: https://gitea.tepibojonegoro.com/avatars/ab1b485ae0fcd8b618bbc36158528b64f23cd9c472442c2e5ca54a63771c1411?size=200
|
||||||
|
|
||||||
|
# - name: Notify Discord Failure
|
||||||
|
# uses: sarisia/actions-status-discord@v1
|
||||||
|
# if: failure()
|
||||||
|
# with:
|
||||||
|
# webhook: ${{ secrets.DISCORD_WEBHOOK_URL }}
|
||||||
|
# status: ${{ job.status }}
|
||||||
|
# title: Build and Deploy ${{ job.status }}
|
||||||
|
# content: "Hey <@${{ env.DISCORD_ID }}> check service failed"
|
||||||
|
# description: Build and deploy ${{ job.status }}
|
||||||
|
# color: 0xFF0000
|
||||||
|
# nodetail: true
|
||||||
|
# username: MBG-AGENT
|
||||||
|
# avatar_url: https://gitea.tepibojonegoro.com/avatars/ab1b485ae0fcd8b618bbc36158528b64f23cd9c472442c2e5ca54a63771c1411?size=200
|
||||||
+44
@@ -0,0 +1,44 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnp/
|
||||||
|
.pnp.js
|
||||||
|
|
||||||
|
# testing
|
||||||
|
coverage/
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
.next/
|
||||||
|
out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
build/
|
||||||
|
|
||||||
|
# misc
|
||||||
|
.DS_Store
|
||||||
|
*.pem
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
|
||||||
|
# local
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel/
|
||||||
|
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
**/*.trace
|
||||||
|
**/*.zip
|
||||||
|
**/*.tar.gz
|
||||||
|
**/*.tgz
|
||||||
|
**/*.log
|
||||||
|
package-lock.json
|
||||||
|
**/*.bun
|
||||||
|
|
||||||
|
*.txt
|
||||||
+25
@@ -0,0 +1,25 @@
|
|||||||
|
FROM oven/bun:1-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json bun.lockb* bun.lock* ./
|
||||||
|
|
||||||
|
RUN if [ -f bun.lockb ] || [ -f bun.lock ]; then \
|
||||||
|
bun install --ci --frozen-lockfile --production; \
|
||||||
|
else \
|
||||||
|
bun install --ci --production; \
|
||||||
|
fi
|
||||||
|
|
||||||
|
FROM oven/bun:1-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
USER bun
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["bun", "run", "start"]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# MBG Back-end
|
||||||
|

|
||||||
|
|
||||||
|
A back-end service built by 🌧 Skyrain Studio.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
Coming soon.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
- Node.js v22.19 and up.
|
||||||
|
- NPM v10.9 and up.
|
||||||
|
- PostgreSQL v17 and up.
|
||||||
|
- S3-compatible object storage (optional, for user session storage).
|
||||||
|
- Docker (for Docker deployment).
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
1. Make sure requirements are met.
|
||||||
|
2. Clone project.
|
||||||
|
3. Copy `.env.example` into `.env`.
|
||||||
|
4. Run `npm install`.
|
||||||
|
5. For development, run `npm run dev`.
|
||||||
|
6. For production, run `npm run start`.
|
||||||
|
|
||||||
|
## Docker Deployment
|
||||||
|
1. Make sure requirements are met.
|
||||||
|
2. Clone project.
|
||||||
|
3. Set the environment variable according to variables defined in `.env`.
|
||||||
|
4. Build image and deploy.
|
||||||
|
|
||||||
|
## Docker Deployment with Docker Compose
|
||||||
|
1. Make sure requirements are met.
|
||||||
|
2. Clone project.
|
||||||
|
3. Copy `docker-compose.example.yaml` into `docker-compose.yaml`.
|
||||||
|
4. Adjust values in `docker-compose.yaml` accordingly.
|
||||||
|
5. Deploy with Docker Compose.
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit'
|
||||||
|
import { dbDsn } from '~/config'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
schema: './src/db/schema/*',
|
||||||
|
out: './drizzle',
|
||||||
|
dialect: 'postgresql',
|
||||||
|
migrations: {
|
||||||
|
prefix: 'unix',
|
||||||
|
},
|
||||||
|
dbCredentials: {
|
||||||
|
url: dbDsn,
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE TYPE "public"."role" AS ENUM('admin', 'visitor', 'user');--> statement-breakpoint
|
||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" "ulid" PRIMARY KEY DEFAULT gen_ulid() NOT NULL,
|
||||||
|
"name" varchar(255) NOT NULL,
|
||||||
|
"email" varchar(255),
|
||||||
|
"phone" varchar(255),
|
||||||
|
"password" varchar(127),
|
||||||
|
"image" text,
|
||||||
|
"google_id" varchar(255),
|
||||||
|
"role" "role" DEFAULT 'user' NOT NULL,
|
||||||
|
"status" boolean DEFAULT true NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
"updated_at" timestamp with time zone NOT NULL,
|
||||||
|
"deleted_at" timestamp with time zone,
|
||||||
|
CONSTRAINT "users_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
{
|
||||||
|
"id": "16065bc5-5f72-44e1-9a45-2f939dd8866a",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.users": {
|
||||||
|
"name": "users",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "ulid",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_ulid()"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"name": "phone",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "varchar(127)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"google_id": {
|
||||||
|
"name": "google_id",
|
||||||
|
"type": "varchar(255)",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "role",
|
||||||
|
"typeSchema": "public",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "'user'"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"name": "status",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"deleted_at": {
|
||||||
|
"name": "deleted_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"users_email_unique": {
|
||||||
|
"name": "users_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {
|
||||||
|
"public.role": {
|
||||||
|
"name": "role",
|
||||||
|
"schema": "public",
|
||||||
|
"values": [
|
||||||
|
"admin",
|
||||||
|
"visitor",
|
||||||
|
"user"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1759370311281,
|
||||||
|
"tag": "1759370311_heavy_gressill",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import stylistic from '@stylistic/eslint-plugin'
|
||||||
|
import { defineConfig } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
{
|
||||||
|
files: ['**/*.ts'],
|
||||||
|
plugins: { js, stylistic },
|
||||||
|
extends: ['js/recommended'],
|
||||||
|
languageOptions: { globals: globals.node },
|
||||||
|
},
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
tseslint.configs.stylistic,
|
||||||
|
stylistic.configs.customize({
|
||||||
|
indent: 2,
|
||||||
|
quotes: 'single',
|
||||||
|
semi: false,
|
||||||
|
arrowParens: true,
|
||||||
|
braceStyle: '1tbs',
|
||||||
|
// 'linebreakStyle': ['error', 'unix'],
|
||||||
|
// 'keyword-spacing': ['error', { before: true, after: true }],
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
files: ['tests/**/*.test.ts'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': 'off',
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'@typescript-eslint/no-empty-function': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"name": "backend-node-mbg",
|
||||||
|
"version": "1.0.50",
|
||||||
|
"scripts": {
|
||||||
|
"start": "bun run src/app/server.ts",
|
||||||
|
"dev": "bun run --watch src/app/server.ts",
|
||||||
|
"db:gen": "drizzle-kit generate",
|
||||||
|
"db:genclean": "rm -rf ./drizzle && drizzle-kit generate",
|
||||||
|
"db:migrate": "drizzle-kit migrate",
|
||||||
|
"db:push": "drizzle-kit push",
|
||||||
|
"setup-admin": "bun run setup-admin.ts",
|
||||||
|
"test": "bun test",
|
||||||
|
"test:watch": "bun test --watch",
|
||||||
|
"test:coverage": "bun test --coverage --coverage-reporter=lcov",
|
||||||
|
"lint": "eslint",
|
||||||
|
"lint:fix": "eslint --fix"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@aws-sdk/client-s3": "^3.893.0",
|
||||||
|
"@bogeychan/elysia-logger": "^0.1.10",
|
||||||
|
"@elysiajs/cors": "^1.4.0",
|
||||||
|
"@elysiajs/jwt": "^1.4.0",
|
||||||
|
"@elysiajs/openapi": "^1.4.11",
|
||||||
|
"@elysiajs/opentelemetry": "^1.4.0",
|
||||||
|
"@elysiajs/swagger": "^1.3.1",
|
||||||
|
"@opentelemetry/auto-instrumentations-node": "^0.64.1",
|
||||||
|
"@opentelemetry/exporter-jaeger": "^2.1.0",
|
||||||
|
"@opentelemetry/exporter-trace-otlp-proto": "^0.205.0",
|
||||||
|
"@opentelemetry/sdk-node": "^0.205.0",
|
||||||
|
"@opentelemetry/sdk-trace-node": "^2.1.0",
|
||||||
|
"@types/pg": "^8.15.5",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"drizzle-kit": "^0.31.4",
|
||||||
|
"drizzle-orm": "^0.44.5",
|
||||||
|
"drizzle-typebox": "^0.3.3",
|
||||||
|
"drizzle-zod": "^0.8.3",
|
||||||
|
"elysia": "latest",
|
||||||
|
"graceful-server-elysia": "^1.0.25",
|
||||||
|
"id128": "^1.6.6",
|
||||||
|
"jose": "^6.1.0",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"redis": "4",
|
||||||
|
"zod": "^4.1.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.35.0",
|
||||||
|
"@stylistic/eslint-plugin": "^5.3.1",
|
||||||
|
"@types/lodash-es": "^4.17.12",
|
||||||
|
"bun-types": "latest",
|
||||||
|
"eslint": "^9.35.0",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"jiti": "^2.5.1",
|
||||||
|
"pino-pretty": "^13.1.1",
|
||||||
|
"typescript-eslint": "^8.44.0"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"@sinclair/typebox": "0.34.33"
|
||||||
|
},
|
||||||
|
"module": "src/app/server.js",
|
||||||
|
"imports": {
|
||||||
|
"##/*": "./src/*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
import { setTimeout } from 'node:timers/promises'
|
||||||
|
import { createInterface } from 'readline'
|
||||||
|
import UserCommandService from './src/modules/user/commands/service'
|
||||||
|
import { passwordHash } from './src/helpers/utils'
|
||||||
|
|
||||||
|
const rl = createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
})
|
||||||
|
|
||||||
|
function question(prompt: string): Promise<string> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(prompt, resolve)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
try {
|
||||||
|
console.log('Setting up admin user...')
|
||||||
|
|
||||||
|
const name = await question('Enter admin name (min 3 characters): ')
|
||||||
|
if (name.length < 3) {
|
||||||
|
console.error('Name must be at least 3 characters long.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailOrPhone = await question('Enter email or phone number: ')
|
||||||
|
if (!emailOrPhone) {
|
||||||
|
console.error('Either email or phone number must be provided.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const password = await question('Enter password (min 8 characters): ')
|
||||||
|
if (password.length < 8) {
|
||||||
|
console.error('Password must be at least 8 characters long.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmPassword = await question('Confirm password: ')
|
||||||
|
if (password !== confirmPassword) {
|
||||||
|
console.error('Passwords do not match.')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if it's email or phone
|
||||||
|
const isEmail = emailOrPhone.includes('@')
|
||||||
|
const userData = {
|
||||||
|
name,
|
||||||
|
password: passwordHash(password),
|
||||||
|
role: 'admin' as const,
|
||||||
|
status: true,
|
||||||
|
...(isEmail ? { email: emailOrPhone } : { phone: emailOrPhone }),
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await UserCommandService.createUser(userData)
|
||||||
|
|
||||||
|
console.log('Admin user created successfully!')
|
||||||
|
console.log(`ID: ${user.id}`)
|
||||||
|
console.log(`Name: ${user.name}`)
|
||||||
|
console.log(`Email/Phone: ${user.email || user.phone}`)
|
||||||
|
console.log(`Role: ${user.role}`)
|
||||||
|
await setTimeout(3000)
|
||||||
|
process.exit()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating admin user:', error instanceof Error ? error.message : String(error))
|
||||||
|
process.exit(1)
|
||||||
|
} finally {
|
||||||
|
rl.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Elysia } from 'elysia'
|
||||||
|
import authRouter from '~/modules/auth/router'
|
||||||
|
import userRouter from '~/modules/user/router'
|
||||||
|
|
||||||
|
export const router = new Elysia({
|
||||||
|
name: 'codebase.router',
|
||||||
|
})
|
||||||
|
.use(authRouter)
|
||||||
|
.use(userRouter)
|
||||||
|
.get('/', () => ({
|
||||||
|
message: 'This service is running as expected.',
|
||||||
|
}))
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Elysia } from 'elysia'
|
||||||
|
import { PORT } from '~/config'
|
||||||
|
import { corsPlugin, defaultHandlerPlugin, gracefulServerPlugin, otelPlugin, setServerIsReady, swaggerPlugin } from '~/plugins'
|
||||||
|
import { router } from './router'
|
||||||
|
|
||||||
|
new Elysia()
|
||||||
|
.use(otelPlugin)
|
||||||
|
.use(corsPlugin)
|
||||||
|
.use(gracefulServerPlugin)
|
||||||
|
.use(defaultHandlerPlugin)
|
||||||
|
.use(swaggerPlugin)
|
||||||
|
.use(router)
|
||||||
|
.listen(PORT, () => {
|
||||||
|
setServerIsReady()
|
||||||
|
})
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
export const ENV = process.env.NODE_ENV || 'development'
|
||||||
|
export const PORT = Number(process.env.PORT || 9000)
|
||||||
|
export const timeZone = process.env.TIMEZONE || 'Asia/Jakarta'
|
||||||
|
|
||||||
|
export const attendanceCheckInHour = Number(process.env.ATTENDANCE_CHECK_IN_HOUR || 8)
|
||||||
|
|
||||||
|
export const dbDsn = process.env.DB_DSN || ''
|
||||||
|
|
||||||
|
export const basicAuth = {
|
||||||
|
username: process.env.BASIC_AUTH_USERNAME || 'basicauth',
|
||||||
|
password: process.env.BASIC_AUTH_PASSWORD || 'supersecret',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const jwt = {
|
||||||
|
algorithm: process.env.JWT_ALG || 'ES256', // ES256 | HS256 | RS256
|
||||||
|
key: {
|
||||||
|
secret: process.env.JWT_SECRET || '', // for HS256 algorithm
|
||||||
|
public: String(process.env.JWT_PUBLIC_KEY || '').replace(/\\n/g, '\n'), // for ES256 or RS256 algorithm
|
||||||
|
private: String(process.env.JWT_PRIVATE_KEY || '').replace(/\\n/g, '\n'), // for ES256 or RS256 algorithm
|
||||||
|
},
|
||||||
|
claims: {
|
||||||
|
issuer: process.env.JWT_ISS || 'skyrain',
|
||||||
|
audience: process.env.JWT_AUD || 'skyrain-mbg',
|
||||||
|
},
|
||||||
|
expires: {
|
||||||
|
access: process.env.JWT_EXP_ACCESS || '15m',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const s3Configs = {
|
||||||
|
config: {
|
||||||
|
region: process.env.S3_REGION || 'us-east-1',
|
||||||
|
endpoint: process.env.S3_URL || '',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: process.env.S3_ACCESS_KEY || '',
|
||||||
|
secretAccessKey: process.env.S3_SECRET_KEY || '',
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
},
|
||||||
|
bucket: process.env.S3_BUCKET_NAME || 'mbg',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const otel = {
|
||||||
|
enabled: process.env.OTEL_ENABLED === 'true',
|
||||||
|
serviceName: process.env.OTEL_SERVICE_NAME || 'backend-node-mbg',
|
||||||
|
tracesExporter: process.env.OTEL_TRACES_EXPORTER || 'otlp',
|
||||||
|
otlpEndpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4319/v1/traces',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CORSConfig = {
|
||||||
|
urls: process.env.CORS_URLS || '*',
|
||||||
|
methods: process.env.CORS_METHODS || '*',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const redis = {
|
||||||
|
host: process.env.REDIS_HOST,
|
||||||
|
port: Number(process.env.REDIS_PORT || 6378),
|
||||||
|
username: process.env.REDIS_USER,
|
||||||
|
password: process.env.REDIS_PASSWORD,
|
||||||
|
tls: Boolean(process.env.REDIS_TLS || false),
|
||||||
|
db: Number(process.env.REDIS_DB),
|
||||||
|
}
|
||||||
|
|
||||||
|
// const minioHost = process.env.MINIO_HOST || ''
|
||||||
|
// export const minio = {
|
||||||
|
// endPoint: minioHost,
|
||||||
|
// port: Number(process.env.MINIO_PORT || 9000),
|
||||||
|
// useSSL: !!(process.env.MINIO_SSL || false),
|
||||||
|
// accessKey: String(process.env.MINIO_ACCESS_KEY) || '',
|
||||||
|
// secretKey: process.env.MINIO_SECRET_KEY || '',
|
||||||
|
// }
|
||||||
|
// export const uploadBucket = process.env.MINIO_BUCKET || ''
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { noDeleteTimestamps, timestamps } from './timestamps'
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { timestamp } from 'drizzle-orm/pg-core'
|
||||||
|
|
||||||
|
export const noDeleteTimestamps = {
|
||||||
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
|
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().$onUpdate(() => new Date()),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const timestamps = {
|
||||||
|
...noDeleteTimestamps,
|
||||||
|
deletedAt: timestamp('deleted_at', { withTimezone: true }),
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/node-postgres'
|
||||||
|
import { Pool } from 'pg'
|
||||||
|
import { dbDsn } from '~/config'
|
||||||
|
import { schema } from './model'
|
||||||
|
|
||||||
|
export const client = new Pool({
|
||||||
|
connectionString: dbDsn,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const db = drizzle({ client, schema: schema })
|
||||||
|
|
||||||
|
export { table, type Table } from './model'
|
||||||
|
export { spread, spreads } from './utils'
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import users, * as userSchema from './schema/users'
|
||||||
|
|
||||||
|
// relation
|
||||||
|
// import * as relations from './relations'
|
||||||
|
|
||||||
|
export const schema = {
|
||||||
|
// ...relations,
|
||||||
|
...userSchema,
|
||||||
|
}
|
||||||
|
|
||||||
|
export const table = {
|
||||||
|
users,
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type Table = typeof table
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// import { relations } from 'drizzle-orm'
|
||||||
|
// import users from './schema/users'
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { boolean, pgEnum, pgTable, serial, text, varchar } from 'drizzle-orm/pg-core'
|
||||||
|
import { timestamps } from '../customTypes'
|
||||||
|
|
||||||
|
export const userRoles = [
|
||||||
|
'admin',
|
||||||
|
'visitor',
|
||||||
|
'user',
|
||||||
|
'volunteer',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const roleEnum = pgEnum('role', userRoles)
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
name: varchar({ length: 255 }).notNull(),
|
||||||
|
email: varchar({ length: 255 }).unique(),
|
||||||
|
phone: varchar({ length: 255 }),
|
||||||
|
password: varchar({ length: 127 }),
|
||||||
|
image: text(),
|
||||||
|
googleId: varchar('google_id', { length: 255 }),
|
||||||
|
role: roleEnum().notNull().default('user'),
|
||||||
|
status: boolean().notNull().default(true),
|
||||||
|
...timestamps,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default users
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
/* eslint-disable @typescript-eslint/no-empty-object-type */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @lastModified 2025-02-04
|
||||||
|
* @see https://elysiajs.com/integrations/drizzle.html#utility
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Kind, type TObject } from '@sinclair/typebox'
|
||||||
|
import {
|
||||||
|
createInsertSchema,
|
||||||
|
createSelectSchema,
|
||||||
|
BuildSchema,
|
||||||
|
} from 'drizzle-typebox'
|
||||||
|
|
||||||
|
// import { table } from './schema'
|
||||||
|
import type { Table } from 'drizzle-orm'
|
||||||
|
|
||||||
|
type Spread<
|
||||||
|
T extends TObject | Table,
|
||||||
|
Mode extends 'select' | 'insert' | undefined,
|
||||||
|
> = T extends TObject<infer Fields>
|
||||||
|
? {
|
||||||
|
[K in keyof Fields]: Fields[K]
|
||||||
|
}
|
||||||
|
: T extends Table
|
||||||
|
? Mode extends 'select'
|
||||||
|
? BuildSchema<
|
||||||
|
'select',
|
||||||
|
T['_']['columns'],
|
||||||
|
undefined
|
||||||
|
>['properties']
|
||||||
|
: Mode extends 'insert'
|
||||||
|
? BuildSchema<
|
||||||
|
'insert',
|
||||||
|
T['_']['columns'],
|
||||||
|
undefined
|
||||||
|
>['properties']
|
||||||
|
: {}
|
||||||
|
: {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spread a Drizzle schema into a plain object
|
||||||
|
*/
|
||||||
|
export const spread = <
|
||||||
|
T extends TObject | Table,
|
||||||
|
Mode extends 'select' | 'insert' | undefined,
|
||||||
|
>(
|
||||||
|
schema: T,
|
||||||
|
mode?: Mode,
|
||||||
|
): Spread<T, Mode> => {
|
||||||
|
const newSchema: Record<string, unknown> = {}
|
||||||
|
let table
|
||||||
|
|
||||||
|
switch (mode) {
|
||||||
|
case 'insert':
|
||||||
|
case 'select':
|
||||||
|
if (Kind in schema) {
|
||||||
|
table = schema
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
table = mode === 'insert'
|
||||||
|
? createInsertSchema(schema)
|
||||||
|
: createSelectSchema(schema)
|
||||||
|
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (!(Kind in schema)) throw new Error('Expect a schema')
|
||||||
|
table = schema
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(table.properties))
|
||||||
|
newSchema[key] = table.properties[key]
|
||||||
|
|
||||||
|
return newSchema as any
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spread a Drizzle Table into a plain object
|
||||||
|
*
|
||||||
|
* If `mode` is 'insert', the schema will be refined for insert
|
||||||
|
* If `mode` is 'select', the schema will be refined for select
|
||||||
|
* If `mode` is undefined, the schema will be spread as is, models will need to be refined manually
|
||||||
|
*/
|
||||||
|
export const spreads = <
|
||||||
|
T extends Record<string, TObject | Table>,
|
||||||
|
Mode extends 'select' | 'insert' | undefined,
|
||||||
|
>(
|
||||||
|
models: T,
|
||||||
|
mode?: Mode,
|
||||||
|
): {
|
||||||
|
[K in keyof T]: Spread<T[K], Mode>
|
||||||
|
} => {
|
||||||
|
const newSchema: Record<string, unknown> = {}
|
||||||
|
const keys = Object.keys(models)
|
||||||
|
|
||||||
|
for (const key of keys) newSchema[key] = spread(models[key], mode)
|
||||||
|
|
||||||
|
return newSchema as any
|
||||||
|
}
|
||||||
Vendored
+323
@@ -0,0 +1,323 @@
|
|||||||
|
import { createClient } from 'redis'
|
||||||
|
import { redis as redisConf } from '~/config'
|
||||||
|
|
||||||
|
type Client = ReturnType<typeof createClient>
|
||||||
|
|
||||||
|
interface RedisGlobal {
|
||||||
|
client?: Client
|
||||||
|
subscriber?: Client
|
||||||
|
connectingPromise?: Promise<Client>
|
||||||
|
subscriberConnectingPromise?: Promise<Client>
|
||||||
|
listenersAttached?: boolean
|
||||||
|
isHealthy?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const globalKey = '__redis'
|
||||||
|
const globalRedis = (globalThis as unknown as Record<string, RedisGlobal>)[globalKey]
|
||||||
|
if (!globalRedis) {
|
||||||
|
(globalThis as unknown as Record<string, RedisGlobal>)[globalKey] = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the Redis configuration from environment variables.
|
||||||
|
* @returns The Redis client options.
|
||||||
|
*/
|
||||||
|
function getRedisConfig() {
|
||||||
|
const { host, port, username, password, db, tls } = redisConf
|
||||||
|
|
||||||
|
const socket: Record<string, unknown> = {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
reconnectStrategy: (retries: number) => {
|
||||||
|
const delay = Math.min(Math.pow(2, retries) * 100, 3000)
|
||||||
|
return delay
|
||||||
|
},
|
||||||
|
keepAlive: true,
|
||||||
|
timeout: 30000,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tls) {
|
||||||
|
socket.tls = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
database: db,
|
||||||
|
socket,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates and returns a shared Redis client.
|
||||||
|
* @returns A promise that resolves to the Redis client.
|
||||||
|
*/
|
||||||
|
export async function getRedis(): Promise<Client> {
|
||||||
|
const globalRedis = ((globalThis as Record<string, unknown>)[globalKey] as RedisGlobal | undefined) || ({} as RedisGlobal)
|
||||||
|
|
||||||
|
if (globalRedis.client && globalRedis.client.isOpen) {
|
||||||
|
return globalRedis.client
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalRedis.connectingPromise) {
|
||||||
|
return globalRedis.connectingPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = getRedisConfig()
|
||||||
|
const client = createClient(config)
|
||||||
|
|
||||||
|
// Attach listeners only once
|
||||||
|
if (!globalRedis.listenersAttached) {
|
||||||
|
client.on('connect', () => {
|
||||||
|
globalRedis.isHealthy = false
|
||||||
|
console.log('[Redis] Connected')
|
||||||
|
})
|
||||||
|
client.on('ready', () => {
|
||||||
|
globalRedis.isHealthy = true
|
||||||
|
console.log('[Redis] Ready')
|
||||||
|
})
|
||||||
|
client.on('reconnecting', () => {
|
||||||
|
globalRedis.isHealthy = false
|
||||||
|
console.log('[Redis] Reconnecting')
|
||||||
|
})
|
||||||
|
client.on('end', () => {
|
||||||
|
globalRedis.isHealthy = false
|
||||||
|
console.log('[Redis] Connection ended')
|
||||||
|
})
|
||||||
|
client.on('error', (err) => {
|
||||||
|
globalRedis.isHealthy = false
|
||||||
|
console.error('[Redis] Error:', err.message)
|
||||||
|
})
|
||||||
|
globalRedis.listenersAttached = true
|
||||||
|
}
|
||||||
|
|
||||||
|
globalRedis.connectingPromise = client.connect().then(() => {
|
||||||
|
globalRedis.client = client as Client
|
||||||
|
delete globalRedis.connectingPromise
|
||||||
|
return client as Client
|
||||||
|
}).catch((err) => {
|
||||||
|
delete globalRedis.connectingPromise
|
||||||
|
throw err
|
||||||
|
}) as Promise<Client>
|
||||||
|
|
||||||
|
return globalRedis.connectingPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the Redis connection is healthy.
|
||||||
|
* @returns True if healthy.
|
||||||
|
*/
|
||||||
|
export function isRedisHealthy(): boolean {
|
||||||
|
const globalRedis = ((globalThis as Record<string, unknown>)[globalKey] as RedisGlobal | undefined) || ({} as RedisGlobal)
|
||||||
|
return globalRedis.isHealthy || false
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily creates and returns a shared Redis subscriber client.
|
||||||
|
* @returns A promise that resolves to the Redis subscriber client.
|
||||||
|
*/
|
||||||
|
export async function getRedisSubscriber(): Promise<Client> {
|
||||||
|
const globalRedis = ((globalThis as Record<string, unknown>)[globalKey] as RedisGlobal | undefined) || ({} as RedisGlobal)
|
||||||
|
|
||||||
|
if (globalRedis.subscriber && globalRedis.subscriber.isOpen) {
|
||||||
|
return globalRedis.subscriber
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalRedis.subscriberConnectingPromise) {
|
||||||
|
return globalRedis.subscriberConnectingPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = await getRedis()
|
||||||
|
const subscriber = client.duplicate()
|
||||||
|
|
||||||
|
globalRedis.subscriberConnectingPromise = subscriber.connect().then(() => {
|
||||||
|
globalRedis.subscriber = subscriber
|
||||||
|
delete globalRedis.subscriberConnectingPromise
|
||||||
|
return subscriber
|
||||||
|
}).catch((err) => {
|
||||||
|
delete globalRedis.subscriberConnectingPromise
|
||||||
|
throw err
|
||||||
|
})
|
||||||
|
|
||||||
|
return globalRedis.subscriberConnectingPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnects the Redis connection gracefully.
|
||||||
|
*/
|
||||||
|
export async function disconnectRedis(): Promise<void> {
|
||||||
|
const globalRedis = ((globalThis as Record<string, unknown>)[globalKey] as RedisGlobal | undefined) || ({} as RedisGlobal)
|
||||||
|
if (globalRedis.client && globalRedis.client.isOpen) {
|
||||||
|
await globalRedis.client.disconnect()
|
||||||
|
delete globalRedis.client
|
||||||
|
}
|
||||||
|
if (globalRedis.subscriber && globalRedis.subscriber.isOpen) {
|
||||||
|
await globalRedis.subscriber.disconnect()
|
||||||
|
delete globalRedis.subscriber
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register process listeners for graceful shutdown
|
||||||
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
let shuttingDown = false
|
||||||
|
const shutdown = async () => {
|
||||||
|
if (shuttingDown) return
|
||||||
|
shuttingDown = true
|
||||||
|
await disconnectRedis()
|
||||||
|
process.exit(0)
|
||||||
|
}
|
||||||
|
process.on('SIGINT', shutdown)
|
||||||
|
process.on('SIGTERM', shutdown)
|
||||||
|
process.on('beforeExit', shutdown)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Executes a function with a Redis client.
|
||||||
|
* @param fn The function to execute.
|
||||||
|
* @returns The result of the function.
|
||||||
|
*/
|
||||||
|
export async function withRedis<T>(fn: (c: Client) => Promise<T>): Promise<T> {
|
||||||
|
const client = await getRedis()
|
||||||
|
return fn(client)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pings the Redis server.
|
||||||
|
* @returns The PONG response.
|
||||||
|
*/
|
||||||
|
export async function ping(): Promise<string> {
|
||||||
|
return withRedis(async (c) => c.ping())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a Redis key with optional prefix.
|
||||||
|
* @param parts The key parts.
|
||||||
|
* @returns The full key.
|
||||||
|
*/
|
||||||
|
export function redisKey(...parts: (string | number)[]): string {
|
||||||
|
const prefix = process.env.REDIS_KEY_PREFIX
|
||||||
|
const keyParts = prefix ? [prefix, ...parts] : parts
|
||||||
|
return keyParts.map(String).join(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a value from Redis.
|
||||||
|
* @param key The key.
|
||||||
|
* @returns The value or null.
|
||||||
|
*/
|
||||||
|
export async function get(key: string): Promise<string | null> {
|
||||||
|
return withRedis(async (c) => c.get(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a value in Redis.
|
||||||
|
* @param key The key.
|
||||||
|
* @param value The value.
|
||||||
|
* @param ttlSeconds Optional TTL.
|
||||||
|
* @returns OK or null.
|
||||||
|
*/
|
||||||
|
export async function set(key: string, value: string, ttlSeconds?: number): Promise<string | null> {
|
||||||
|
return withRedis(async (c) => {
|
||||||
|
if (ttlSeconds) {
|
||||||
|
return c.setEx(key, ttlSeconds, value)
|
||||||
|
}
|
||||||
|
return c.set(key, value)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes keys from Redis.
|
||||||
|
* @param keys The keys.
|
||||||
|
* @returns The number of deleted keys.
|
||||||
|
*/
|
||||||
|
export async function del(keys: string | string[]): Promise<number> {
|
||||||
|
const keyArray = Array.isArray(keys) ? keys : [keys]
|
||||||
|
return withRedis(async (c) => c.del(keyArray))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increments a key by a value.
|
||||||
|
* @param key The key.
|
||||||
|
* @param by The increment value.
|
||||||
|
* @returns The new value.
|
||||||
|
*/
|
||||||
|
export async function incrBy(key: string, by = 1): Promise<number> {
|
||||||
|
return withRedis(async (c) => c.incrBy(key, by))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if keys exist.
|
||||||
|
* @param keys The keys.
|
||||||
|
* @returns The number of existing keys.
|
||||||
|
*/
|
||||||
|
export async function exists(keys: string | string[]): Promise<number> {
|
||||||
|
const keyArray = Array.isArray(keys) ? keys : [keys]
|
||||||
|
return withRedis(async (c) => c.exists(keyArray))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a JSON value in Redis.
|
||||||
|
* @param key The key.
|
||||||
|
* @param value The value.
|
||||||
|
* @param ttlSeconds Optional TTL.
|
||||||
|
* @returns OK or null.
|
||||||
|
*/
|
||||||
|
export async function setJson<T>(key: string, value: T, ttlSeconds?: number): Promise<string | null> {
|
||||||
|
const json = JSON.stringify(value)
|
||||||
|
return set(key, json, ttlSeconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets a JSON value from Redis.
|
||||||
|
* @param key The key.
|
||||||
|
* @returns The parsed value or null.
|
||||||
|
*/
|
||||||
|
export async function getJson<T>(key: string): Promise<T | null> {
|
||||||
|
const value = await get(key)
|
||||||
|
if (!value) return null
|
||||||
|
try {
|
||||||
|
return JSON.parse(value) as T
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acquires a distributed lock.
|
||||||
|
* @param key The lock key.
|
||||||
|
* @param ttlMs The TTL in milliseconds.
|
||||||
|
* @param value Optional value.
|
||||||
|
* @returns The lock object or null.
|
||||||
|
*/
|
||||||
|
export async function acquireLock(
|
||||||
|
key: string,
|
||||||
|
ttlMs: number,
|
||||||
|
value = 'locked',
|
||||||
|
): Promise<null | { value: string, unlock: () => Promise<boolean> }> {
|
||||||
|
const result = await withRedis(async (c) => c.set(key, value, { NX: true, PX: ttlMs }))
|
||||||
|
if (result !== 'OK') return null
|
||||||
|
return {
|
||||||
|
value,
|
||||||
|
unlock: () => releaseLock(key, value),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Releases a distributed lock.
|
||||||
|
* @param key The lock key.
|
||||||
|
* @param value The expected value.
|
||||||
|
* @returns True if released.
|
||||||
|
*/
|
||||||
|
export async function releaseLock(key: string, value: string): Promise<boolean> {
|
||||||
|
const script = `
|
||||||
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("DEL", KEYS[1])
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
`
|
||||||
|
const result = await withRedis(async (c) => c.eval(script, { keys: [key], arguments: [value] }))
|
||||||
|
return result === 1
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class DataAlreadyExistsError extends Error {
|
||||||
|
constructor(public readonly message: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type SlashPath = `/${string}`
|
||||||
|
|
||||||
|
export class FieldValidationError extends Error {
|
||||||
|
constructor(public readonly path: SlashPath, public readonly message: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export type SlashPath = `/${string}`
|
||||||
|
|
||||||
|
export class MultiFieldValidationError extends Error {
|
||||||
|
constructor(public readonly errors: [ path: SlashPath, message: string ][], public readonly message = 'Found errors on multiple fields.') {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export class UnauthenticatedError extends Error {
|
||||||
|
constructor(public readonly message: string) {
|
||||||
|
super(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { FieldValidationError } from './FieldValidationError'
|
||||||
|
import { MultiFieldValidationError } from './MultiFieldValidationError'
|
||||||
|
import { UnauthenticatedError } from './UnauthenticatedError'
|
||||||
|
import { DataAlreadyExistsError } from './DataAlreadyExistsError'
|
||||||
|
|
||||||
|
export * from './FieldValidationError'
|
||||||
|
export * from './UnauthenticatedError'
|
||||||
|
export * from './DataAlreadyExistsError'
|
||||||
|
export { InternalServerError } from 'elysia'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
FIELD_VALIDATION: FieldValidationError,
|
||||||
|
MULTI_FIELD_VALIDATION: MultiFieldValidationError,
|
||||||
|
UNAUTHENTICATED: UnauthenticatedError,
|
||||||
|
DATA_ALREADY_EXISTS: DataAlreadyExistsError,
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
importPKCS8,
|
||||||
|
importSPKI,
|
||||||
|
JWTHeaderParameters,
|
||||||
|
JWTPayload,
|
||||||
|
jwtVerify,
|
||||||
|
SignJWT,
|
||||||
|
errors,
|
||||||
|
} from 'jose'
|
||||||
|
import { jwt as jwtConfig } from '~/config'
|
||||||
|
import { type UserInfo } from '~/modules/auth/schema'
|
||||||
|
|
||||||
|
export const JWTInvalid = errors.JWTInvalid
|
||||||
|
export const JWTExpired = errors.JWTExpired
|
||||||
|
|
||||||
|
type Alg = 'HS256' | 'RS256' | 'ES256'
|
||||||
|
|
||||||
|
interface SignOptions {
|
||||||
|
issuer?: string
|
||||||
|
audience?: string | string[]
|
||||||
|
expiresIn?: string | number
|
||||||
|
kid?: string
|
||||||
|
alg?: Alg // optional override
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VerifyOptions {
|
||||||
|
issuer?: string | string[]
|
||||||
|
audience?: string | string[]
|
||||||
|
clockTolerance?: string | number
|
||||||
|
alg?: Alg // optional override
|
||||||
|
}
|
||||||
|
|
||||||
|
export type JwtClaims = JWTPayload & {
|
||||||
|
user: UserInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
let cachedAlg: Alg | null = null
|
||||||
|
let cachedSignerKey: CryptoKey | null = null
|
||||||
|
let cachedVerifierKey: CryptoKey | null = null
|
||||||
|
|
||||||
|
function getAlg(override?: Alg): Alg {
|
||||||
|
const envAlg = jwtConfig.algorithm.toUpperCase()
|
||||||
|
const alg = (override || envAlg) as Alg
|
||||||
|
if (!['HS256', 'RS256', 'ES256'].includes(alg)) {
|
||||||
|
throw new Error(`Unsupported JWT_ALG: ${alg}`)
|
||||||
|
}
|
||||||
|
return alg
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importHmacKey(secret: string): Promise<CryptoKey> {
|
||||||
|
const raw = new TextEncoder().encode(secret)
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
raw,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign', 'verify'],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadKeys(alg: Alg): Promise<{ signKey: CryptoKey, verifyKey: CryptoKey }> {
|
||||||
|
if (cachedAlg === alg && cachedSignerKey && cachedVerifierKey) {
|
||||||
|
return { signKey: cachedSignerKey, verifyKey: cachedVerifierKey }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (alg === 'HS256') {
|
||||||
|
const key = await importHmacKey(jwtConfig.key.secret)
|
||||||
|
cachedAlg = alg
|
||||||
|
cachedSignerKey = key
|
||||||
|
cachedVerifierKey = key
|
||||||
|
return { signKey: key, verifyKey: key }
|
||||||
|
}
|
||||||
|
|
||||||
|
const priv = await importPKCS8(jwtConfig.key.private, alg)
|
||||||
|
const pub = await importSPKI(jwtConfig.key.public, alg)
|
||||||
|
cachedAlg = alg
|
||||||
|
cachedSignerKey = priv
|
||||||
|
cachedVerifierKey = pub
|
||||||
|
return { signKey: priv, verifyKey: pub }
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sign(
|
||||||
|
claims: JwtClaims,
|
||||||
|
opts: SignOptions = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const alg = getAlg(opts.alg)
|
||||||
|
const { signKey } = await loadKeys(alg)
|
||||||
|
|
||||||
|
const header: JWTHeaderParameters = { alg, ...(opts.kid ? { kid: opts.kid } : {}) }
|
||||||
|
|
||||||
|
const builder = new SignJWT(claims).setProtectedHeader(header).setIssuedAt()
|
||||||
|
|
||||||
|
if (opts.issuer) builder.setIssuer(opts.issuer)
|
||||||
|
if (opts.audience) builder.setAudience(opts.audience)
|
||||||
|
builder.setExpirationTime(opts.expiresIn ?? jwtConfig.expires.access)
|
||||||
|
|
||||||
|
return builder.sign(signKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verify<T extends JWTPayload = JwtClaims>(
|
||||||
|
token: string,
|
||||||
|
opts: VerifyOptions = {},
|
||||||
|
): Promise<T> {
|
||||||
|
const alg = getAlg(opts.alg)
|
||||||
|
const { verifyKey } = await loadKeys(alg)
|
||||||
|
|
||||||
|
const { payload } = await jwtVerify(token, verifyKey, {
|
||||||
|
algorithms: [alg],
|
||||||
|
issuer: opts.issuer,
|
||||||
|
audience: opts.audience,
|
||||||
|
clockTolerance: opts.clockTolerance ?? '5s',
|
||||||
|
})
|
||||||
|
|
||||||
|
return payload as T
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { createHmac, timingSafeEqual } from 'crypto'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonicalizes an object by recursively removing 'signature' properties,
|
||||||
|
* sorting object keys lexicographically, preserving array order,
|
||||||
|
* omitting undefined, functions, and symbols from objects,
|
||||||
|
* converting undefined or unsupported array elements to null,
|
||||||
|
* converting Dates to ISO strings, BigInt to strings,
|
||||||
|
* and non-finite numbers (NaN/Infinity/-Infinity) to null.
|
||||||
|
* Throws on circular references or unsupported objects.
|
||||||
|
* @param data The data to canonicalize.
|
||||||
|
* @returns The canonicalized, signature-free structure.
|
||||||
|
*/
|
||||||
|
export function canonicalizeObject<T = unknown>(data: T): unknown {
|
||||||
|
const visited = new Set<unknown>()
|
||||||
|
|
||||||
|
function recurse(value: unknown): unknown {
|
||||||
|
if (value === null || typeof value === 'boolean' || typeof value === 'string') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
if (!isFinite(value)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'bigint') {
|
||||||
|
return value.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toISOString()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
if (visited.has(value)) {
|
||||||
|
throw new Error('Circular reference detected in array')
|
||||||
|
}
|
||||||
|
visited.add(value)
|
||||||
|
const result = value.map((item) => {
|
||||||
|
if (item === undefined) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return recurse(item)
|
||||||
|
})
|
||||||
|
visited.delete(value)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === 'object') {
|
||||||
|
if (visited.has(value)) {
|
||||||
|
throw new Error('Circular reference detected in object')
|
||||||
|
}
|
||||||
|
visited.add(value)
|
||||||
|
|
||||||
|
const obj = value as Record<string | symbol, unknown>
|
||||||
|
const result: Record<string, unknown> = {}
|
||||||
|
|
||||||
|
for (const key in obj) {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(obj, key) && typeof key === 'string' && key !== 'signature') {
|
||||||
|
const val = obj[key]
|
||||||
|
if (val !== undefined && typeof val !== 'function' && typeof val !== 'symbol') {
|
||||||
|
result[key] = recurse(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort keys lexicographically
|
||||||
|
const sortedKeys = Object.keys(result).sort()
|
||||||
|
const sortedResult: Record<string, unknown> = {}
|
||||||
|
for (const key of sortedKeys) {
|
||||||
|
sortedResult[key] = result[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
visited.delete(value)
|
||||||
|
return sortedResult
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unsupported types
|
||||||
|
if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'undefined') {
|
||||||
|
throw new Error(`Unsupported type: ${typeof value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it has toJSON, try that
|
||||||
|
if (value && typeof (value as { toJSON(): unknown }).toJSON === 'function') {
|
||||||
|
return recurse((value as { toJSON(): unknown }).toJSON())
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Cannot canonicalize object of type ${typeof value}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return recurse(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the canonical JSON string of the data.
|
||||||
|
* @param data The data to canonicalize.
|
||||||
|
* @returns The canonical JSON string.
|
||||||
|
*/
|
||||||
|
export function canonicalize(data: unknown): string {
|
||||||
|
return JSON.stringify(canonicalizeObject(data))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs the data using HMAC-SHA256 and returns the signature as lowercase hex.
|
||||||
|
* @param data The data to sign.
|
||||||
|
* @param secretKey The secret key. If not provided, uses process.env.SECRET_KEY.
|
||||||
|
* @returns The signature.
|
||||||
|
*/
|
||||||
|
export function sign(data: unknown, secretKey?: string): string {
|
||||||
|
if (!secretKey) {
|
||||||
|
secretKey = process.env.SECRET_KEY
|
||||||
|
if (!secretKey || secretKey.trim() === '') {
|
||||||
|
throw new Error('Secret key is required. Provide it as a parameter or set process.env.SECRET_KEY.')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const canonical = canonicalize(data)
|
||||||
|
const hmac = createHmac('sha256', secretKey)
|
||||||
|
hmac.update(canonical, 'utf8')
|
||||||
|
return hmac.digest('hex')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the signature of the data using HMAC-SHA256 with constant-time comparison.
|
||||||
|
* @param data The data to verify.
|
||||||
|
* @param signature The expected signature.
|
||||||
|
* @param secretKey The secret key. If not provided, uses process.env.SECRET_KEY.
|
||||||
|
* @returns True if the signature is valid, false otherwise.
|
||||||
|
*/
|
||||||
|
export function verify(data: unknown, signature: string, secretKey?: string): boolean {
|
||||||
|
try {
|
||||||
|
const computed = sign(data, secretKey)
|
||||||
|
const computedBuf = Buffer.from(computed, 'hex')
|
||||||
|
const providedBuf = Buffer.from(signature, 'hex')
|
||||||
|
if (computedBuf.length !== providedBuf.length) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return timingSafeEqual(computedBuf, providedBuf)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// formats.ts
|
||||||
|
import { FormatRegistry } from '@sinclair/typebox'
|
||||||
|
|
||||||
|
FormatRegistry.Set('mobilePhone', (value): boolean =>
|
||||||
|
typeof value === 'string' && /^08\d{8,13}$/.test(value),
|
||||||
|
)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export const passwordHash = (password: string) => Bun.password.hashSync(password, { algorithm: 'bcrypt' })
|
||||||
|
|
||||||
|
export const passwordVerify = (password: string, hash: string) => Bun.password.verifySync(password, hash)
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { HTTPHeaders, StatusMap } from 'elysia'
|
||||||
|
import { basicAuth as credential } from '~/config'
|
||||||
|
import { UnauthenticatedError } from '~/helpers/errors'
|
||||||
|
import { logger } from '~/plugins'
|
||||||
|
|
||||||
|
export const basicAuthMiddleware = (
|
||||||
|
headers: Record<string, string | undefined>,
|
||||||
|
set: {
|
||||||
|
headers: HTTPHeaders
|
||||||
|
status?: number | keyof StatusMap
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
const expectedAuth = Buffer.from(`${credential.username}:${credential.password}`).toString('base64')
|
||||||
|
if (headers.authorization !== `Basic ${expectedAuth}`) {
|
||||||
|
logger.debug({
|
||||||
|
'headers.authorization': headers.authorization,
|
||||||
|
}, 'Failed basic authentication attempt')
|
||||||
|
set.status = 401
|
||||||
|
set.headers['WWW-Authenticate'] = 'Basic realm="Restricted Area"'
|
||||||
|
throw new UnauthenticatedError('Unauthenticated.')
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { HTTPHeaders, StatusMap } from 'elysia'
|
||||||
|
import { UnauthenticatedError } from '~/helpers/errors'
|
||||||
|
import { verify, JWTExpired, JWTInvalid } from '~/helpers/jwt'
|
||||||
|
import { logger } from '~/plugins'
|
||||||
|
|
||||||
|
const throwError = (
|
||||||
|
set: {
|
||||||
|
headers: HTTPHeaders
|
||||||
|
status?: number | keyof StatusMap
|
||||||
|
},
|
||||||
|
message = 'Unauthenticated.',
|
||||||
|
) => {
|
||||||
|
set.status = 401
|
||||||
|
set.headers['WWW-Authenticate'] = 'Bearer realm="Restricted Area"'
|
||||||
|
throw new UnauthenticatedError(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bearerAuthMiddleware = async (
|
||||||
|
headers: Record<string, string | undefined>,
|
||||||
|
set: {
|
||||||
|
headers: HTTPHeaders
|
||||||
|
status?: number | keyof StatusMap
|
||||||
|
},
|
||||||
|
) => {
|
||||||
|
if (headers.authorization === undefined) {
|
||||||
|
logger.debug({
|
||||||
|
'headers.authorization': headers.authorization,
|
||||||
|
}, 'Failed bearer authentication attempt due to missing auth header')
|
||||||
|
throwError(set)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headers.authorization?.startsWith('Bearer ')) {
|
||||||
|
logger.debug({
|
||||||
|
'headers.authorization': headers.authorization,
|
||||||
|
}, 'Failed bearer authentication attempt due to invalid auth header')
|
||||||
|
throwError(set)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = await verify(`${headers.authorization}`.replace(/^Bearer /, ''))
|
||||||
|
|
||||||
|
return payload.user
|
||||||
|
} catch (error) {
|
||||||
|
let message = undefined
|
||||||
|
|
||||||
|
if (error instanceof JWTExpired) {
|
||||||
|
logger.debug({
|
||||||
|
'headers.authorization': headers.authorization,
|
||||||
|
}, 'Failed bearer authentication attempt due to expired token')
|
||||||
|
message = 'Token expired.'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof JWTInvalid) {
|
||||||
|
logger.debug({
|
||||||
|
'headers.authorization': headers.authorization,
|
||||||
|
}, 'Failed bearer authentication attempt due to invalid token')
|
||||||
|
message = 'Token invalid.'
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.debug({
|
||||||
|
'headers.authorization': headers.authorization,
|
||||||
|
}, 'Failed bearer authentication attempt due to failed verification')
|
||||||
|
throwError(set, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { basicAuthMiddleware } from './basicAuth'
|
||||||
|
export { bearerAuthMiddleware } from './bearerAuth'
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { UnauthenticatedError } from '~/helpers/errors'
|
||||||
|
import { LoginBody, LoginResponse } from '../schema'
|
||||||
|
import UserQuery from '@/user/queries/query'
|
||||||
|
import { passwordVerify } from '~/helpers/utils'
|
||||||
|
import { logger } from '~/plugins'
|
||||||
|
import { isEmpty, omit } from 'lodash-es'
|
||||||
|
import { sign } from '~/helpers/jwt'
|
||||||
|
import { UserInfo } from '../schema'
|
||||||
|
|
||||||
|
export default abstract class CommandService {
|
||||||
|
static async login(body: LoginBody): Promise<LoginResponse> {
|
||||||
|
const user = await UserQuery.getUserByEmail(body.email)
|
||||||
|
if (!user) {
|
||||||
|
logger.debug({
|
||||||
|
'body.email': body.email,
|
||||||
|
}, 'Login failed due to user not found.')
|
||||||
|
throw new UnauthenticatedError('Invalid username or password.')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isEmpty(user.password) && !passwordVerify(body.password, String(user.password))) {
|
||||||
|
logger.info({
|
||||||
|
'user.hasPassword': !isEmpty(user.password),
|
||||||
|
}, 'Login failed due to invalid password.')
|
||||||
|
throw new UnauthenticatedError('Invalid username or password.')
|
||||||
|
}
|
||||||
|
|
||||||
|
const docs = omit(user, ['password', 'deletedAt']) as UserInfo
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const sub = `${user.id}.${now.toString(36).toUpperCase()}`
|
||||||
|
const accessToken = await sign({
|
||||||
|
sub,
|
||||||
|
user: docs,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
accessToken,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Elysia } from 'elysia'
|
||||||
|
import { basicAuthMiddleware, bearerAuthMiddleware } from '~/middlewares'
|
||||||
|
import { getInfoSchema, loginBody, loginResponseSchema } from './schema'
|
||||||
|
import CommandService from './commands/service'
|
||||||
|
|
||||||
|
export const router = new Elysia({
|
||||||
|
name: 'modules.auth',
|
||||||
|
detail: { tags: ['Authentication'] },
|
||||||
|
prefix: '/auth/v1',
|
||||||
|
})
|
||||||
|
.post('/login', async ({ headers, set, body }) => {
|
||||||
|
// basic auth middleware
|
||||||
|
basicAuthMiddleware(headers, set)
|
||||||
|
|
||||||
|
const user = await CommandService.login(body)
|
||||||
|
return {
|
||||||
|
message: 'Logged in.',
|
||||||
|
data: user,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: loginBody,
|
||||||
|
response: loginResponseSchema,
|
||||||
|
detail: {
|
||||||
|
security: [{ basicAuth: [] }],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.get('/info', async ({ headers, set }) => {
|
||||||
|
// bearer auth middleware
|
||||||
|
const user = await bearerAuthMiddleware(headers, set)
|
||||||
|
return {
|
||||||
|
message: 'Get user info.',
|
||||||
|
data: user,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
response: getInfoSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { selectUserSchema } from '../user/schema'
|
||||||
|
|
||||||
|
export const loginBody = z.object({
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
})
|
||||||
|
export type LoginBody = z.infer<typeof loginBody>
|
||||||
|
|
||||||
|
const loginResponse = z.object({
|
||||||
|
accessToken: z.string().describe('Access token of the user, encoded in JWT format'),
|
||||||
|
// refreshToken: z.string().describe('Refresh token of the user, encoded in JWT format'),
|
||||||
|
})
|
||||||
|
export type LoginResponse = z.infer<typeof loginResponse>
|
||||||
|
|
||||||
|
const userInfo = selectUserSchema.omit({
|
||||||
|
password: true,
|
||||||
|
deletedAt: true,
|
||||||
|
})
|
||||||
|
export const getInfoResponse = userInfo
|
||||||
|
export type UserInfo = z.infer<typeof getInfoResponse>
|
||||||
|
|
||||||
|
export const loginResponseSchema = {
|
||||||
|
200: z.object({
|
||||||
|
message: z.literal('Logged in.'),
|
||||||
|
data: loginResponse,
|
||||||
|
}),
|
||||||
|
401: z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getInfoSchema = {
|
||||||
|
200: z.object({
|
||||||
|
message: z.literal('Get user info.'),
|
||||||
|
data: getInfoResponse,
|
||||||
|
}),
|
||||||
|
401: z.object({
|
||||||
|
message: z.string(),
|
||||||
|
}),
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { db, table } from '~/db'
|
||||||
|
import type { NewUser } from '../schema'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
|
||||||
|
export default abstract class Command {
|
||||||
|
static async insertUser(user: NewUser) {
|
||||||
|
const newUser = await db.insert(table.users).values(user).returning()
|
||||||
|
return newUser[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateUser(id: number, body: NewUser) {
|
||||||
|
const user = await db.update(table.users)
|
||||||
|
.set(body)
|
||||||
|
.where(eq(table.users.id, id))
|
||||||
|
.returning()
|
||||||
|
return user[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteUser(id: number) {
|
||||||
|
const user = await db.update(table.users)
|
||||||
|
.set({ deletedAt: new Date() })
|
||||||
|
.where(eq(table.users.id, id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
return user[0]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import type { CreateUserSchema, NewUser } from '../schema'
|
||||||
|
import Command from './command'
|
||||||
|
import Query from '../queries/query'
|
||||||
|
import { NotFoundError } from 'elysia'
|
||||||
|
import { passwordHash } from '~/helpers/utils'
|
||||||
|
import { FieldValidationError } from '~/helpers/errors'
|
||||||
|
import { roleEnum } from '~/db/schema/users'
|
||||||
|
|
||||||
|
export default abstract class CommandService {
|
||||||
|
static async createUser(body: CreateUserSchema) {
|
||||||
|
if (!body.email && !body.phone) {
|
||||||
|
throw new FieldValidationError('/', 'Either email or phone number must be provided')
|
||||||
|
}
|
||||||
|
|
||||||
|
// check email availability
|
||||||
|
if (body.email) {
|
||||||
|
const checkEmail = await Query.getUserByEmail(body.email)
|
||||||
|
if (checkEmail) {
|
||||||
|
throw new FieldValidationError('/email', 'Email already in use')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.password) {
|
||||||
|
body.password = passwordHash(body.password)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
...body,
|
||||||
|
role: body.role as typeof roleEnum.enumValues[number],
|
||||||
|
status: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdUser = await Command.insertUser(newUser)
|
||||||
|
|
||||||
|
return createdUser
|
||||||
|
}
|
||||||
|
|
||||||
|
static async updateUser(id: number, body: CreateUserSchema) {
|
||||||
|
// check id
|
||||||
|
const checkId = await Query.getUserById(id)
|
||||||
|
if (!checkId) {
|
||||||
|
throw new NotFoundError('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
// check name
|
||||||
|
const checkName = await Query.getUserByName(body.name)
|
||||||
|
if (checkName && checkName.id !== id) {
|
||||||
|
throw new FieldValidationError('/name', 'Name already in use')
|
||||||
|
}
|
||||||
|
|
||||||
|
// check email
|
||||||
|
if (body.email) {
|
||||||
|
const checkEmail = await Query.getUserByEmail(body.email)
|
||||||
|
if (checkEmail?.id !== id) {
|
||||||
|
throw new FieldValidationError('/email', 'Email already in use')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newUser = {
|
||||||
|
...body,
|
||||||
|
status: true,
|
||||||
|
} as NewUser
|
||||||
|
|
||||||
|
return Command.updateUser(id, newUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
static async deleteUser(id: number) {
|
||||||
|
// check id
|
||||||
|
const checkId = await Query.getUserById(id)
|
||||||
|
if (!checkId) {
|
||||||
|
throw new NotFoundError('User not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
return Command.deleteUser(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { db } from '~/db'
|
||||||
|
|
||||||
|
export default abstract class Query {
|
||||||
|
static async listUser() {
|
||||||
|
const user = await db.query.users.findMany({
|
||||||
|
where: (u, { isNull }) => isNull(u.deletedAt),
|
||||||
|
})
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserById(id: number) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
columns: {
|
||||||
|
deletedAt: false,
|
||||||
|
},
|
||||||
|
where: (u, { and, eq, isNull }) => and(
|
||||||
|
eq(u.id, id),
|
||||||
|
isNull(u.deletedAt),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserByEmail(email: string) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
columns: {
|
||||||
|
deletedAt: false,
|
||||||
|
},
|
||||||
|
where: (u, { and, eq, isNull }) => and(
|
||||||
|
eq(u.email, email),
|
||||||
|
isNull(u.deletedAt),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
static async getUserByName(name: string) {
|
||||||
|
const user = await db.query.users.findFirst({
|
||||||
|
columns: {
|
||||||
|
deletedAt: false,
|
||||||
|
},
|
||||||
|
where: (u, { and, eq, isNull }) => and(
|
||||||
|
eq(u.name, name),
|
||||||
|
isNull(u.deletedAt),
|
||||||
|
),
|
||||||
|
})
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import Query from './query'
|
||||||
|
|
||||||
|
export default abstract class QueryService {
|
||||||
|
static async getUsers() {
|
||||||
|
const allUsers = await Query.listUser()
|
||||||
|
return allUsers
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Elysia, t } from 'elysia'
|
||||||
|
import { bearerAuthMiddleware } from '~/middlewares'
|
||||||
|
import { validationErrorSchema } from '~/plugins/defaultHandler'
|
||||||
|
import CommandService from './commands/service'
|
||||||
|
import QueryService from './queries/service'
|
||||||
|
import { createUserBody, createUserResponse, listUserResponse } from './schema'
|
||||||
|
|
||||||
|
export const router = new Elysia({
|
||||||
|
name: 'modules.user',
|
||||||
|
detail: { tags: ['Users'] },
|
||||||
|
prefix: '/user/v1/users',
|
||||||
|
})
|
||||||
|
.get('/', async ({ headers, set }) => {
|
||||||
|
await bearerAuthMiddleware(headers, set)
|
||||||
|
|
||||||
|
const users = await QueryService.getUsers()
|
||||||
|
return {
|
||||||
|
message: 'Get users.' as const,
|
||||||
|
data: users,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
message: t.Literal('Get users.'),
|
||||||
|
data: listUserResponse,
|
||||||
|
}),
|
||||||
|
401: t.Object({
|
||||||
|
message: t.Literal('Unauthenticated.'),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.post('/', async ({ body, headers, set }) => {
|
||||||
|
await bearerAuthMiddleware(headers, set)
|
||||||
|
|
||||||
|
const user = await CommandService.createUser(body)
|
||||||
|
return {
|
||||||
|
message: 'User created.',
|
||||||
|
data: user,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: createUserBody,
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
message: t.Literal('User created.'),
|
||||||
|
data: createUserResponse,
|
||||||
|
}),
|
||||||
|
401: t.Object({
|
||||||
|
message: t.Literal('Unauthenticated.'),
|
||||||
|
}),
|
||||||
|
422: validationErrorSchema,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.put('/:id', async ({ params: { id }, body, headers, set }) => {
|
||||||
|
await bearerAuthMiddleware(headers, set)
|
||||||
|
|
||||||
|
const user = await CommandService.updateUser(id, body)
|
||||||
|
return {
|
||||||
|
message: 'User updated.' as const,
|
||||||
|
data: user,
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
body: createUserBody,
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
message: t.Literal('User updated.'),
|
||||||
|
data: createUserResponse,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.delete('/:id', async ({ params: { id }, headers, set }) => {
|
||||||
|
await bearerAuthMiddleware(headers, set)
|
||||||
|
|
||||||
|
await CommandService.deleteUser(id)
|
||||||
|
return {
|
||||||
|
message: 'User deleted.' as const,
|
||||||
|
data: { id },
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
response: {
|
||||||
|
200: t.Object({
|
||||||
|
message: t.Literal('User deleted.'),
|
||||||
|
data: t.Object({ id: t.String() }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { createSelectSchema, createInsertSchema } from 'drizzle-zod'
|
||||||
|
import { table as $t } from '~/db'
|
||||||
|
import { roleEnum } from '~/db/schema/users'
|
||||||
|
|
||||||
|
export const selectUserSchema = createSelectSchema($t.users)
|
||||||
|
export const $s = selectUserSchema.shape
|
||||||
|
export const $i = createInsertSchema($t.users).shape
|
||||||
|
|
||||||
|
export const roleSchema = createSelectSchema(roleEnum)
|
||||||
|
|
||||||
|
export const createUserBody = z.object({
|
||||||
|
name: $i.name.min(3),
|
||||||
|
email: $i.email,
|
||||||
|
phone: z.string().optional(),
|
||||||
|
password: z.string().min(8),
|
||||||
|
role: roleSchema.optional(), // will validate in service
|
||||||
|
partnerId: z.string().optional(),
|
||||||
|
})
|
||||||
|
export type CreateUserSchema = z.infer<typeof createUserBody>
|
||||||
|
|
||||||
|
export const createUserResponse = selectUserSchema.pick({
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
role: true,
|
||||||
|
status: true,
|
||||||
|
createdAt: true,
|
||||||
|
})
|
||||||
|
export const listUserResponse = z.array(createUserResponse)
|
||||||
|
|
||||||
|
export type User = typeof $t.users.$inferSelect
|
||||||
|
export type NewUser = typeof $t.users.$inferInsert
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { cors } from '@elysiajs/cors'
|
||||||
|
import { CORSConfig } from '~/config'
|
||||||
|
|
||||||
|
export const corsPlugin = cors({
|
||||||
|
origin: CORSConfig.urls.split(','),
|
||||||
|
credentials: true,
|
||||||
|
methods: CORSConfig.methods.split(','),
|
||||||
|
})
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { Elysia, t, ValidationError } from 'elysia'
|
||||||
|
import { TypeCheck, type ValueError } from '@sinclair/typebox/compiler'
|
||||||
|
import logger from './logger'
|
||||||
|
import customErrors, { FieldValidationError } from '~/helpers/errors'
|
||||||
|
import { Prettify } from 'elysia/dist/types'
|
||||||
|
import { MultiFieldValidationError } from '~/helpers/errors/MultiFieldValidationError'
|
||||||
|
|
||||||
|
function isNumericStatus(c: unknown): c is number {
|
||||||
|
return typeof c === 'number'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validationSchema = t.Object({
|
||||||
|
path: t.String({ description: 'Usually contains the name of field being validated.' }),
|
||||||
|
message: t.String({ description: 'Contains the validation error message.' }),
|
||||||
|
})
|
||||||
|
export const validationErrorSchema = t.Object({
|
||||||
|
message: t.Literal('Failed to validate request.'),
|
||||||
|
details: t.Array(validationSchema, {
|
||||||
|
description: 'Contain details of validation errors being thrown.',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
type ValidationResponseItem = typeof validationSchema.static
|
||||||
|
|
||||||
|
export type ValidationResponse = ValidationResponse[]
|
||||||
|
|
||||||
|
export const defaultHandlerPlugin = new Elysia({
|
||||||
|
name: 'codebase.defaultHandler',
|
||||||
|
})
|
||||||
|
.error(customErrors)
|
||||||
|
.onError(({ error, code, status }) => {
|
||||||
|
switch (code) {
|
||||||
|
case 'NOT_FOUND':
|
||||||
|
return status(404, {
|
||||||
|
message: 'Route not found.',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'UNAUTHENTICATED':
|
||||||
|
return status(401, {
|
||||||
|
message: error.message,
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'DATA_ALREADY_EXISTS':
|
||||||
|
return status(409, {
|
||||||
|
message: error.message,
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'FIELD_VALIDATION':
|
||||||
|
return status(422, handleFieldValidationError(error))
|
||||||
|
|
||||||
|
case 'MULTI_FIELD_VALIDATION':
|
||||||
|
return status(422, handleMultiFieldValidationError(error))
|
||||||
|
|
||||||
|
case 'VALIDATION':
|
||||||
|
return status(422, handleValidationError(error))
|
||||||
|
|
||||||
|
case 'PARSE':
|
||||||
|
return status(400, {
|
||||||
|
message: 'Failed to parse request body.',
|
||||||
|
})
|
||||||
|
|
||||||
|
case 'UNKNOWN':
|
||||||
|
logger.error(error)
|
||||||
|
return {
|
||||||
|
message: 'Server error occured.',
|
||||||
|
details: {
|
||||||
|
error: error.name,
|
||||||
|
message: error.message,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (isNumericStatus(code)) {
|
||||||
|
const body = typeof error === 'string'
|
||||||
|
? { error: 'HTTP_ERROR', message: error }
|
||||||
|
: (error as Record<string, unknown>) ?? { error: 'HTTP_ERROR' }
|
||||||
|
return status(code, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.error(error)
|
||||||
|
return {
|
||||||
|
message: 'Server error occured.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.as('global') // important to make sure this applies to all routes
|
||||||
|
|
||||||
|
const handleValidationError = (error: ValidationError) => {
|
||||||
|
const details: ValidationResponseItem[] = []
|
||||||
|
if (error.validator instanceof TypeCheck) {
|
||||||
|
const errIterator = error.validator.Errors(error.value)
|
||||||
|
for (const err of errIterator) {
|
||||||
|
details.push({
|
||||||
|
path: err.path || '/',
|
||||||
|
message: err.schema.error?.toString() || err.message,
|
||||||
|
// details: err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (const err of error.all as Prettify<{ summary: string } & ValueError>[]) {
|
||||||
|
if (typeof err.summary === 'string') {
|
||||||
|
details.push({
|
||||||
|
path: err.path || '/',
|
||||||
|
message: err.summary,
|
||||||
|
// details: err,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Failed to validate request.',
|
||||||
|
details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFieldValidationError = (error: FieldValidationError) => {
|
||||||
|
const errors: ValidationResponseItem[] = [{
|
||||||
|
path: error.path || '/',
|
||||||
|
message: error.message,
|
||||||
|
// details: err,
|
||||||
|
}]
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Failed to validate request.',
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMultiFieldValidationError = (error: MultiFieldValidationError) => {
|
||||||
|
const errors: ValidationResponseItem[] = error.errors.map((err) => ({
|
||||||
|
path: err[0],
|
||||||
|
message: err[1],
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: error.message,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Elysia } from 'elysia'
|
||||||
|
import { db } from '~/db'
|
||||||
|
|
||||||
|
export const drizzlePlugin = new Elysia({
|
||||||
|
name: 'plugins.dsizzle',
|
||||||
|
})
|
||||||
|
.decorate('db', db)
|
||||||
|
.as('global')
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { pluginGracefulServer } from 'graceful-server-elysia'
|
||||||
|
import logger from './logger'
|
||||||
|
import { PORT } from '~/config'
|
||||||
|
|
||||||
|
export const gracefulServerPlugin = pluginGracefulServer({
|
||||||
|
onStart: () => {
|
||||||
|
logger.info('Service is starting...')
|
||||||
|
},
|
||||||
|
onReady: () => {
|
||||||
|
logger.info(`Service is running at http://0.0.0.0:${PORT}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export { setServerIsReady } from 'graceful-server-elysia'
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export { corsPlugin } from './cors'
|
||||||
|
export { defaultHandlerPlugin } from './defaultHandler'
|
||||||
|
export { gracefulServerPlugin, setServerIsReady } from './gracefulServer'
|
||||||
|
export { logger, loggerPlugin } from './logger'
|
||||||
|
export { drizzlePlugin } from './drizzle'
|
||||||
|
export { swaggerPlugin } from './swagger'
|
||||||
|
export { otelPlugin } from './otel'
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { LoggerOptions, TransportTargetOptions } from 'pino'
|
||||||
|
import { createPinoLogger } from '@bogeychan/elysia-logger'
|
||||||
|
import { ENV } from '~/config'
|
||||||
|
|
||||||
|
const loggerOptions: LoggerOptions = {
|
||||||
|
level: ENV === 'production' ? 'info' : 'debug',
|
||||||
|
formatters: {
|
||||||
|
bindings: () => ({}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const loggerTransports: TransportTargetOptions[] = []
|
||||||
|
|
||||||
|
if (ENV === 'development') {
|
||||||
|
loggerTransports.push({
|
||||||
|
target: 'pino-pretty',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
loggerTransports.push({
|
||||||
|
target: 'pino/file',
|
||||||
|
options: { destination: 1 },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const logger = createPinoLogger({
|
||||||
|
...loggerOptions,
|
||||||
|
transport: {
|
||||||
|
targets: loggerTransports,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loggerPlugin = logger.into
|
||||||
|
|
||||||
|
export default logger
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { Elysia } from 'elysia'
|
||||||
|
import { opentelemetry } from '@elysiajs/opentelemetry'
|
||||||
|
import { NodeSDK } from '@opentelemetry/sdk-node'
|
||||||
|
import { getNodeAutoInstrumentations } from '@opentelemetry/auto-instrumentations-node'
|
||||||
|
import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'
|
||||||
|
import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-proto'
|
||||||
|
import { otel } from '~/config'
|
||||||
|
|
||||||
|
let sdk: NodeSDK | undefined
|
||||||
|
|
||||||
|
if (otel.enabled) {
|
||||||
|
sdk = new NodeSDK({
|
||||||
|
serviceName: otel.serviceName,
|
||||||
|
spanProcessors: [
|
||||||
|
new BatchSpanProcessor(
|
||||||
|
new OTLPTraceExporter({
|
||||||
|
url: otel.otlpEndpoint,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
instrumentations: [getNodeAutoInstrumentations()],
|
||||||
|
})
|
||||||
|
|
||||||
|
sdk.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const otelPlugin = otel.enabled ? opentelemetry() : (app: Elysia) => app
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { S3Client } from '@aws-sdk/client-s3'
|
||||||
|
import { s3Configs } from '~/config'
|
||||||
|
|
||||||
|
export const s3Plugin = new S3Client(s3Configs.config)
|
||||||
|
|
||||||
|
export const s3BucketName = s3Configs.bucket
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { openapi } from '@elysiajs/openapi'
|
||||||
|
import { OpenAPIV3 } from 'openapi-types'
|
||||||
|
import { PORT } from '~/config'
|
||||||
|
|
||||||
|
interface OpenApiDocWithTagGroups { 'x-tagGroups': { name: string, tags: string[] } }
|
||||||
|
type OpenApiDocumentation = Omit<Partial<OpenAPIV3.Document<OpenApiDocWithTagGroups>>, 'x-express-openapi-additional-middleware' | 'x-express-openapi-validation-strict'>
|
||||||
|
|
||||||
|
export const swaggerPlugin = openapi({
|
||||||
|
path: '/swagger',
|
||||||
|
provider: 'scalar',
|
||||||
|
documentation: {
|
||||||
|
'info': {
|
||||||
|
title: 'Backend node Skyrain API Documentation',
|
||||||
|
version: 'v1.0.0',
|
||||||
|
},
|
||||||
|
'tags': [
|
||||||
|
{ name: 'Authentication', description: 'Authentication API' },
|
||||||
|
{ name: 'Users', description: 'User API' },
|
||||||
|
],
|
||||||
|
'servers': [
|
||||||
|
{
|
||||||
|
url: 'https://skyrainstudio.com',
|
||||||
|
description: 'production domain',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `http://localhost:${PORT}`,
|
||||||
|
description: 'localhost',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `https://skyrainstudio.com`,
|
||||||
|
description: 'Dev server',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// --- Grouping di atas tag ---
|
||||||
|
'x-tagGroups': [
|
||||||
|
{ name: 'Auth & IAM', tags: ['Authentication', 'Users'] },
|
||||||
|
],
|
||||||
|
'components': {
|
||||||
|
securitySchemes: {
|
||||||
|
basicAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'basic',
|
||||||
|
},
|
||||||
|
bearerAuth: {
|
||||||
|
type: 'http',
|
||||||
|
scheme: 'bearer',
|
||||||
|
bearerFormat: 'JWT',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'security': [{ bearerAuth: [] }],
|
||||||
|
} as OpenApiDocumentation,
|
||||||
|
})
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { describe, expect, it, beforeEach, mock } from 'bun:test'
|
||||||
|
import { UnauthenticatedError, InternalServerError } from '~/helpers/errors'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
mock.module('@/user/queries/query', () => ({
|
||||||
|
default: {
|
||||||
|
getUserByEmail: mock(async (email: string) => {
|
||||||
|
if (email === 'test@example.com') {
|
||||||
|
return {
|
||||||
|
id: 'user-123',
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
password: '$2a$10$mockhashedpassword',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email === 'volunteer@example.com') {
|
||||||
|
return {
|
||||||
|
id: 'volunteer-123',
|
||||||
|
name: 'Test Volunteer',
|
||||||
|
email: 'volunteer@example.com',
|
||||||
|
role: 'volunteer',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
password: '$2a$10$mockhashedpassword',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
getVolunteerById: mock(async ({ id }: { id: string }) => {
|
||||||
|
if (id === 'volunteer-123') {
|
||||||
|
return {
|
||||||
|
id: 'vol-456',
|
||||||
|
partnerId: 'partner-789',
|
||||||
|
name: 'Test Volunteer',
|
||||||
|
phone: '+1234567890',
|
||||||
|
gender: 'male',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/helpers/utils', () => ({
|
||||||
|
passwordVerify: mock((password: string, _hash: string) => {
|
||||||
|
return password === 'correctpassword'
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/helpers/jwt', () => ({
|
||||||
|
sign: mock(async (payload: unknown) => {
|
||||||
|
return `jwt.token.${Buffer.from(JSON.stringify(payload)).toString('base64')}`
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/plugins', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: mock(() => undefined),
|
||||||
|
info: mock(() => undefined),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import CommandService from '~/modules/auth/commands/service'
|
||||||
|
|
||||||
|
describe('Auth CommandService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset all mocks before each test
|
||||||
|
mock.restore()
|
||||||
|
|
||||||
|
// Reapply main mocks
|
||||||
|
mock.module('@/user/queries/query', () => ({
|
||||||
|
default: {
|
||||||
|
getUserByEmail: mock(async (email: string) => {
|
||||||
|
if (email === 'test@example.com') {
|
||||||
|
return {
|
||||||
|
id: 'user-123',
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
password: '$2a$10$mockhashedpassword',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (email === 'volunteer@example.com') {
|
||||||
|
return {
|
||||||
|
id: 'volunteer-123',
|
||||||
|
name: 'Test Volunteer',
|
||||||
|
email: 'volunteer@example.com',
|
||||||
|
role: 'volunteer',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
password: '$2a$10$mockhashedpassword',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
getVolunteerById: mock(async ({ id }: { id: string }) => {
|
||||||
|
if (id === 'volunteer-123') {
|
||||||
|
return {
|
||||||
|
id: 'vol-456',
|
||||||
|
partnerId: 'partner-789',
|
||||||
|
name: 'Test Volunteer',
|
||||||
|
phone: '+1234567890',
|
||||||
|
gender: 'male',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/helpers/utils', () => ({
|
||||||
|
passwordVerify: mock((password: string, hash: string) => {
|
||||||
|
return password === 'correctpassword'
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/helpers/jwt', () => ({
|
||||||
|
sign: mock(async (payload: any) => {
|
||||||
|
return `jwt.token.${Buffer.from(JSON.stringify(payload)).toString('base64')}`
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/plugins', () => ({
|
||||||
|
logger: {
|
||||||
|
debug: mock(() => {}),
|
||||||
|
info: mock(() => {}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('login', () => {
|
||||||
|
it('should login successfully with valid credentials', async () => {
|
||||||
|
const result = await CommandService.login({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'correctpassword',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('accessToken')
|
||||||
|
expect(typeof result.accessToken).toBe('string')
|
||||||
|
expect(result.accessToken).toMatch(/^jwt\.token\./)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw UnauthenticatedError for non-existent user', async () => {
|
||||||
|
expect(async () => {
|
||||||
|
await CommandService.login({
|
||||||
|
email: 'nonexistent@example.com',
|
||||||
|
password: 'anypassword',
|
||||||
|
})
|
||||||
|
}).toThrow(UnauthenticatedError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw UnauthenticatedError for incorrect password', async () => {
|
||||||
|
expect(async () => {
|
||||||
|
await CommandService.login({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'wrongpassword',
|
||||||
|
})
|
||||||
|
}).toThrow(UnauthenticatedError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should login volunteer user successfully', async () => {
|
||||||
|
const result = await CommandService.login({
|
||||||
|
email: 'volunteer@example.com',
|
||||||
|
password: 'correctpassword',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('accessToken')
|
||||||
|
expect(typeof result.accessToken).toBe('string')
|
||||||
|
|
||||||
|
// Decode the JWT payload to verify volunteer data is included
|
||||||
|
const payloadBase64 = result.accessToken.split('.')[2]
|
||||||
|
const payload = JSON.parse(Buffer.from(payloadBase64, 'base64').toString())
|
||||||
|
expect(payload.user).toHaveProperty('volunteer')
|
||||||
|
expect(payload.user.volunteer.id).toBe('vol-456')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw InternalServerError when volunteer detail not found', async () => {
|
||||||
|
// Mock getUserByEmail to return a volunteer user but getVolunteerById to return null
|
||||||
|
const mockUserQuery = await import('~/modules/user/queries/query')
|
||||||
|
mockUserQuery.default.getUserByEmail = mock(async () => ({
|
||||||
|
id: 'volunteer-without-detail',
|
||||||
|
name: 'Volunteer Without Detail',
|
||||||
|
email: 'volunteer-no-detail@example.com',
|
||||||
|
role: 'volunteer',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
password: '$2a$10$mockhashedpassword',
|
||||||
|
})) as any
|
||||||
|
mockUserQuery.default.getVolunteerById = mock(async () => null) as any
|
||||||
|
|
||||||
|
expect(async () => {
|
||||||
|
await CommandService.login({
|
||||||
|
email: 'volunteer-no-detail@example.com',
|
||||||
|
password: 'correctpassword',
|
||||||
|
})
|
||||||
|
}).toThrow(InternalServerError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate unique sub for each login', async () => {
|
||||||
|
const result1 = await CommandService.login({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'correctpassword',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wait a millisecond to ensure different timestamp
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1))
|
||||||
|
|
||||||
|
const result2 = await CommandService.login({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'correctpassword',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Decode both tokens to compare sub values
|
||||||
|
const payload1Base64 = result1.accessToken.split('.')[2]
|
||||||
|
const payload1 = JSON.parse(Buffer.from(payload1Base64, 'base64').toString())
|
||||||
|
|
||||||
|
const payload2Base64 = result2.accessToken.split('.')[2]
|
||||||
|
const payload2 = JSON.parse(Buffer.from(payload2Base64, 'base64').toString())
|
||||||
|
|
||||||
|
expect(payload1.sub).not.toBe(payload2.sub)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should include correct user data in JWT payload', async () => {
|
||||||
|
const result = await CommandService.login({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'correctpassword',
|
||||||
|
})
|
||||||
|
|
||||||
|
const payloadBase64 = result.accessToken.split('.')[2]
|
||||||
|
const payload = JSON.parse(Buffer.from(payloadBase64, 'base64').toString())
|
||||||
|
|
||||||
|
expect(payload.user.id).toBe('user-123')
|
||||||
|
expect(payload.user.name).toBe('Test User')
|
||||||
|
expect(payload.user.email).toBe('test@example.com')
|
||||||
|
expect(payload.user.role).toBe('admin')
|
||||||
|
expect(payload.user).not.toHaveProperty('password')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
import { describe, expect, it, beforeEach, mock } from 'bun:test'
|
||||||
|
import { Elysia } from 'elysia'
|
||||||
|
import { router } from '~/modules/auth/router'
|
||||||
|
|
||||||
|
// Mock dependencies
|
||||||
|
mock.module('~/middlewares', () => ({
|
||||||
|
basicAuthMiddleware: mock((headers: Record<string, string | undefined>, set: { status?: number }) => {
|
||||||
|
if (!headers.authorization || headers.authorization !== 'Basic dGVzdDp0ZXN0') {
|
||||||
|
set.status = 401
|
||||||
|
throw new Error('Unauthenticated.')
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
bearerAuthMiddleware: mock(async (headers: Record<string, string | undefined>, set: { status?: number }) => {
|
||||||
|
if (!headers.authorization || !headers.authorization.startsWith('Bearer ')) {
|
||||||
|
set.status = 401
|
||||||
|
throw new Error('Unauthenticated.')
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: 123,
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
phone: null,
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
role: 'admin',
|
||||||
|
status: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('@/user/queries/query', () => ({
|
||||||
|
default: {
|
||||||
|
getUserByEmail: mock(async (email: string) => {
|
||||||
|
if (email === 'test@example.com') {
|
||||||
|
return {
|
||||||
|
id: 'user-123',
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
password: '$2a$10$mockhashedpassword',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
getVolunteerById: mock(async ({ id }: { id: string }) => {
|
||||||
|
if (id === 'volunteer-123') {
|
||||||
|
return {
|
||||||
|
id: 'vol-456',
|
||||||
|
partnerId: 'partner-789',
|
||||||
|
name: 'Test Volunteer',
|
||||||
|
phone: '+1234567890',
|
||||||
|
gender: 'male',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/helpers/utils', () => ({
|
||||||
|
passwordVerify: mock((password: string, _hash: string) => {
|
||||||
|
return password === 'correctpassword'
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/helpers/jwt', () => ({
|
||||||
|
sign: mock(async (_payload: unknown) => 'mock-access-token-12345'),
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Auth Router', () => {
|
||||||
|
let app: Elysia
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
app = new Elysia().use(router)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('POST /auth/v1/login', () => {
|
||||||
|
it('should login successfully with valid credentials', async () => {
|
||||||
|
const response = await app.handle(
|
||||||
|
new Request('http://localhost/auth/v1/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Basic dGVzdDp0ZXN0', // test:test in base64
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'correctpassword',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const data = await response.json()
|
||||||
|
expect(data.message).toBe('Logged in.')
|
||||||
|
expect(data.data.accessToken).toBe('mock-access-token-12345')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail login with invalid basic auth', async () => {
|
||||||
|
const response = await app.handle(
|
||||||
|
new Request('http://localhost/auth/v1/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Basic aW52YWxpZA==', // invalid:invalid in base64
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'correctpassword',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail login without authorization header', async () => {
|
||||||
|
const response = await app.handle(
|
||||||
|
new Request('http://localhost/auth/v1/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate request body schema', async () => {
|
||||||
|
const response = await app.handle(
|
||||||
|
new Request('http://localhost/auth/v1/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Basic dGVzdDp0ZXN0',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
email: 'invalid-email',
|
||||||
|
password: 'short',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(422)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('GET /auth/v1/info', () => {
|
||||||
|
it('should get user info with valid bearer token', async () => {
|
||||||
|
const response = await app.handle(
|
||||||
|
new Request('http://localhost/auth/v1/info', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Bearer valid-token-123',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(200)
|
||||||
|
const data = await response.json()
|
||||||
|
expect(data.message).toBe('Get user info.')
|
||||||
|
expect(data.data.id).toBe(123)
|
||||||
|
expect(data.data.email).toBe('test@example.com')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail with invalid bearer token', async () => {
|
||||||
|
const response = await app.handle(
|
||||||
|
new Request('http://localhost/auth/v1/info', {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
Authorization: 'Invalid token-123',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fail without authorization header', async () => {
|
||||||
|
const response = await app.handle(
|
||||||
|
new Request('http://localhost/auth/v1/info', {
|
||||||
|
method: 'GET',
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.status).toBe(401)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { loginBody, getInfoResponse, loginResponseSchema, getInfoSchema } from '~/modules/auth/schema'
|
||||||
|
|
||||||
|
describe('Auth Schema', () => {
|
||||||
|
describe('loginBody', () => {
|
||||||
|
it('should validate correct login body', () => {
|
||||||
|
const validBody = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'password123',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => loginBody.parse(validBody)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject invalid email format', () => {
|
||||||
|
const invalidBody = {
|
||||||
|
email: 'invalid-email',
|
||||||
|
password: 'password123',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => loginBody.parse(invalidBody)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject short password', () => {
|
||||||
|
const invalidBody = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
password: 'short',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => loginBody.parse(invalidBody)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject missing email', () => {
|
||||||
|
const invalidBody = {
|
||||||
|
password: 'password123',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => loginBody.parse(invalidBody)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject missing password', () => {
|
||||||
|
const invalidBody = {
|
||||||
|
email: 'test@example.com',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => loginBody.parse(invalidBody)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getInfoResponse', () => {
|
||||||
|
it('should validate admin user info', () => {
|
||||||
|
const adminUser = {
|
||||||
|
id: 123,
|
||||||
|
name: 'Admin User',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
phone: null,
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
role: 'admin',
|
||||||
|
status: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => getInfoResponse.parse(adminUser)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should validate volunteer user info', () => {
|
||||||
|
const volunteerUser = {
|
||||||
|
id: 456,
|
||||||
|
name: 'Volunteer User',
|
||||||
|
email: 'volunteer@example.com',
|
||||||
|
phone: '+1234567890',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
role: 'volunteer',
|
||||||
|
status: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => getInfoResponse.parse(volunteerUser)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject invalid role', () => {
|
||||||
|
const invalidUser = {
|
||||||
|
id: 'user-123',
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'invalid-role',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => getInfoResponse.parse(invalidUser)).toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject volunteer without volunteer data', () => {
|
||||||
|
const invalidVolunteer = {
|
||||||
|
role: 'volunteer',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => getInfoResponse.parse(invalidVolunteer)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('loginResponseSchema', () => {
|
||||||
|
it('should have correct 200 response structure', () => {
|
||||||
|
const response200 = loginResponseSchema[200]
|
||||||
|
expect(response200).toBeDefined()
|
||||||
|
|
||||||
|
const validResponse = {
|
||||||
|
message: 'Logged in.',
|
||||||
|
data: {
|
||||||
|
accessToken: 'some-jwt-token',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => response200.parse(validResponse)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct 401 response structure', () => {
|
||||||
|
const response401 = loginResponseSchema[401]
|
||||||
|
expect(response401).toBeDefined()
|
||||||
|
|
||||||
|
const validError = {
|
||||||
|
message: 'Invalid username or password.',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => response401.parse(validError)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject 200 response with wrong message', () => {
|
||||||
|
const response200 = loginResponseSchema[200]
|
||||||
|
const invalidResponse = {
|
||||||
|
message: 'Wrong message.',
|
||||||
|
data: {
|
||||||
|
accessToken: 'some-jwt-token',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => response200.parse(invalidResponse)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('getInfoSchema', () => {
|
||||||
|
it('should have correct 200 response structure for admin', () => {
|
||||||
|
const response200 = getInfoSchema[200]
|
||||||
|
expect(response200).toBeDefined()
|
||||||
|
|
||||||
|
const validResponse = {
|
||||||
|
message: 'Get user info.',
|
||||||
|
data: {
|
||||||
|
id: 123,
|
||||||
|
name: 'Admin User',
|
||||||
|
email: 'admin@example.com',
|
||||||
|
phone: null,
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
role: 'admin',
|
||||||
|
status: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => response200.parse(validResponse)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct 200 response structure for volunteer', () => {
|
||||||
|
const response200 = getInfoSchema[200]
|
||||||
|
|
||||||
|
const validResponse = {
|
||||||
|
message: 'Get user info.',
|
||||||
|
data: {
|
||||||
|
id: 789,
|
||||||
|
name: 'Volunteer User',
|
||||||
|
email: 'volunteer@example.com',
|
||||||
|
phone: '+1234567890',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
role: 'volunteer',
|
||||||
|
status: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => response200.parse(validResponse)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should have correct 401 response structure', () => {
|
||||||
|
const response401 = getInfoSchema[401]
|
||||||
|
expect(response401).toBeDefined()
|
||||||
|
|
||||||
|
const validError = {
|
||||||
|
message: 'Unauthenticated.',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => response401.parse(validError)).not.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reject 200 response with wrong message', () => {
|
||||||
|
const response200 = getInfoSchema[200]
|
||||||
|
const invalidResponse = {
|
||||||
|
message: 'Wrong message.',
|
||||||
|
data: {
|
||||||
|
id: 'user-123',
|
||||||
|
name: 'Test User',
|
||||||
|
email: 'test@example.com',
|
||||||
|
role: 'admin',
|
||||||
|
image: null,
|
||||||
|
googleId: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(() => response200.parse(invalidResponse)).toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { describe, expect, it, beforeEach, mock } from 'bun:test'
|
||||||
|
import { FieldValidationError } from '~/helpers/errors'
|
||||||
|
import { MultiFieldValidationError } from '~/helpers/errors/MultiFieldValidationError'
|
||||||
|
|
||||||
|
import CommandService from '~/modules/purchase_order/commands/service'
|
||||||
|
|
||||||
|
describe('Purchase Order CommandService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// reset and reapply mocks to keep isolated state for each test
|
||||||
|
mock.restore()
|
||||||
|
|
||||||
|
mock.module('~/modules/partner/queries/query', () => ({
|
||||||
|
default: {
|
||||||
|
getById: mock(async (id: string) => {
|
||||||
|
if (id === 'partner-123') return { id: 'partner-123', name: 'Partner' }
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/modules/supplier/queries/query', () => ({
|
||||||
|
default: {
|
||||||
|
getById: mock(async (id: string) => {
|
||||||
|
if (id === 'supplier-123') return { id: 'supplier-123', name: 'Supplier' }
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/modules/supplier_item/queries/query', () => ({
|
||||||
|
default: {
|
||||||
|
checkSupplierItemsByIds: mock(async (supplierId: string, ids: string[]) => ids.map((id) => ({ id, price: 100 }))),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/modules/purchase_order/commands/command', () => ({
|
||||||
|
default: {
|
||||||
|
insertPurchaseOrder: mock(async (body: any, supplierItems: any[]) => ({ id: 'po-1', ...body })),
|
||||||
|
updatePurchaseOrderWithItems: mock(async (id: string, body: any) => ({ id, ...body })),
|
||||||
|
deletePurchaseOrder: mock(async (id: string) => [{ deletedAt: new Date() }]),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module('~/modules/purchase_order/queries/query', () => ({
|
||||||
|
default: {
|
||||||
|
getPurchaseOrderById: mock(async (id: string) => {
|
||||||
|
if (id === 'existing-po') return { id: 'existing-po' }
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('createPurchaseOrder', () => {
|
||||||
|
it('should throw FieldValidationError when partner not found', async () => {
|
||||||
|
expect(CommandService.createPurchaseOrder({
|
||||||
|
partnerId: 'missing-partner',
|
||||||
|
supplierId: 'supplier-123',
|
||||||
|
orderedAt: new Date().toISOString(),
|
||||||
|
receiptAt: new Date().toISOString(),
|
||||||
|
orderItems: [{ id: 'item-1', qty: 1 }],
|
||||||
|
})).rejects.toThrow(FieldValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw FieldValidationError when supplier not found', async () => {
|
||||||
|
expect(CommandService.createPurchaseOrder({
|
||||||
|
partnerId: 'partner-123',
|
||||||
|
supplierId: 'missing-supplier',
|
||||||
|
orderedAt: new Date().toISOString(),
|
||||||
|
receiptAt: new Date().toISOString(),
|
||||||
|
orderItems: [{ id: 'item-1', qty: 1 }],
|
||||||
|
})).rejects.toThrow(FieldValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw MultiFieldValidationError when supplier does not provide items', async () => {
|
||||||
|
// override supplier item check to return different item
|
||||||
|
const supplierItemQuery = await import('~/modules/supplier_item/queries/query')
|
||||||
|
supplierItemQuery.default.checkSupplierItemsByIds = mock(async () => [{ id: 'other-item', price: 100 }]) as any
|
||||||
|
|
||||||
|
expect(CommandService.createPurchaseOrder({
|
||||||
|
partnerId: 'partner-123',
|
||||||
|
supplierId: 'supplier-123',
|
||||||
|
orderedAt: new Date().toISOString(),
|
||||||
|
receiptAt: new Date().toISOString(),
|
||||||
|
orderItems: [{ id: 'item-1', qty: 1 }],
|
||||||
|
})).rejects.toThrow(MultiFieldValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should throw MultiFieldValidationError when supplier has unset price', async () => {
|
||||||
|
const supplierItemQuery = await import('~/modules/supplier_item/queries/query')
|
||||||
|
supplierItemQuery.default.checkSupplierItemsByIds = mock(async (supplierId: string, ids: string[]) => ids.map((id) => ({ id, price: 0 }))) as any
|
||||||
|
|
||||||
|
expect(CommandService.createPurchaseOrder({
|
||||||
|
partnerId: 'partner-123',
|
||||||
|
supplierId: 'supplier-123',
|
||||||
|
orderedAt: new Date().toISOString(),
|
||||||
|
receiptAt: new Date().toISOString(),
|
||||||
|
orderItems: [{ id: 'item-1', qty: 1 }],
|
||||||
|
})).rejects.toThrow(MultiFieldValidationError)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create purchase order with merged items', async () => {
|
||||||
|
const result = await CommandService.createPurchaseOrder({
|
||||||
|
partnerId: 'partner-123',
|
||||||
|
supplierId: 'supplier-123',
|
||||||
|
orderedAt: new Date().toISOString(),
|
||||||
|
receiptAt: new Date().toISOString(),
|
||||||
|
orderItems: [{ id: 'item-1', qty: 1 }, { id: 'item-1', qty: 2 }],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result.id).toBe('po-1')
|
||||||
|
expect(result.orderItems).toBeDefined()
|
||||||
|
// merged qty should be 3
|
||||||
|
const merged = result.orderItems.find((o: any) => o.id === 'item-1')
|
||||||
|
expect(merged).toBeDefined()
|
||||||
|
expect((merged as any).qty).toBe(3)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('updatePurchaseOrder', () => {
|
||||||
|
it('should throw NotFoundError when purchase order not found', async () => {
|
||||||
|
expect(CommandService.updatePurchaseOrder('missing-po', {
|
||||||
|
partnerId: 'partner-123',
|
||||||
|
supplierId: 'supplier-123',
|
||||||
|
orderedAt: new Date().toISOString(),
|
||||||
|
receiptAt: new Date().toISOString(),
|
||||||
|
orderItems: [{ id: 'item-1', qty: 1 }],
|
||||||
|
})).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update existing purchase order', async () => {
|
||||||
|
const result = await CommandService.updatePurchaseOrder('existing-po', {
|
||||||
|
partnerId: 'partner-123',
|
||||||
|
supplierId: 'supplier-123',
|
||||||
|
orderedAt: new Date().toISOString(),
|
||||||
|
receiptAt: new Date().toISOString(),
|
||||||
|
orderItems: [{ id: 'item-1', qty: 1 }],
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result.id).toBe('existing-po')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('deletePurchaseOrder', () => {
|
||||||
|
it('should throw NotFoundError when purchase order not found', async () => {
|
||||||
|
expect(CommandService.deletePurchaseOrder('missing-po')).rejects.toThrow()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should delete existing purchase order', async () => {
|
||||||
|
const result = await CommandService.deletePurchaseOrder('existing-po')
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(result.deletedAt).toBeValidDate()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, expect, it, beforeEach, mock } from 'bun:test'
|
||||||
|
import QueryService from '~/modules/purchase_order/queries/service'
|
||||||
|
|
||||||
|
// Default mocks
|
||||||
|
mock.module('~/modules/purchase_order/queries/query', () => ({
|
||||||
|
default: {
|
||||||
|
listPurchaseOrders: mock(async (payload: any) => ({
|
||||||
|
data: [{ id: 'po-1', orderNumber: 'PO-001' }],
|
||||||
|
meta: { page: 1, size: 10, totalData: 1, totalPage: 1 },
|
||||||
|
})),
|
||||||
|
getPurchaseOrderById: mock(async (id: string) => {
|
||||||
|
if (id === 'po-1') return { id: 'po-1', orderNumber: 'PO-001' }
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
getPurchaseOrderReport: mock(async (params: any) => ({
|
||||||
|
purchaseOrders: [{ id: 'po-1', orderNumber: 'PO-001' }],
|
||||||
|
summary: { totalAmount: 1000, totalClaimAmount: 100, totalMarginAmount: 50 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('Purchase Order QueryService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mock.restore()
|
||||||
|
mock.module('~/modules/purchase_order/queries/query', () => ({
|
||||||
|
default: {
|
||||||
|
listPurchaseOrders: mock(async (payload: any) => ({
|
||||||
|
data: [{ id: 'po-1', orderNumber: 'PO-001' }],
|
||||||
|
meta: { page: payload.page ?? 1, size: payload.size ?? 10, totalData: 1, totalPage: 1 },
|
||||||
|
})),
|
||||||
|
getPurchaseOrderById: mock(async (id: string) => {
|
||||||
|
if (id === 'po-1') return { id: 'po-1', orderNumber: 'PO-001' }
|
||||||
|
return null
|
||||||
|
}),
|
||||||
|
getPurchaseOrderReport: mock(async (params: any) => ({
|
||||||
|
purchaseOrders: [{ id: 'po-1', orderNumber: 'PO-001' }],
|
||||||
|
summary: { totalAmount: 1000, totalClaimAmount: 100, totalMarginAmount: 50 },
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return purchase orders list with meta', async () => {
|
||||||
|
const result = await QueryService.getPurchaseOrders({ page: 1, size: 10 })
|
||||||
|
expect(result).toBeDefined()
|
||||||
|
expect(Array.isArray(result.data)).toBe(true)
|
||||||
|
expect(result.meta).toBeDefined()
|
||||||
|
expect(result.meta.page).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return a single purchase order when found', async () => {
|
||||||
|
const po = await QueryService.getPurchaseOrder('po-1')
|
||||||
|
expect(po).toBeDefined()
|
||||||
|
expect(po).not.toBeNull()
|
||||||
|
expect((po as any).id).toBe('po-1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return null when purchase order not found', async () => {
|
||||||
|
const po = await QueryService.getPurchaseOrder('missing-po')
|
||||||
|
expect(po).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return report data', async () => {
|
||||||
|
const report = await QueryService.getPurchaseOrderReport({
|
||||||
|
partnerId: 'partner-123',
|
||||||
|
startDate: new Date().toISOString(),
|
||||||
|
endDate: new Date().toISOString(),
|
||||||
|
} as any)
|
||||||
|
expect(report).toBeDefined()
|
||||||
|
expect(Array.isArray(report.purchaseOrders)).toBe(true)
|
||||||
|
expect(report.summary).toBeDefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig to read more about this file */
|
||||||
|
|
||||||
|
/* Projects */
|
||||||
|
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
|
||||||
|
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
|
||||||
|
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
|
||||||
|
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
|
||||||
|
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
|
||||||
|
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
|
||||||
|
|
||||||
|
/* Language and Environment */
|
||||||
|
"target": "es2022", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
|
||||||
|
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
|
||||||
|
// "jsx": "preserve", /* Specify what JSX code is generated. */
|
||||||
|
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
|
||||||
|
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
|
||||||
|
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
|
||||||
|
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
|
||||||
|
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
|
||||||
|
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
|
||||||
|
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
|
||||||
|
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
|
||||||
|
|
||||||
|
/* Modules */
|
||||||
|
"module": "es2022", /* Specify what module code is generated. */
|
||||||
|
// "rootDir": "./", /* Specify the root folder within your source files. */
|
||||||
|
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
|
||||||
|
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
|
||||||
|
"paths": { /* Specify a set of entries that re-map imports to additional lookup locations. */
|
||||||
|
"~/*": ["./src/*"],
|
||||||
|
"@/*": ["./src/modules/*"],
|
||||||
|
},
|
||||||
|
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
|
||||||
|
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
|
||||||
|
"types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
|
||||||
|
// "resolveJsonModule": true, /* Enable importing .json files. */
|
||||||
|
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
|
||||||
|
|
||||||
|
/* JavaScript Support */
|
||||||
|
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
|
||||||
|
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
|
||||||
|
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
|
||||||
|
|
||||||
|
/* Emit */
|
||||||
|
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
|
||||||
|
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
|
||||||
|
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
|
||||||
|
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
|
||||||
|
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
|
||||||
|
// "outDir": "./", /* Specify an output folder for all emitted files. */
|
||||||
|
// "removeComments": true, /* Disable emitting comments. */
|
||||||
|
// "noEmit": true, /* Disable emitting files from a compilation. */
|
||||||
|
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
|
||||||
|
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
|
||||||
|
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
|
||||||
|
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
|
||||||
|
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
|
||||||
|
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
|
||||||
|
// "newLine": "crlf", /* Set the newline character for emitting files. */
|
||||||
|
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
|
||||||
|
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
|
||||||
|
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
|
||||||
|
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
|
||||||
|
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
|
||||||
|
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
|
||||||
|
|
||||||
|
/* Interop Constraints */
|
||||||
|
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
|
||||||
|
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
|
||||||
|
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
|
||||||
|
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
|
||||||
|
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
|
||||||
|
|
||||||
|
/* Type Checking */
|
||||||
|
"strict": true, /* Enable all strict type-checking options. */
|
||||||
|
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
|
||||||
|
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
|
||||||
|
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
|
||||||
|
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
|
||||||
|
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
|
||||||
|
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
|
||||||
|
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
|
||||||
|
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
|
||||||
|
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
|
||||||
|
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
|
||||||
|
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
|
||||||
|
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
|
||||||
|
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
|
||||||
|
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
|
||||||
|
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
|
||||||
|
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
|
||||||
|
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
|
||||||
|
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
|
||||||
|
|
||||||
|
/* Completeness */
|
||||||
|
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
|
||||||
|
"skipLibCheck": true /* Skip type checking all .d.ts files. */
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user