first commit 🎉
This commit is contained in:
@@ -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,
|
||||
})
|
||||
Reference in New Issue
Block a user