From ec37fc03169cb4ee2da73d2a8645511019fe8399 Mon Sep 17 00:00:00 2001 From: Mark Zheleznyakov Date: Wed, 19 Nov 2025 10:43:05 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=9A=D0=BE=D0=BD=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BB=D0=BB=D0=B5=D1=80=20=D0=B8=20=D1=81=D0=B5=D1=80=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=20=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=82=D0=B5=D0=BB=D0=B5=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../{src => }/drizzle/0000_organic_namora.sql | 0 .../{src => }/drizzle/meta/0000_snapshot.json | 0 .../{src => }/drizzle/meta/_journal.json | 0 .../src/controllers/users.controller.ts | 83 +++++++++++++++++ apps/backend/src/index.ts | 6 +- apps/backend/src/services/redis.service.ts | 90 +++++++++++++++++++ apps/backend/src/services/users.service.ts | 60 +++++++++++++ 7 files changed, 237 insertions(+), 2 deletions(-) rename apps/backend/{src => }/drizzle/0000_organic_namora.sql (100%) rename apps/backend/{src => }/drizzle/meta/0000_snapshot.json (100%) rename apps/backend/{src => }/drizzle/meta/_journal.json (100%) create mode 100644 apps/backend/src/controllers/users.controller.ts create mode 100644 apps/backend/src/services/redis.service.ts create mode 100644 apps/backend/src/services/users.service.ts diff --git a/apps/backend/src/drizzle/0000_organic_namora.sql b/apps/backend/drizzle/0000_organic_namora.sql similarity index 100% rename from apps/backend/src/drizzle/0000_organic_namora.sql rename to apps/backend/drizzle/0000_organic_namora.sql diff --git a/apps/backend/src/drizzle/meta/0000_snapshot.json b/apps/backend/drizzle/meta/0000_snapshot.json similarity index 100% rename from apps/backend/src/drizzle/meta/0000_snapshot.json rename to apps/backend/drizzle/meta/0000_snapshot.json diff --git a/apps/backend/src/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json similarity index 100% rename from apps/backend/src/drizzle/meta/_journal.json rename to apps/backend/drizzle/meta/_journal.json diff --git a/apps/backend/src/controllers/users.controller.ts b/apps/backend/src/controllers/users.controller.ts new file mode 100644 index 0000000..fe0948f --- /dev/null +++ b/apps/backend/src/controllers/users.controller.ts @@ -0,0 +1,83 @@ +import { Elysia, t } from 'elysia'; +import { usersService } from '@/services/users.service'; + +export const usersController = new Elysia({ prefix: '/users' }) + .get( + '/by-name', + async ({ query, set }) => { + try { + const name = query.name?.trim(); + + const foundUser = await usersService.getByName(name); + + if (!foundUser) { + set.status = 404; + return { error: 'User not found' }; + } + + return { + id: foundUser.id, + name: foundUser.name, + email: foundUser.email, + image: foundUser.image, + emailVerified: foundUser.emailVerified, + createdAt: foundUser.createdAt.toISOString(), + updatedAt: foundUser.updatedAt.toISOString(), + }; + } catch (error) { + set.status = 500; + return { + error: error instanceof Error ? error.message : 'Failed to get user', + }; + } + }, + { + query: t.Object({ + name: t.String({ minLength: 1 }), + }), + detail: { + tags: ['Users'], + summary: 'Get user by name', + description: 'Returns user information by name', + }, + } + ) + .get( + '/:id', + async ({ params: { id }, set }) => { + try { + const foundUser = await usersService.getById(id); + + if (!foundUser) { + set.status = 404; + return { error: 'User not found' }; + } + + return { + id: foundUser.id, + name: foundUser.name, + email: foundUser.email, + image: foundUser.image, + emailVerified: foundUser.emailVerified, + createdAt: foundUser.createdAt.toISOString(), + updatedAt: foundUser.updatedAt.toISOString(), + }; + } catch (error) { + set.status = 500; + return { + error: error instanceof Error ? error.message : 'Failed to get user', + }; + } + }, + { + params: t.Object({ + id: t.String(), + }), + detail: { + tags: ['Users'], + summary: 'Get user by ID', + description: 'Returns user information by ID', + }, + } + ); + diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 9a363e8..caa45f8 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -4,6 +4,7 @@ import { auth } from "@/lib/auth"; import { env } from "./config/env"; import { AuthOpenAPI } from "./lib/auth/openapi"; import { purple } from "./lib/term/color"; +import { usersController } from "./controllers/users.controller"; const app = new Elysia() .use(openapi({ @@ -13,6 +14,7 @@ const app = new Elysia() } })) .mount('/auth', auth.handler) + .use(usersController) .listen(env.PORT); const hostname = app.server?.hostname @@ -27,6 +29,6 @@ console.log(purple` /_/ `) -console.log(` ${purple`started server`} @ ${hostname}:${port}`); -console.log(` ${purple`visit scalar`} @ ${hostname}:${port}/openapi`) +console.log(` ${purple`started server`} @ http://${hostname}:${port}`); +console.log(` ${purple`visit scalar`} @ http://${hostname}:${port}/openapi`) diff --git a/apps/backend/src/services/redis.service.ts b/apps/backend/src/services/redis.service.ts new file mode 100644 index 0000000..b1082eb --- /dev/null +++ b/apps/backend/src/services/redis.service.ts @@ -0,0 +1,90 @@ +import { env } from '@/config/env'; +import { RedisClient } from 'bun'; + +class RedisService { + private client: any = null; + private isConnected = false; + + async connect() { + if (this.isConnected) return; + + try { + this.client = new RedisClient(env.REDIS_URL); + this.isConnected = true; + console.log('Redis connected'); + } catch (error) { + console.error('Failed to connect to Redis:', error); + if (env.NODE_ENV === 'test') { + console.warn('Running without Redis in test mode'); + } else { + throw error; + } + } + } + + async get(key: string): Promise { + if (!this.isConnected) return null; + try { + const value = await this.client.get(key); + return value ? JSON.parse(value) : null; + } catch (error) { + console.error(`Redis GET error for key ${key}:`, error); + return null; + } + } + + async set(key: string, value: any, ttl?: number): Promise { + if (!this.isConnected) return; + try { + const serialized = JSON.stringify(value); + if (ttl) { + await this.client.setex(key, ttl, serialized); + } else { + await this.client.set(key, serialized); + } + } catch (error) { + console.error(`Redis SET error for key ${key}:`, error); + } + } + + async del(key: string): Promise { + if (!this.isConnected) return; + try { + await this.client.del(key); + } catch (error) { + console.error(`Redis DEL error for key ${key}:`, error); + } + } + + async delPattern(pattern: string): Promise { + if (!this.isConnected) return; + try { + const keys = await this.client.keys(pattern); + if (keys.length > 0) { + await this.client.del(...keys); + } + } catch (error) { + console.error(`Redis DEL pattern error for ${pattern}:`, error); + } + } + + async disconnect() { + if (this.isConnected && this.client) { + try { + if (typeof this.client.quit === 'function') { + await this.client.quit(); + } else if (typeof this.client.close === 'function') { + await this.client.close(); + } + } catch (error) { + console.warn('Error disconnecting Redis:', error); + } finally { + this.isConnected = false; + this.client = null; + } + } + } +} + +export const redis = new RedisService(); + diff --git a/apps/backend/src/services/users.service.ts b/apps/backend/src/services/users.service.ts new file mode 100644 index 0000000..f707174 --- /dev/null +++ b/apps/backend/src/services/users.service.ts @@ -0,0 +1,60 @@ +import { eq, ilike } from 'drizzle-orm'; +import { db, user } from '@/db'; +import { redis } from './redis.service'; + +class UsersService { + private readonly CACHE_TTL = 3600; + + async getById(userId: string): Promise { + const cacheKey = `user:${userId}`; + const cached = await redis.get(cacheKey); + if (cached) return cached; + + const [foundUser] = await db + .select() + .from(user) + .where(eq(user.id, userId)) + .limit(1); + + if (foundUser) { + await redis.set(cacheKey, foundUser, this.CACHE_TTL); + } + + return foundUser || null; + } + + async getByName(name: string): Promise { + if (!name || name.trim().length === 0) { + return null; + } + + const searchName = name.trim(); + const cacheKey = `user_by_name:${searchName}`; + + const cached = await redis.get(cacheKey); + if (cached) return cached; + + let [foundUser] = await db + .select() + .from(user) + .where(eq(user.name, searchName)) + .limit(1); + + if (!foundUser) { + [foundUser] = await db + .select() + .from(user) + .where(ilike(user.name, searchName)) + .limit(1); + } + + if (foundUser) { + await redis.set(cacheKey, foundUser, this.CACHE_TTL); + } + + return foundUser || null; + } +} + +export const usersService = new UsersService(); +