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
+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