first commit 🎉

This commit is contained in:
2025-10-08 11:37:54 +07:00
commit 4ec61fa51e
61 changed files with 4558 additions and 0 deletions
+15
View File
@@ -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
+25
View File
@@ -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=
+70
View File
@@ -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
View File
@@ -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
View File
@@ -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"]
+35
View File
@@ -0,0 +1,35 @@
# MBG Back-end
![Version](https://img.shields.io/badge/Version-1.0.0-green?style=flat-square&logo=gitea&logoColor=white)
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.
+1317
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -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,
},
})
+16
View File
@@ -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")
);
+127
View File
@@ -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": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1759370311281,
"tag": "1759370311_heavy_gressill",
"breakpoints": true
}
]
}
+33
View File
@@ -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',
},
},
])
+64
View File
@@ -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/*"
}
}
+73
View File
@@ -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()
+12
View File
@@ -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.',
}))
+15
View File
@@ -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()
})
+72
View File
@@ -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 || ''
+1
View File
@@ -0,0 +1 @@
export { noDeleteTimestamps, timestamps } from './timestamps'
+11
View File
@@ -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 }),
}
+13
View File
@@ -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'
+15
View File
@@ -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
+2
View File
@@ -0,0 +1,2 @@
// import { relations } from 'drizzle-orm'
// import users from './schema/users'
+26
View File
@@ -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
View File
@@ -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
}
+323
View File
@@ -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)
}
}
+16
View File
@@ -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,
}
+115
View File
@@ -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
}
+145
View File
@@ -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
}
}
+6
View File
@@ -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),
)
+3
View File
@@ -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)
+22
View File
@@ -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.')
}
}
+65
View File
@@ -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)
}
}
+2
View File
@@ -0,0 +1,2 @@
export { basicAuthMiddleware } from './basicAuth'
export { bearerAuthMiddleware } from './bearerAuth'
+40
View File
@@ -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,
}
}
}
+38
View File
@@ -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
+41
View File
@@ -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(),
}),
}
+27
View File
@@ -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]
}
}
+76
View File
@@ -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)
}
}
+52
View File
@@ -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
}
}
+8
View File
@@ -0,0 +1,8 @@
import Query from './query'
export default abstract class QueryService {
static async getUsers() {
const allUsers = await Query.listUser()
return allUsers
}
}
+87
View File
@@ -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
+34
View File
@@ -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
+8
View File
@@ -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(','),
})
+139
View File
@@ -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,
}
}
+8
View File
@@ -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')
+14
View File
@@ -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'
+7
View File
@@ -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'
+34
View File
@@ -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
+27
View File
@@ -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
+6
View File
@@ -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
+53
View File
@@ -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,
})
+244
View File
@@ -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')
})
})
})
+198
View File
@@ -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)
})
})
})
+220
View File
@@ -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
View File
@@ -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. */
}
}