feat: integration with API Vega Cloud
This commit is contained in:
@@ -1,13 +1,9 @@
|
||||
import { Elysia } from 'elysia'
|
||||
// import authRouter from '~/modules/auth/router'
|
||||
// import userRouter from '~/modules/user/router'
|
||||
import deviceRouter from '~/modules/device/router'
|
||||
|
||||
export const router = new Elysia({
|
||||
name: 'codebase.router',
|
||||
})
|
||||
// .use(authRouter)
|
||||
// .use(userRouter)
|
||||
.use(deviceRouter)
|
||||
.get('/', () => ({
|
||||
message: 'This service is running as expected.',
|
||||
|
||||
+6
-9
@@ -61,12 +61,9 @@ export const redis = {
|
||||
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 || ''
|
||||
export const httpConfig = {
|
||||
baseUrl: process.env.HTTP_BASE_URL || 'default-url',
|
||||
username: process.env.HTTP_USERNAME,
|
||||
password: process.env.HTTP_PASSWORD,
|
||||
timeout: Number(process.env.HTTP_TIMEOUT) || 3000,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'
|
||||
import { httpConfig } from '~/config'
|
||||
import logger from '~/plugins/logger'
|
||||
|
||||
interface AxiosConfigWithMetadata extends AxiosRequestConfig {
|
||||
metadata?: {
|
||||
startTime: number
|
||||
}
|
||||
}
|
||||
|
||||
interface HttpRequestOptions {
|
||||
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
||||
url: string
|
||||
body?: Record<string, unknown>
|
||||
config?: AxiosRequestConfig
|
||||
}
|
||||
|
||||
interface HttpResponse<T = unknown> {
|
||||
data: T
|
||||
status: number
|
||||
statusText: string
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
class HttpError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public response?: AxiosResponse,
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'HttpError'
|
||||
}
|
||||
}
|
||||
|
||||
let axiosInstance: AxiosInstance | null = null
|
||||
|
||||
function getAxiosInstance(): AxiosInstance {
|
||||
if (!axiosInstance) {
|
||||
axiosInstance = axios.create({
|
||||
baseURL: httpConfig.baseUrl,
|
||||
timeout: 30000, // 30 seconds default timeout
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
// Add basic auth if configured
|
||||
if (httpConfig.username && httpConfig.password) {
|
||||
const auth = Buffer.from(`${httpConfig.username}:${httpConfig.password}`).toString('base64')
|
||||
axiosInstance.defaults.headers.common['Authorization'] = `Basic ${auth}`
|
||||
}
|
||||
|
||||
// Request interceptor for logging
|
||||
axiosInstance.interceptors.request.use(
|
||||
(config) => {
|
||||
;(config as AxiosConfigWithMetadata).metadata = { startTime: Date.now() }
|
||||
logger.info({
|
||||
msg: 'HTTP Request',
|
||||
method: config.method?.toUpperCase(),
|
||||
url: config.url,
|
||||
baseURL: config.baseURL,
|
||||
headers: config.headers,
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
})
|
||||
return config
|
||||
},
|
||||
(error) => {
|
||||
logger.error({
|
||||
msg: 'HTTP Request Error',
|
||||
error: error.message,
|
||||
config: {
|
||||
method: error.config?.method,
|
||||
url: error.config?.url,
|
||||
baseURL: error.config?.baseURL,
|
||||
},
|
||||
})
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
// Response interceptor for logging
|
||||
axiosInstance.interceptors.response.use(
|
||||
(response) => {
|
||||
const startTime = (response.config as AxiosConfigWithMetadata).metadata?.startTime
|
||||
logger.info({
|
||||
msg: 'HTTP Response',
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
method: response.config.method?.toUpperCase(),
|
||||
url: response.config.url,
|
||||
baseURL: response.config.baseURL,
|
||||
responseTime: startTime ? Date.now() - startTime : undefined,
|
||||
})
|
||||
return response
|
||||
},
|
||||
(error) => {
|
||||
const startTime = (error.config as AxiosConfigWithMetadata)?.metadata?.startTime
|
||||
logger.error({
|
||||
msg: 'HTTP Response Error',
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
method: error.config?.method?.toUpperCase(),
|
||||
url: error.config?.url,
|
||||
baseURL: error.config?.baseURL,
|
||||
error: error.message,
|
||||
responseData: error.response?.data,
|
||||
responseTime: startTime ? Date.now() - startTime : undefined,
|
||||
})
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
return axiosInstance
|
||||
}
|
||||
|
||||
async function httpRequest<T = unknown>(options: HttpRequestOptions): Promise<HttpResponse<T>> {
|
||||
const { method, url, body, config = {} } = options
|
||||
const instance = getAxiosInstance()
|
||||
|
||||
const axiosConfig: AxiosRequestConfig = {
|
||||
method,
|
||||
url,
|
||||
...config,
|
||||
}
|
||||
|
||||
if (body && ['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
axiosConfig.data = body
|
||||
}
|
||||
|
||||
if (method === 'GET' && body) {
|
||||
axiosConfig.params = body
|
||||
}
|
||||
|
||||
const maxRetries = 3
|
||||
let lastError: Error | null = null
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const response: AxiosResponse<T> = await instance.request(axiosConfig)
|
||||
return {
|
||||
data: response.data,
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers as Record<string, string>,
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error as Error
|
||||
|
||||
// Check if it's a transient error worth retrying
|
||||
const isRetryable = axios.isAxiosError(error)
|
||||
&& (
|
||||
!error.response // Network errors
|
||||
|| error.response.status >= 500 // Server errors
|
||||
|| error.response.status === 429 // Rate limiting
|
||||
)
|
||||
|
||||
if (!isRetryable || attempt === maxRetries) {
|
||||
break
|
||||
}
|
||||
|
||||
// Exponential backoff
|
||||
const delay = Math.pow(2, attempt) * 1000
|
||||
logger.warn({
|
||||
msg: 'HTTP Retry',
|
||||
attempt,
|
||||
maxRetries,
|
||||
delay,
|
||||
method,
|
||||
url,
|
||||
error: lastError.message,
|
||||
})
|
||||
await new Promise((resolve) => setTimeout(resolve, delay))
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, all retries failed
|
||||
if (lastError && axios.isAxiosError(lastError)) {
|
||||
const status = lastError.response?.status || 0
|
||||
const message = lastError.response?.data?.message || lastError.message
|
||||
throw new HttpError(`HTTP ${method} ${url} failed: ${message}`, status, lastError.response)
|
||||
}
|
||||
|
||||
throw new HttpError(`HTTP ${method} ${url} failed: ${lastError?.message || 'Unknown error'}`, 0)
|
||||
}
|
||||
|
||||
export { httpRequest, HttpError }
|
||||
export type { HttpRequestOptions, HttpResponse }
|
||||
@@ -132,13 +132,9 @@ console.log(sign({
|
||||
deviceType: 'A',
|
||||
commandType: 'C',
|
||||
towerNumber: 'T1',
|
||||
timestamp: '2025-10-01T14:30:12Z',
|
||||
nonce: '9f2a6e7c8d3b',
|
||||
payload: {
|
||||
AC: 'On',
|
||||
Temp: 25,
|
||||
},
|
||||
}, 'savy_123123'))
|
||||
timestamp: '2025-10-14T04:06:52.022Z',
|
||||
nonce: '50ce476cd1c355ae552ae18be55af72c',
|
||||
}, '458fd644689a456efa87d38f788511d9'))
|
||||
|
||||
/**
|
||||
* Verifies the signature of the data using HMAC-SHA256 with constant-time comparison.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Clock skew window: ±5 minutes
|
||||
const CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||
// const CLOCK_SKEW_MS = 5 * 60 * 1000
|
||||
|
||||
export const deviceGuard = () => ({
|
||||
beforeHandle(context: unknown) {
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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
|
||||
@@ -1,41 +0,0 @@
|
||||
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,19 @@
|
||||
import { httpRequest } from '~/helpers/http/axios'
|
||||
import logger from '~/plugins/logger'
|
||||
|
||||
export async function sendCommandToThirdParty(topicData: string, payload: string): Promise<void> {
|
||||
try {
|
||||
const response = await httpRequest({
|
||||
method: 'POST',
|
||||
url: '/command',
|
||||
body: {
|
||||
topic: topicData,
|
||||
payload,
|
||||
},
|
||||
})
|
||||
logger.info({ msg: 'Third-party command sent successfully', topic: topicData, status: response.status })
|
||||
} catch (error) {
|
||||
logger.error({ msg: 'Failed to send command to third-party service', topic: topicData, error: (error as Error).message })
|
||||
throw new Error('third_party_command_failed')
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { verify } from '~/helpers/signature'
|
||||
import { DeviceCommandBody } from '../schema'
|
||||
import { sendCommandToThirdParty } from './command'
|
||||
|
||||
export default abstract class CommandService {
|
||||
static async sendCommand(body: DeviceCommandBody, headers: Record<string, string | undefined>): Promise<{
|
||||
@@ -22,10 +23,20 @@ export default abstract class CommandService {
|
||||
|
||||
// TODO: Check nonce for replay attack when DB is implemented
|
||||
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'Command accepted',
|
||||
const result = {
|
||||
status: 'success' as const,
|
||||
message: 'Command accepted' as const,
|
||||
receivedAt: new Date().toISOString(),
|
||||
}
|
||||
|
||||
// Generate topicData and send to third-party service
|
||||
try {
|
||||
const topicData = `${body.merchantName}/${body.floorName}_${body.unitNumber}-${body.deviceName}-${body.roomName}-${body.deviceType}-${body.commandType}-${body.towerNumber}`
|
||||
await sendCommandToThirdParty(topicData, body.payload.AC)
|
||||
} catch {
|
||||
throw new Error('internal_error')
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { httpRequest } from '~/helpers/http/axios'
|
||||
import logger from '~/plugins/logger'
|
||||
|
||||
export async function queryDeviceStatus(topic: string): Promise<{
|
||||
floor: string
|
||||
unit: string
|
||||
device: string
|
||||
room: string
|
||||
devType: string
|
||||
code: string
|
||||
tower: string
|
||||
payload: string
|
||||
} | null> {
|
||||
try {
|
||||
const response = await httpRequest({
|
||||
method: 'GET',
|
||||
url: '/state-reply',
|
||||
body: { topic },
|
||||
})
|
||||
|
||||
if ((response.data as Record<string, unknown>) && (response.data as Record<string, unknown>).data) {
|
||||
const thirdPartyData = (response.data as Record<string, unknown>).data as Record<string, unknown>
|
||||
logger.info({ msg: 'Device status queried successfully', topic, status: response.status })
|
||||
|
||||
return {
|
||||
floor: thirdPartyData.floor as string,
|
||||
unit: thirdPartyData.unit as string,
|
||||
device: thirdPartyData.device as string,
|
||||
room: thirdPartyData.room as string,
|
||||
devType: thirdPartyData.devType as string,
|
||||
code: thirdPartyData.code as string,
|
||||
tower: thirdPartyData.tower as string,
|
||||
payload: thirdPartyData.payload as string,
|
||||
}
|
||||
} else {
|
||||
logger.info({ msg: 'Device status not found', topic, status: response.status })
|
||||
return null
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error({ msg: 'Failed to query device status', topic, error: (error as Error).message })
|
||||
throw new Error('third_party_query_failed')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { verify } from '~/helpers/signature'
|
||||
import { DeviceQueryParams } from '../schema'
|
||||
import { queryDeviceStatus } from './query'
|
||||
|
||||
export default abstract class QueryService {
|
||||
static async getDeviceStatus(query: DeviceQueryParams, headers: Record<string, string | undefined>): Promise<{
|
||||
status: 'success'
|
||||
message: 'command accepted'
|
||||
data: {
|
||||
floor: string
|
||||
unit: string
|
||||
device: string
|
||||
room: string
|
||||
devType: string
|
||||
code: string
|
||||
tower: string
|
||||
payload: string
|
||||
}
|
||||
} | null> {
|
||||
const apiKey = headers['x-api-key']
|
||||
|
||||
// For now, use apiKey as secretKey since DB validation is skipped
|
||||
const secretKey = apiKey
|
||||
|
||||
// Remove signature from query for verification
|
||||
const { signature, ...queryWithoutSignature } = query
|
||||
|
||||
const isValid = verify(queryWithoutSignature, signature, secretKey)
|
||||
if (!isValid) {
|
||||
throw new Error('invalid_signature')
|
||||
}
|
||||
|
||||
// TODO: Check nonce for replay attack when DB is implemented
|
||||
|
||||
const topic = `${query.merchantName}/${query.floorName}_${query.unitNumber}-${query.deviceName}-${query.roomName}-${query.deviceType}-${query.commandType}-${query.towerNumber}`
|
||||
|
||||
const deviceData = await queryDeviceStatus(topic)
|
||||
|
||||
if (deviceData) {
|
||||
return {
|
||||
status: 'success',
|
||||
message: 'command accepted',
|
||||
data: deviceData,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Elysia } from 'elysia'
|
||||
import { deviceGuard } from '~/middlewares/deviceGuard'
|
||||
import { deviceCommandBody, deviceCommandResponseSchema } from './schema'
|
||||
import { deviceCommandBody, deviceCommandResponseSchema, deviceQueryParams, deviceQueryResponseSchema } from './schema'
|
||||
import CommandService from './commands/service'
|
||||
import QueryService from './queries/service'
|
||||
import { deviceErrorResponseMap } from '~/helpers/errors'
|
||||
|
||||
export const router = new Elysia({
|
||||
@@ -35,5 +36,35 @@ export const router = new Elysia({
|
||||
],
|
||||
},
|
||||
})
|
||||
.get('/status', async ({ query, headers, set }) => {
|
||||
try {
|
||||
const result = await QueryService.getDeviceStatus(query, headers)
|
||||
if (result) {
|
||||
return result
|
||||
} else {
|
||||
set.status = 404
|
||||
return { status: 'not_found', message: 'Device status not found' }
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message in deviceErrorResponseMap) {
|
||||
const { status, response } = deviceErrorResponseMap[error.message]
|
||||
set.status = status
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return response as any
|
||||
}
|
||||
set.status = 500
|
||||
return { status: 'internal_error', message: 'Server error' }
|
||||
}
|
||||
}, {
|
||||
query: deviceQueryParams,
|
||||
response: deviceQueryResponseSchema,
|
||||
detail: {
|
||||
security: [
|
||||
{ apiKey: [] },
|
||||
{ timestamp: [] },
|
||||
{ signature: [] },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
export default router
|
||||
|
||||
@@ -20,6 +20,22 @@ export const deviceCommandBody = z.object({
|
||||
})
|
||||
export type DeviceCommandBody = z.infer<typeof deviceCommandBody>
|
||||
|
||||
// Request query schema for GET endpoint
|
||||
export const deviceQueryParams = z.object({
|
||||
merchantName: z.string(),
|
||||
floorName: z.string(),
|
||||
unitNumber: z.string(),
|
||||
deviceName: z.string(),
|
||||
roomName: z.string(),
|
||||
deviceType: z.string(),
|
||||
commandType: z.string(),
|
||||
towerNumber: z.string(),
|
||||
timestamp: z.string().datetime(), // ISO8601
|
||||
nonce: z.string(),
|
||||
signature: z.string(),
|
||||
})
|
||||
export type DeviceQueryParams = z.infer<typeof deviceQueryParams>
|
||||
|
||||
// Response schemas
|
||||
export const deviceCommandResponseSchema = {
|
||||
200: z.object({
|
||||
@@ -45,6 +61,50 @@ export const deviceCommandResponseSchema = {
|
||||
status: z.literal('replay_attack_detected'),
|
||||
message: z.literal('Timestamp or nonce is invalid/has been reused'),
|
||||
}),
|
||||
404: z.object({
|
||||
status: z.literal('not_found'),
|
||||
message: z.literal('Device status not found'),
|
||||
}),
|
||||
500: z.object({
|
||||
status: z.literal('internal_error'),
|
||||
message: z.literal('Server error'),
|
||||
}),
|
||||
}
|
||||
|
||||
// Query response schema
|
||||
export const deviceQueryResponseSchema = {
|
||||
200: z.object({
|
||||
status: z.literal('success'),
|
||||
message: z.literal('command accepted'),
|
||||
data: z.object({
|
||||
floor: z.string(),
|
||||
unit: z.string(),
|
||||
device: z.string(),
|
||||
room: z.string(),
|
||||
devType: z.string(),
|
||||
code: z.string(),
|
||||
tower: z.string(),
|
||||
payload: z.string(),
|
||||
}),
|
||||
}),
|
||||
400: z.object({
|
||||
status: z.literal('invalid_request'),
|
||||
message: z.literal('Invalid request format'),
|
||||
}),
|
||||
401: z.union([
|
||||
z.object({
|
||||
status: z.literal('invalid_signature'),
|
||||
message: z.literal('Signature mismatch'),
|
||||
}),
|
||||
z.object({
|
||||
status: z.literal('invalid_api_key'),
|
||||
message: z.literal('API Key is not valid'),
|
||||
}),
|
||||
]),
|
||||
403: z.object({
|
||||
status: z.literal('replay_attack_detected'),
|
||||
message: z.literal('Timestamp or nonce is invalid/has been reused'),
|
||||
}),
|
||||
500: z.object({
|
||||
status: z.literal('internal_error'),
|
||||
message: z.literal('Server error'),
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
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]
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import Query from './query'
|
||||
|
||||
export default abstract class QueryService {
|
||||
static async getUsers() {
|
||||
const allUsers = await Query.listUser()
|
||||
return allUsers
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
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
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
Reference in New Issue
Block a user