diff --git a/server/src/app.ts b/server/src/app.ts index 7adfd62..182cf78 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -6,7 +6,7 @@ import { redisConfig, serverConfig } from './config' import { errorHandler, sessionStore } from './lib/express' import logger from './lib/log' import pdfRouter from './pdf-processing/handler' -import userRouter from './users/handler' +import userRouter from './features/users/router' const app = express() diff --git a/server/src/entities/user.ts b/server/src/entities/user.ts new file mode 100644 index 0000000..5c8d1f9 --- /dev/null +++ b/server/src/entities/user.ts @@ -0,0 +1,14 @@ +import * as z from 'zod' + +const userSchema = z.object({ + id: z.string().uuid(), + firstName: z.string(), + lastName: z.string(), + email: z.string().email(), + password: z.string().min(5), + pdfs: z.array(z.object({ id: z.string(), fileName: z.string() })).min(1) +}) + +type User = z.infer + +export { User } diff --git a/server/src/features/users/create/createUser.ts b/server/src/features/users/create/createUser.ts new file mode 100644 index 0000000..89b1f24 --- /dev/null +++ b/server/src/features/users/create/createUser.ts @@ -0,0 +1,38 @@ +import { Request, Response } from 'express' + +import * as z from 'zod' +import { createError } from '../../../lib/error' +import { createUser, userExists } from './data' + +const userRequest = z.object({ + firstName: z.string(), + lastName: z.string(), + email: z.string().email(), + password: z.string().min(5) +}) + +const errors = { + validationError: { + name: 'ValidationError', + code: 400, + msg: 'Invalid input', + retryable: false + }, + emailConflictError: { + name: 'EmailConflictError', + code: 409, + msg: 'Email already exists', + retryable: false + } +} + +const create = async (req: Request, res: Response) => { + const request = await userRequest.parseAsync(req.body).catch((err) => createError(errors.validationError, err)) + + if (await userExists(request.email)) createError(errors.emailConflictError) + + await createUser(request) + res.json({ result: 'ok' }) +} + +export { create } diff --git a/server/src/features/users/create/data.ts b/server/src/features/users/create/data.ts new file mode 100644 index 0000000..3bf2190 --- /dev/null +++ b/server/src/features/users/create/data.ts @@ -0,0 +1,48 @@ +import bcrypt from 'bcrypt' +import * as uuid from 'uuid' +import { User } from '../../../entities/user' +import r, { idx, key, userKey } from '../../../lib/redis' + +const handleCreateIdxError = (err: Error) => { + if (err.message.toLowerCase().includes('index already exists')) return + + throw err +} + +const userExists = async (email: string) => r.sismember(key('emails'), email) + +const createIdx = async (userId: string) => { + await r + .send_command( + 'FT.CREATE', + idx(userId), + 'ON', + 'HASH', + 'PREFIX', + '1', + key(`pdfs:${userId}`), + 'SCHEMA', + 'content', + 'TEXT', + 'PHONETIC', + 'dm:en' + ) + .catch(handleCreateIdxError) +} + +const createUser = async (userReq: Partial) => { + const saltRounds = 8 + const password = await bcrypt.hash(userReq.password, saltRounds) + + const newUser = { ...userReq, id: uuid.v4(), password } as User + + await createIdx(newUser.id) + await r + .multi([ + ['call', 'JSON.SET', userKey(newUser.id), '.', JSON.stringify(newUser)], + ['sadd', key('emails'), newUser.email], + ['hmset', idx('email'), newUser.email, newUser.id] + ]) + .exec() +} +export { createUser, userExists } diff --git a/server/src/features/users/get/data.ts b/server/src/features/users/get/data.ts new file mode 100644 index 0000000..05ddeed --- /dev/null +++ b/server/src/features/users/get/data.ts @@ -0,0 +1,11 @@ +import r, { userKey } from '../../../lib/redis' +import { User } from '../../../entities/user' + +const getUser = async (userId: string) => { + const resp = (await r.send_command('JSON.GET', userKey(userId))) as User + const { password, ...payload } = resp + + return payload +} + +export { getUser } diff --git a/server/src/features/users/get/getUser.ts b/server/src/features/users/get/getUser.ts new file mode 100644 index 0000000..11d792a --- /dev/null +++ b/server/src/features/users/get/getUser.ts @@ -0,0 +1,30 @@ +import { Request, Response } from 'express' +import { getUser } from './data' +import * as z from 'zod' +import { createError } from '../../../lib/error' + +const response = z.object({ + firstName: z.string(), + lastName: z.string(), + email: z.string().email(), + pdfs: z.array(z.object({ id: z.string(), fileName: z.string() })).min(1) +}) + +const errors = { + validationError: { + name: 'InteralServerError', + code: 500, + msg: 'Invalid data', + retryable: false + } +} + +const getCurrentUser = async (req: Request, res: Response) => { + const user = await getUser(req.session.userId) + + const result = await response.parseAsync(user).catch((err) => createError(errors.validationError, err)) + + res.json({ result }) +} + +export { getCurrentUser } diff --git a/server/src/features/users/list/.gitkeep b/server/src/features/users/list/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/server/src/features/users/router.ts b/server/src/features/users/router.ts new file mode 100644 index 0000000..0c3da02 --- /dev/null +++ b/server/src/features/users/router.ts @@ -0,0 +1,12 @@ +import { create } from './create/createUser' +import express, { Router } from 'express' + +import { auth, safeRouteHandler } from '../../lib/express' +import { getCurrentUser } from './get/getUser' + +const router = Router() + +router.post('/users', express.json(), safeRouteHandler(create)) +router.get('/me', auth, safeRouteHandler(getCurrentUser)) + +export default router diff --git a/server/src/pdf-processing/store.ts b/server/src/pdf-processing/store.ts index 048ef94..1a6de84 100644 --- a/server/src/pdf-processing/store.ts +++ b/server/src/pdf-processing/store.ts @@ -16,12 +16,7 @@ const getParagraphs = (sentences: string[]) => { const words = sentence.trim().split(' ') if (words.length + paragraph.length > MAX_PARAGRAPH_SIZE) { - paragraphs.push( - paragraph - .join(' ') - .slice(0) - .trim() - ) + paragraphs.push(paragraph.join(' ').slice(0).trim()) console.log(paragraph.length) paragraph = [] } diff --git a/server/src/users/errors.ts b/server/src/users/errors.ts deleted file mode 100644 index 9dc86f3..0000000 --- a/server/src/users/errors.ts +++ /dev/null @@ -1,20 +0,0 @@ -export const errors = { - validationError: { - name: 'ValidationError', - code: 400, - msg: 'Invalid input', - retryable: false - }, - emailConflictError: { - name: 'EmailConflictError', - code: 409, - msg: 'Email already exists', - retryable: false - }, - pdfIndexing: { - name: 'PDFError', - code: 500, - msg: 'Could not index pdf', - retryable: true - } -} diff --git a/server/src/users/handler.ts b/server/src/users/handler.ts deleted file mode 100644 index 1352cf4..0000000 --- a/server/src/users/handler.ts +++ /dev/null @@ -1,52 +0,0 @@ -import express, { Request, Response, Router } from 'express' - -import { createError } from '../lib/error' -import { auth, safeRouteHandler } from '../lib/express' -import log from '../lib/log' -import { errors } from './errors' -import * as s from './service' -import { loginSchema, userSchema } from './types' - -const router = Router() - -const signup = async (req: Request, res: Response) => { - const user = await userSchema.parseAsync(req.body).catch((err) => createError(errors.validationError, err)) - - await s.createUser(user) - - res.json({ result: 'ok' }) -} - -const login = async (req: Request, res: Response) => { - const loginInput = await loginSchema.parseAsync(req.body).catch((err) => createError(errors.validationError, err)) - - const userId = await s.checkUser(loginInput.email, loginInput.password) - - req.session.userId = userId - res.json({ result: 'ok' }) -} - -const me = async (req: Request, res: Response) => { - const result = await s.getUser(req.session.userId) - - res.json({ result }) -} - -const logout = (req: Request, res: Response) => { - const { userId } = req.session - - if (userId) - req.session.destroy((err) => { - log.warn(err) - log.warn(`Could not logout user ${userId}`) - }) - - res.json({ result: 'ok' }) -} - -router.post('/users', express.json(), safeRouteHandler(signup)) -router.post('/login', express.json(), safeRouteHandler(login)) -router.get('/me', auth, safeRouteHandler(me)) -router.get('/logout', logout) - -export default router diff --git a/server/src/users/service.ts b/server/src/users/service.ts deleted file mode 100644 index 9712cc3..0000000 --- a/server/src/users/service.ts +++ /dev/null @@ -1,73 +0,0 @@ -import bcrypt from 'bcrypt' -import * as uuid from 'uuid' - -import { createError } from '../lib/error' -import r, { idx, key, userKey } from '../lib/redis' -import { errors } from './errors' -import { User } from './types' - -const handleError = (err: Error) => { - if (err.message.toLowerCase().includes('index already exists')) return - - createError(errors.pdfIndexing, err) -} - -const createIdx = async (userId: string) => { - await r - .send_command( - 'FT.CREATE', - idx(userId), - 'ON', - 'HASH', - 'PREFIX', - '1', - key(`pdfs:${userId}`), - 'SCHEMA', - 'content', - 'TEXT', - 'PHONETIC', - 'dm:en' - ) - .catch(handleError) -} - -const createUser = async (newUser: User) => { - if (await r.sismember(key('emails'), newUser.email)) createError(errors.emailConflictError) - - const password = newUser.password - const saltRounds = 8 - const userId = uuid.v4() - - newUser.password = await bcrypt.hash(password, saltRounds) - newUser.pdfs = [{ id: '', fileName: '' }] - - await createIdx(userId) - await r - .multi([ - ['call', 'JSON.SET', userKey(userId), '.', JSON.stringify(newUser)], - ['sadd', key('emails'), newUser.email], - ['hmset', idx('email'), newUser.email, userId] - ]) - .exec() -} - -const checkUser = async (email: string, password: string): Promise => { - const userId = await r.hget(idx('email'), email) - - if (!userId) return createError(errors.validationError) - - const userPassword = await r.send_command('JSON.GET', userKey(userId), '.password') - - if (!(await bcrypt.compare(password, userPassword))) createError(errors.validationError) - - return userId -} - -const getUser = async (userId: string) => { - const resp = (await r.send_command('JSON.GET', userKey(userId))) as User - const { password, ...payload } = resp - - return payload -} - -export { checkUser, createIdx, createUser, getUser } diff --git a/server/src/users/types.ts b/server/src/users/types.ts deleted file mode 100644 index b87304e..0000000 --- a/server/src/users/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -import * as z from 'zod' - -const userSchema = z.object({ - firstName: z.string(), - lastName: z.string(), - email: z.string().email(), - password: z.string().min(5), - pdfs: z.array(z.object({ id: z.string(), fileName: z.string() })).optional() -}) - -const loginSchema = z.object({ - email: z.string().email(), - password: z.string().min(5) -}) - -declare module 'express-session' { - interface Session { - userId: string - destroy(callback?: (err: Error) => void): this - } -} - -type User = z.infer - -export { loginSchema, User, userSchema }