diff --git a/client/src/components/ProtectedRoute.tsx b/client/src/components/ProtectedRoute.tsx index e6bb4a2..be029a5 100644 --- a/client/src/components/ProtectedRoute.tsx +++ b/client/src/components/ProtectedRoute.tsx @@ -14,7 +14,7 @@ function ProtectedRoute({ children }: ProtectedRouteProps) { // Показываем загрузку пока проверяем авторизацию if (isLoading) { return ( -
+
Проверка авторизации...
); @@ -30,4 +30,3 @@ function ProtectedRoute({ children }: ProtectedRouteProps) { } export default ProtectedRoute; - diff --git a/client/src/components/PublicRoute.tsx b/client/src/components/PublicRoute.tsx index 136f9be..8815ed4 100644 --- a/client/src/components/PublicRoute.tsx +++ b/client/src/components/PublicRoute.tsx @@ -15,7 +15,7 @@ function PublicRoute({ children }: PublicRouteProps) { // Показываем загрузку пока проверяем авторизацию if (isLoading) { return ( -
+
Загрузка...
); @@ -31,4 +31,3 @@ function PublicRoute({ children }: PublicRouteProps) { } export default PublicRoute; - diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index 930c3fa..52dd4c8 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -18,4 +18,3 @@ export const api = ky.create({ ], }, }); - diff --git a/client/src/lib/queryClient.ts b/client/src/lib/queryClient.ts index d745668..5cc8f65 100644 --- a/client/src/lib/queryClient.ts +++ b/client/src/lib/queryClient.ts @@ -12,4 +12,3 @@ export const queryClient = new QueryClient({ }, }, }); - diff --git a/client/src/pages/HomePage.tsx b/client/src/pages/HomePage.tsx index 99a5d30..2ab2c3d 100644 --- a/client/src/pages/HomePage.tsx +++ b/client/src/pages/HomePage.tsx @@ -12,24 +12,68 @@ function HomePage() { }; return ( -
-
-
-

Главная страница

+
+
+
+

Главная страница

-
-

+
+

Добро пожаловать, {user?.fullName}!

Email: {user?.email}

Роль: {user?.role}

+ {user?.currentCompany && ( +
+

Компания

+

+ {user.currentCompany.name} +

+ {user.currentCompany.description && ( +

+ {user.currentCompany.description} +

+ )} +
+ )} + + {user?.currentBranch && ( +
+

Филиал

+

+ {user.currentBranch.name} +

+ {user.currentBranch.address && ( +

+ Адрес: {user.currentBranch.address} +

+ )} + {(user.currentBranch.city || user.currentBranch.country) && ( +

+ {[user.currentBranch.city, user.currentBranch.country] + .filter(Boolean) + .join(", ")} +

+ )} +
+ )} + + {!user?.currentBranch && !user?.currentCompany && ( +
+

+ Вы не привязаны ни к одному филиалу. Обратитесь к + администратору. +

+
+ )} + diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 6b12710..9af6fe2 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -22,8 +22,8 @@ function LoginPage() { }; return ( -
-
+
+

Вход в аккаунт @@ -45,7 +45,7 @@ function LoginPage() { required value={email} onChange={(e) => setEmail(e.target.value)} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="block px-3 py-2 mt-1 w-full rounded-md border border-gray-300 shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" placeholder="your@email.com" />

@@ -63,14 +63,14 @@ function LoginPage() { required value={password} onChange={(e) => setPassword(e.target.value)} - className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" + className="block px-3 py-2 mt-1 w-full rounded-md border border-gray-300 shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500" placeholder="••••••••" />
{loginMutation.isError && ( -
+
Неверный email или пароль
)} @@ -78,14 +78,17 @@ function LoginPage() { -
+
Нет аккаунта?{" "} - + Зарегистрироваться
diff --git a/client/src/types/auth.ts b/client/src/types/auth.ts index 33bdd4f..c53887e 100644 --- a/client/src/types/auth.ts +++ b/client/src/types/auth.ts @@ -1,11 +1,27 @@ export type RoleName = "admin" | "director" | "manager"; +export interface Branch { + id: string; + name: string; + address: string | null; + city: string | null; + country: string | null; +} + +export interface Company { + id: string; + name: string; + description: string | null; +} + export interface User { id: string; email: string; fullName: string; role: RoleName; createdAt: string; + currentBranch?: Branch; + currentCompany?: Company; } export interface LoginData { @@ -35,4 +51,3 @@ export interface RegisterResponse { export interface MeResponse { user: User; } - diff --git a/server/COMPANIES_BRANCHES.md b/server/COMPANIES_BRANCHES.md new file mode 100644 index 0000000..ebe9bdd --- /dev/null +++ b/server/COMPANIES_BRANCHES.md @@ -0,0 +1,376 @@ +# Компании и филиалы - Документация + +## Обзор + +Добавлена функциональность для управления компаниями и филиалами с поддержкой привязки пользователей к нескольким филиалам. + +## Структура базы данных + +### Таблицы + +#### `companies` - Компании +- `id` - UUID, первичный ключ +- `name` - Название компании (обязательное) +- `description` - Описание компании +- `createdAt` - Дата создания +- `updatedAt` - Дата обновления + +#### `branches` - Филиалы +- `id` - UUID, первичный ключ +- `companyId` - UUID, внешний ключ на компанию (каскадное удаление) +- `name` - Название филиала (обязательное) +- `address` - Адрес филиала +- `city` - Город +- `country` - Страна +- `createdAt` - Дата создания +- `updatedAt` - Дата обновления + +#### `user_branches` - Связь пользователей и филиалов (Many-to-Many) +- `userId` - UUID, внешний ключ на пользователя (каскадное удаление) +- `branchId` - UUID, внешний ключ на филиал (каскадное удаление) +- `createdAt` - Дата создания +- Составной первичный ключ: `(userId, branchId)` + +### Изменения в таблице `users` +Добавлено поле: +- `currentBranchId` - UUID, внешний ключ на выбранный филиал (nullable) + +## API Эндпоинты + +Все эндпоинты требуют авторизации через Bearer токен. + +### Компании (`/companies`) + +#### `GET /companies` +Получить список всех компаний с их филиалами. + +**Ответ:** +```json +{ + "companies": [ + { + "id": "uuid", + "name": "Компания 1", + "description": "Описание", + "branches": [...], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } + ] +} +``` + +#### `GET /companies/:id` +Получить компанию по ID. + +**Параметры:** +- `id` - UUID компании + +**Ответ:** +```json +{ + "company": { + "id": "uuid", + "name": "Компания 1", + "description": "Описание", + "branches": [...], + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } +} +``` + +#### `POST /companies` +Создать новую компанию. + +**Body:** +```json +{ + "name": "Новая компания", + "description": "Описание (опционально)" +} +``` + +**Ответ:** +```json +{ + "company": { + "id": "uuid", + "name": "Новая компания", + "description": "Описание", + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } +} +``` + +#### `PATCH /companies/:id` +Обновить компанию. + +**Body:** +```json +{ + "name": "Обновленное название (опционально)", + "description": "Обновленное описание (опционально)" +} +``` + +#### `DELETE /companies/:id` +Удалить компанию (каскадно удалит все филиалы). + +**Ответ:** +```json +{ + "message": "Company deleted successfully" +} +``` + +### Филиалы (`/branches`) + +#### `GET /branches/my` +Получить филиалы текущего пользователя. + +**Ответ:** +```json +{ + "branches": [ + { + "id": "uuid", + "companyId": "uuid", + "name": "Филиал 1", + "address": "Адрес", + "city": "Город", + "country": "Страна", + "company": {...}, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } + ] +} +``` + +#### `GET /branches/:id` +Получить филиал по ID. + +**Ответ:** +```json +{ + "branch": { + "id": "uuid", + "companyId": "uuid", + "name": "Филиал 1", + "address": "Адрес", + "city": "Город", + "country": "Страна", + "company": {...}, + "createdAt": "2024-01-01T00:00:00Z", + "updatedAt": "2024-01-01T00:00:00Z" + } +} +``` + +#### `GET /branches/:id/users` +Получить пользователей филиала. + +**Ответ:** +```json +{ + "users": [ + { + "id": "uuid", + "email": "user@example.com", + "fullName": "Иван Иванов", + "role": "manager", + "currentBranchId": "uuid" + } + ] +} +``` + +#### `POST /branches` +Создать новый филиал. + +**Body:** +```json +{ + "companyId": "uuid", + "name": "Новый филиал", + "address": "Адрес (опционально)", + "city": "Город (опционально)", + "country": "Страна (опционально)" +} +``` + +#### `PATCH /branches/:id` +Обновить филиал. + +**Body:** +```json +{ + "name": "Обновленное название (опционально)", + "address": "Обновленный адрес (опционально)", + "city": "Обновленный город (опционально)", + "country": "Обновленная страна (опционально)" +} +``` + +#### `DELETE /branches/:id` +Удалить филиал. + +#### `POST /branches/:id/users` +Привязать пользователя к филиалу. + +**Body:** +```json +{ + "userId": "uuid" +} +``` + +**Ответ:** +```json +{ + "message": "User assigned to branch successfully" +} +``` + +#### `DELETE /branches/:id/users/:userId` +Отвязать пользователя от филиала. + +**Ответ:** +```json +{ + "message": "User removed from branch successfully" +} +``` + +#### `POST /branches/:id/select` +Установить филиал как текущий для авторизованного пользователя. + +**Ответ:** +```json +{ + "message": "Current branch updated successfully", + "currentBranchId": "uuid" +} +``` + +**Ошибки:** +- `400` - Пользователь не привязан к этому филиалу + +## Использование + +### Создание компании и филиалов + +```typescript +// 1. Создать компанию +POST /companies +{ + "name": "ООО Рога и Копыта", + "description": "Крупная IT компания" +} + +// 2. Создать филиалы +POST /branches +{ + "companyId": "company-uuid", + "name": "Московский офис", + "city": "Москва", + "country": "Россия" +} + +POST /branches +{ + "companyId": "company-uuid", + "name": "Питерский офис", + "city": "Санкт-Петербург", + "country": "Россия" +} +``` + +### Привязка пользователя к филиалам + +```typescript +// Привязать пользователя к московскому офису +POST /branches/{moscow-branch-id}/users +{ + "userId": "user-uuid" +} + +// Привязать пользователя к питерскому офису +POST /branches/{spb-branch-id}/users +{ + "userId": "user-uuid" +} +``` + +### Выбор текущего филиала + +```typescript +// Установить московский офис как текущий +POST /branches/{moscow-branch-id}/select + +// Теперь в объекте пользователя currentBranchId будет указывать на московский офис +``` + +### Получение филиалов пользователя + +```typescript +// Получить все филиалы, к которым привязан пользователь +GET /branches/my +``` + +## Миграция базы данных + +После добавления схем необходимо выполнить миграцию: + +```bash +cd server +bun run drizzle-kit generate +bun run drizzle-kit migrate +``` + +## Типы TypeScript + +### Company +```typescript +type Company = { + id: string; + name: string; + description: string | null; + createdAt: Date; + updatedAt: Date; +}; +``` + +### Branch +```typescript +type Branch = { + id: string; + companyId: string; + name: string; + address: string | null; + city: string | null; + country: string | null; + createdAt: Date; + updatedAt: Date; +}; +``` + +### UserBranch +```typescript +type UserBranch = { + userId: string; + branchId: string; + createdAt: Date; +}; +``` + +## Примечания + +1. **Каскадное удаление**: При удалении компании автоматически удаляются все её филиалы +2. **Каскадное удаление связей**: При удалении пользователя или филиала автоматически удаляются все связи в таблице `user_branches` +3. **Валидация**: Пользователь может установить текущим только тот филиал, к которому он привязан +4. **Many-to-Many**: Пользователь может быть привязан к нескольким филиалам одновременно +5. **Текущий филиал**: Поле `currentBranchId` в таблице пользователей позволяет отслеживать, в каком филиале пользователь работает в данный момент + diff --git a/server/src/controllers/auth.ts b/server/src/controllers/auth.ts index bfc3028..bbbce12 100644 --- a/server/src/controllers/auth.ts +++ b/server/src/controllers/auth.ts @@ -4,6 +4,7 @@ import { loginService, registerService, sessionService, + userService, } from "../services/auth"; import type { LoginData, RegisterData } from "../services/auth/types"; @@ -43,7 +44,17 @@ export const authController = new Elysia({ prefix: "/auth" }) .use(authMiddleware) // GET /me .get("/me", async ({ currentUser }) => { - return { user: currentUser }; + // Получить полную информацию о пользователе с филиалом и компанией + const userWithRelations = await userService.findByIdWithRelations( + currentUser.id + ); + + if (!userWithRelations) { + return { user: currentUser }; + } + + const userResponse = userService.sanitizeWithRelations(userWithRelations); + return { user: userResponse }; }) // POST /logout .post("/logout", async ({ authSession }) => { diff --git a/server/src/controllers/branch.ts b/server/src/controllers/branch.ts new file mode 100644 index 0000000..4a6dc7e --- /dev/null +++ b/server/src/controllers/branch.ts @@ -0,0 +1,194 @@ +import { Elysia, t } from "elysia"; +import { authMiddleware } from "../middlewares/auth"; +import { branchService } from "../services/branch"; +import { companyService } from "../services/company"; + +export const branchController = new Elysia({ prefix: "/branches" }) + // Все роуты требуют авторизации + .use(authMiddleware) + // GET /branches - получить филиалы пользователя + .get("/my", async ({ currentUser }) => { + const branches = await branchService.findByUserId(currentUser.id); + return { branches }; + }) + // GET /branches/:id - получить филиал по ID + .get("/:id", async ({ params, status }) => { + const { id } = params; + + const branch = await branchService.findById(id); + + if (!branch) { + return status(404, "Branch not found"); + } + + return { branch }; + }) + // GET /branches/:id/users - получить пользователей филиала + .get("/:id/users", async ({ params, status }) => { + const { id } = params; + + // Проверить существование филиала + const branch = await branchService.findById(id); + + if (!branch) { + return status(404, "Branch not found"); + } + + const users = await branchService.getUsersByBranchId(id); + + return { users }; + }) + // POST /branches - создать филиал + .post( + "/", + async ({ body, status }) => { + const { companyId, name, address, city, country } = body as { + companyId: string; + name: string; + address?: string; + city?: string; + country?: string; + }; + + // Проверить существование компании + const company = await companyService.findById(companyId); + + if (!company) { + return status(404, "Company not found"); + } + + const branch = await branchService.create({ + companyId, + name, + address, + city, + country, + }); + + return { branch }; + }, + { + body: t.Object({ + companyId: t.String({ format: "uuid" }), + name: t.String({ minLength: 1, maxLength: 255 }), + address: t.Optional(t.String({ maxLength: 500 })), + city: t.Optional(t.String({ maxLength: 100 })), + country: t.Optional(t.String({ maxLength: 100 })), + }), + } + ) + // PATCH /branches/:id - обновить филиал + .patch( + "/:id", + async ({ params, body, status }) => { + const { id } = params; + const { name, address, city, country } = body as { + name?: string; + address?: string; + city?: string; + country?: string; + }; + + // Проверить существование филиала + const branch = await branchService.findById(id); + + if (!branch) { + return status(404, "Branch not found"); + } + + const updatedBranch = await branchService.update(id, { + name, + address, + city, + country, + }); + + return { branch: updatedBranch }; + }, + { + body: t.Object({ + name: t.Optional(t.String({ minLength: 1, maxLength: 255 })), + address: t.Optional(t.String({ maxLength: 500 })), + city: t.Optional(t.String({ maxLength: 100 })), + country: t.Optional(t.String({ maxLength: 100 })), + }), + } + ) + // DELETE /branches/:id - удалить филиал + .delete("/:id", async ({ params, status }) => { + const { id } = params; + + // Проверить существование филиала + const branch = await branchService.findById(id); + + if (!branch) { + return status(404, "Branch not found"); + } + + await branchService.delete(id); + + return { message: "Branch deleted successfully" }; + }) + // POST /branches/:id/users - привязать пользователя к филиалу + .post( + "/:id/users", + async ({ params, body, status }) => { + const { id } = params; + const { userId } = body as { userId: string }; + + // Проверить существование филиала + const branch = await branchService.findById(id); + + if (!branch) { + return status(404, "Branch not found"); + } + + try { + await branchService.assignUserToBranch(userId, id); + return { message: "User assigned to branch successfully" }; + } catch (error) { + return status(409, "User is already assigned to this branch"); + } + }, + { + body: t.Object({ + userId: t.String({ format: "uuid" }), + }), + } + ) + // DELETE /branches/:id/users/:userId - отвязать пользователя от филиала + .delete("/:id/users/:userId", async ({ params, status }) => { + const { id, userId } = params; + + // Проверить существование филиала + const branch = await branchService.findById(id); + + if (!branch) { + return status(404, "Branch not found"); + } + + await branchService.removeUserFromBranch(userId, id); + + return { message: "User removed from branch successfully" }; + }) + // POST /branches/:id/select - установить филиал как текущий для пользователя + .post("/:id/select", async ({ params, currentUser, status }) => { + const { id } = params; + + try { + const updatedUser = await branchService.setUserCurrentBranch( + currentUser.id, + id + ); + + return { + message: "Current branch updated successfully", + currentBranchId: updatedUser.currentBranchId, + }; + } catch (error) { + if (error instanceof Error) { + return status(400, error.message); + } + return status(500, "Failed to update current branch"); + } + }); diff --git a/server/src/controllers/company.ts b/server/src/controllers/company.ts new file mode 100644 index 0000000..45eaa0e --- /dev/null +++ b/server/src/controllers/company.ts @@ -0,0 +1,93 @@ +import { Elysia, t } from "elysia"; +import { authMiddleware } from "../middlewares/auth"; +import { companyService } from "../services/company"; + +export const companyController = new Elysia({ prefix: "/companies" }) + // Все роуты требуют авторизации + .use(authMiddleware) + // GET /companies - получить все компании + .get("/", async () => { + const companies = await companyService.findAll(); + return { companies }; + }) + // GET /companies/:id - получить компанию по ID + .get("/:id", async ({ params, status }) => { + const { id } = params; + + const company = await companyService.findById(id); + + if (!company) { + return status(404, "Company not found"); + } + + return { company }; + }) + // POST /companies - создать компанию + .post( + "/", + async ({ body }) => { + const { name, description } = body as { + name: string; + description?: string; + }; + + const company = await companyService.create({ + name, + description, + }); + + return { company }; + }, + { + body: t.Object({ + name: t.String({ minLength: 1, maxLength: 255 }), + description: t.Optional(t.String({ maxLength: 1000 })), + }), + } + ) + // PATCH /companies/:id - обновить компанию + .patch( + "/:id", + async ({ params, body, status }) => { + const { id } = params; + const { name, description } = body as { + name?: string; + description?: string; + }; + + // Проверить существование компании + const company = await companyService.findById(id); + + if (!company) { + return status(404, "Company not found"); + } + + const updatedCompany = await companyService.update(id, { + name, + description, + }); + + return { company: updatedCompany }; + }, + { + body: t.Object({ + name: t.Optional(t.String({ minLength: 1, maxLength: 255 })), + description: t.Optional(t.String({ maxLength: 1000 })), + }), + } + ) + // DELETE /companies/:id - удалить компанию + .delete("/:id", async ({ params, status }) => { + const { id } = params; + + // Проверить существование компании + const company = await companyService.findById(id); + + if (!company) { + return status(404, "Company not found"); + } + + await companyService.delete(id); + + return { message: "Company deleted successfully" }; + }); diff --git a/server/src/controllers/session.ts b/server/src/controllers/session.ts new file mode 100644 index 0000000..fa82f51 --- /dev/null +++ b/server/src/controllers/session.ts @@ -0,0 +1,208 @@ +import { Elysia, t } from "elysia"; +import { authMiddleware } from "../middlewares/auth"; +import { eq } from "drizzle-orm"; +import db from "../db"; +import { apps } from "../db/schema/apps"; +import { serverSessionService } from "../services/serverSession"; + +export const sessionController = new Elysia({ prefix: "/sessions" }) + // Все роуты требуют авторизации + .use(authMiddleware) + // GET /sessions - получить список сессий пользователя + .get("/", async ({ currentUser, query }) => { + const { status, mode } = query as { + status?: "starting" | "started" | "ending" | "ended"; + mode?: "stream" | "local"; + }; + + const sessions = await serverSessionService.findByUserId(currentUser.id, { + status, + mode, + }); + + return { sessions }; + }) + // GET /sessions/:id - получить информацию о конкретной сессии + .get("/:id", async ({ params, currentUser, status }) => { + const { id } = params; + + const session = await serverSessionService.findByIdForUser( + id, + currentUser.id + ); + + if (!session) { + return status(404, "Session not found"); + } + + return { session }; + }) + // POST /sessions - создать новую сессию + .post( + "/", + async ({ body, currentUser, status }) => { + const { appId, mode, serverId } = body as { + appId: string; + mode: "stream" | "local"; + serverId?: string; + }; + + // Проверить, что приложение существует + const app = await db.query.apps.findFirst({ + where: eq(apps.id, appId), + }); + + if (!app) { + return status(404, "App not found"); + } + + // Проверить, что пользователь не имеет активных сессий этого приложения + const hasActive = await serverSessionService.hasActiveSession( + currentUser.id, + appId + ); + + if (hasActive) { + return status(409, "User already has an active session for this app"); + } + + // Создать сессию + try { + const newSession = await serverSessionService.create({ + appId, + userId: currentUser.id, + mode, + serverId, + }); + + return { session: newSession }; + } catch (error) { + if (error instanceof Error) { + return status(503, error.message); + } + return status(500, "Failed to create session"); + } + }, + { + body: t.Object({ + appId: t.String({ format: "uuid" }), + mode: t.Union([t.Literal("stream"), t.Literal("local")]), + serverId: t.Optional(t.String({ format: "uuid" })), + }), + } + ) + // PATCH /sessions/:id - обновить статус сессии + .patch( + "/:id", + async ({ params, body, currentUser, status }) => { + const { id } = params; + const { + status: sessionStatus, + appPid, + cirrusPid, + endAt, + } = body as { + status?: "starting" | "started" | "ending" | "ended"; + appPid?: number; + cirrusPid?: number; + endAt?: string; + }; + + // Проверить, что сессия существует и принадлежит пользователю + const session = await serverSessionService.findByIdForUser( + id, + currentUser.id + ); + + if (!session) { + return status(404, "Session not found"); + } + + // Обновить сессию + const updatedSession = await serverSessionService.update(id, { + status: sessionStatus, + appPid, + cirrusPid, + endAt: endAt ? new Date(endAt) : undefined, + }); + + return { session: updatedSession }; + }, + { + body: t.Object({ + status: t.Optional( + t.Union([ + t.Literal("starting"), + t.Literal("started"), + t.Literal("ending"), + t.Literal("ended"), + ]) + ), + appPid: t.Optional(t.Number()), + cirrusPid: t.Optional(t.Number()), + endAt: t.Optional(t.String({ format: "date-time" })), + }), + } + ) + // DELETE /sessions/:id - удалить (завершить) сессию + .delete("/:id", async ({ params, currentUser, status }) => { + const { id } = params; + + // Проверить, что сессия существует и принадлежит пользователю + const session = await serverSessionService.findByIdForUser( + id, + currentUser.id + ); + + if (!session) { + return status(404, "Session not found"); + } + + // Если сессия активна, изменить статус на "ending" + if (session.status === "started" || session.status === "starting") { + await serverSessionService.end(id); + return { message: "Session is ending" }; + } + + // Если сессия уже завершена или завершается + return { message: "Session already ended or ending" }; + }) + // POST /sessions/:id/extend - продлить сессию + .post( + "/:id/extend", + async ({ params, body, currentUser, status }) => { + const { id } = params; + const { minutes } = body as { minutes: number }; + + // Проверить, что сессия существует и принадлежит пользователю + const session = await serverSessionService.findByIdForUser( + id, + currentUser.id + ); + + if (!session) { + return status(404, "Session not found"); + } + + // Проверить, что сессия активна + if (session.status !== "started") { + return status(400, "Can only extend active sessions"); + } + + // Продлить сессию + try { + const updatedSession = await serverSessionService.extend(id, minutes); + return { session: updatedSession }; + } catch (error) { + if (error instanceof Error) { + return status(400, error.message); + } + return status(500, "Failed to extend session"); + } + }, + { + body: t.Object({ + minutes: t.Number({ minimum: 1, maximum: 120 }), + }), + } + ); diff --git a/server/src/db/schema/branches.ts b/server/src/db/schema/branches.ts new file mode 100644 index 0000000..98b73be --- /dev/null +++ b/server/src/db/schema/branches.ts @@ -0,0 +1,24 @@ +import { pgTable, uuid, varchar, timestamp } from "drizzle-orm/pg-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { companies } from "./companies"; + +export const branches = pgTable("branches", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id") + .notNull() + .references(() => companies.id, { onDelete: "cascade" }), + name: varchar("name", { length: 255 }).notNull(), + address: varchar("address", { length: 500 }), + city: varchar("city", { length: 100 }), + country: varchar("country", { length: 100 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +// Zod schemas for validation +export const insertBranchSchema = createInsertSchema(branches); +export const selectBranchSchema = createSelectSchema(branches); + +// Type exports +export type Branch = typeof branches.$inferSelect; +export type NewBranch = typeof branches.$inferInsert; diff --git a/server/src/db/schema/companies.ts b/server/src/db/schema/companies.ts new file mode 100644 index 0000000..cbcf004 --- /dev/null +++ b/server/src/db/schema/companies.ts @@ -0,0 +1,18 @@ +import { pgTable, uuid, varchar, timestamp } from "drizzle-orm/pg-core"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; + +export const companies = pgTable("companies", { + id: uuid("id").primaryKey().defaultRandom(), + name: varchar("name", { length: 255 }).notNull(), + description: varchar("description", { length: 1000 }), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), +}); + +// Zod schemas for validation +export const insertCompanySchema = createInsertSchema(companies); +export const selectCompanySchema = createSelectSchema(companies); + +// Type exports +export type Company = typeof companies.$inferSelect; +export type NewCompany = typeof companies.$inferInsert; diff --git a/server/src/db/schema/enums.ts b/server/src/db/schema/enums.ts index 8e70b67..e4fc910 100644 --- a/server/src/db/schema/enums.ts +++ b/server/src/db/schema/enums.ts @@ -6,4 +6,3 @@ export const roleNameEnum = pgEnum("role_name", [ "director", "manager", ]); - diff --git a/server/src/db/schema/index.ts b/server/src/db/schema/index.ts index cf7546a..237277a 100644 --- a/server/src/db/schema/index.ts +++ b/server/src/db/schema/index.ts @@ -1,9 +1,29 @@ // Export all schemas export * from "./enums"; -export * from "./streamServers"; -export * from "./localServers"; +export * from "./servers"; export * from "./apps"; +export * from "./companies"; +export * from "./branches"; export * from "./users"; +export * from "./userBranches"; export * from "./serverSessions"; export * from "./authSessions"; export * from "./protectedRoutes"; + +// Relations (defined here to avoid circular dependencies) +import { relations } from "drizzle-orm"; +import { companies } from "./companies"; +import { branches } from "./branches"; +import { userBranches } from "./userBranches"; + +export const companiesRelations = relations(companies, ({ many }) => ({ + branches: many(branches), +})); + +export const branchesRelations = relations(branches, ({ one, many }) => ({ + company: one(companies, { + fields: [branches.companyId], + references: [companies.id], + }), + userBranches: many(userBranches), +})); \ No newline at end of file diff --git a/server/src/db/schema/localServers.ts b/server/src/db/schema/localServers.ts deleted file mode 100644 index 8494196..0000000 --- a/server/src/db/schema/localServers.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { pgTable, uuid, varchar } from "drizzle-orm/pg-core"; -import { relations } from "drizzle-orm"; -import { createInsertSchema, createSelectSchema } from "drizzle-zod"; -import { serverSessions } from "./serverSessions"; - -export const localServers = pgTable("local_servers", { - id: uuid("id").primaryKey().defaultRandom(), - localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars - hostname: varchar("hostname", { length: 255 }).notNull(), - location: varchar("location", { length: 10 }).notNull(), // ru1, uae1 - type: varchar("type", { length: 10 }).notNull(), // demo, prod -}); - -// Zod schemas for validation -export const insertLocalServerSchema = createInsertSchema(localServers); -export const selectLocalServerSchema = createSelectSchema(localServers); - -// Relations -export const localServersRelations = relations(localServers, ({ many }) => ({ - serverSessions: many(serverSessions, { - relationName: "session_local_server", - }), -})); - -// Type exports -export type LocalServer = typeof localServers.$inferSelect; -export type NewLocalServer = typeof localServers.$inferInsert; diff --git a/server/src/db/schema/protectedRoutes.ts b/server/src/db/schema/protectedRoutes.ts index cc9f19c..e80ca6b 100644 --- a/server/src/db/schema/protectedRoutes.ts +++ b/server/src/db/schema/protectedRoutes.ts @@ -4,14 +4,8 @@ import { roleNameEnum } from "./enums"; export const protectedRoutes = pgTable("protected_routes", { path: varchar("path", { length: 255 }).primaryKey(), // /auth/register-user - methods: varchar("methods", { length: 50 }) - .array() - .notNull() - .default([]), // Массив: ["GET", "POST", "PUT", "DELETE", "PATCH"] - roles: roleNameEnum("roles") - .array() - .notNull() - .default([]), // Массив: ["admin", "director", "manager"] + methods: varchar("methods", { length: 50 }).array().notNull().default([]), // Массив: ["GET", "POST", "PUT", "DELETE", "PATCH"] + roles: roleNameEnum("roles").array().notNull().default([]), // Массив: ["admin", "director", "manager"] description: text("description"), // Описание маршрута createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() @@ -28,4 +22,3 @@ export const selectProtectedRouteSchema = createSelectSchema(protectedRoutes); // Type exports export type ProtectedRoute = typeof protectedRoutes.$inferSelect; export type NewProtectedRoute = typeof protectedRoutes.$inferInsert; - diff --git a/server/src/db/schema/serverSessions.ts b/server/src/db/schema/serverSessions.ts index 525aad2..19fc4a5 100644 --- a/server/src/db/schema/serverSessions.ts +++ b/server/src/db/schema/serverSessions.ts @@ -1,6 +1,5 @@ import { pgTable, uuid, integer, timestamp, pgEnum } from "drizzle-orm/pg-core"; -import { streamServers } from "./streamServers"; -import { localServers } from "./localServers"; +import { servers } from "./servers"; import { apps } from "./apps"; import { users } from "./users"; import { relations } from "drizzle-orm"; @@ -8,10 +7,18 @@ import { createInsertSchema, createSelectSchema } from "drizzle-zod"; // Enums export const sessionModeEnum = pgEnum("session_mode", ["stream", "local"]); +export const sessionStatusEnum = pgEnum("session_status", [ + "starting", + "started", + "ending", + "ended", +]); export const serverSessions = pgTable("server_sessions", { id: uuid("id").primaryKey().defaultRandom(), - serverId: uuid("server_id").notNull(), + serverId: uuid("server_id") + .notNull() + .references(() => servers.id), appId: uuid("app_id") .notNull() .references(() => apps.id), @@ -23,6 +30,7 @@ export const serverSessions = pgTable("server_sessions", { appPid: integer("app_pid"), cirrusPid: integer("cirrus_pid"), mode: sessionModeEnum("mode").notNull(), // stream, local + status: sessionStatusEnum("status").notNull(), // starting, started, ending, ended createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), }); @@ -37,17 +45,9 @@ export const serverSessionsRelations = relations(serverSessions, ({ one }) => ({ fields: [serverSessions.userId], references: [users.id], }), - // Полиморфная реляция для serverId - // В зависимости от mode, serverId может ссылаться на stream_servers или local_servers - streamServer: one(streamServers, { + server: one(servers, { fields: [serverSessions.serverId], - references: [streamServers.id], - relationName: "session_stream_server", - }), - localServer: one(localServers, { - fields: [serverSessions.serverId], - references: [localServers.id], - relationName: "session_local_server", + references: [servers.id], }), })); diff --git a/server/src/db/schema/servers.ts b/server/src/db/schema/servers.ts new file mode 100644 index 0000000..e82b6f0 --- /dev/null +++ b/server/src/db/schema/servers.ts @@ -0,0 +1,31 @@ +import { pgTable, uuid, varchar, pgEnum } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { serverSessions } from "./serverSessions"; + +// Enums +export const serverLocationEnum = pgEnum("server_location", ["ru1", "uae1"]); +export const serverTypeEnum = pgEnum("server_type", ["stream", "local"]); +export const serverTierEnum = pgEnum("server_tier", ["demo", "prod"]); + +export const servers = pgTable("servers", { + id: uuid("id").primaryKey().defaultRandom(), + localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars + hostname: varchar("hostname").notNull(), + type: serverTypeEnum("type").notNull(), // stream, local + location: serverLocationEnum("location"), // ru1, uae1 (только для stream) + tier: serverTierEnum("tier"), // demo, prod (только для stream) +}); + +// Zod schemas for validation +export const insertServerSchema = createInsertSchema(servers); +export const selectServerSchema = createSelectSchema(servers); + +// Relations +export const serversRelations = relations(servers, ({ many }) => ({ + serverSessions: many(serverSessions), +})); + +// Type exports +export type Server = typeof servers.$inferSelect; +export type NewServer = typeof servers.$inferInsert; diff --git a/server/src/db/schema/streamServers.ts b/server/src/db/schema/streamServers.ts deleted file mode 100644 index c6a4fd9..0000000 --- a/server/src/db/schema/streamServers.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { pgTable, uuid, varchar, pgEnum } from "drizzle-orm/pg-core"; -import { relations } from "drizzle-orm"; -import { createInsertSchema, createSelectSchema } from "drizzle-zod"; -import { serverSessions } from "./serverSessions"; - -// Enums -export const serverLocationEnum = pgEnum("server_location", ["ru1", "uae1"]); -export const serverTypeEnum = pgEnum("server_type", ["demo", "prod"]); - -export const streamServers = pgTable("stream_servers", { - id: uuid("id").primaryKey().defaultRandom(), - localIp: varchar("local_ip", { length: 45 }).notNull(), // IPv6 can be up to 45 chars - hostname: varchar("hostname", { length: 255 }).notNull(), - location: serverLocationEnum("location").notNull(), - type: serverTypeEnum("type").notNull(), -}); - -// Zod schemas for validation -export const insertStreamServerSchema = createInsertSchema(streamServers); -export const selectStreamServerSchema = createSelectSchema(streamServers); - -// Relations -export const streamServersRelations = relations(streamServers, ({ many }) => ({ - serverSessions: many(serverSessions, { - relationName: "session_stream_server", - }), -})); - -// Type exports -export type StreamServer = typeof streamServers.$inferSelect; -export type NewStreamServer = typeof streamServers.$inferInsert; diff --git a/server/src/db/schema/userBranches.ts b/server/src/db/schema/userBranches.ts new file mode 100644 index 0000000..56db018 --- /dev/null +++ b/server/src/db/schema/userBranches.ts @@ -0,0 +1,42 @@ +import { pgTable, uuid, timestamp, primaryKey } from "drizzle-orm/pg-core"; +import { relations } from "drizzle-orm"; +import { createInsertSchema, createSelectSchema } from "drizzle-zod"; +import { users } from "./users"; +import { branches } from "./branches"; + +// Junction table для связи many-to-many между пользователями и филиалами +export const userBranches = pgTable( + "user_branches", + { + userId: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + branchId: uuid("branch_id") + .notNull() + .references(() => branches.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), + }, + (table) => ({ + pk: primaryKey({ columns: [table.userId, table.branchId] }), + }) +); + +// Relations +export const userBranchesRelations = relations(userBranches, ({ one }) => ({ + user: one(users, { + fields: [userBranches.userId], + references: [users.id], + }), + branch: one(branches, { + fields: [userBranches.branchId], + references: [branches.id], + }), +})); + +// Zod schemas for validation +export const insertUserBranchSchema = createInsertSchema(userBranches); +export const selectUserBranchSchema = createSelectSchema(userBranches); + +// Type exports +export type UserBranch = typeof userBranches.$inferSelect; +export type NewUserBranch = typeof userBranches.$inferInsert; diff --git a/server/src/db/schema/users.ts b/server/src/db/schema/users.ts index 794d1f5..ffa11f6 100644 --- a/server/src/db/schema/users.ts +++ b/server/src/db/schema/users.ts @@ -4,6 +4,8 @@ import { createInsertSchema, createSelectSchema } from "drizzle-zod"; import { roleNameEnum } from "./enums"; import { serverSessions } from "./serverSessions"; import { authSessions } from "./authSessions"; +import { userBranches } from "./userBranches"; +import { branches } from "./branches"; export const users = pgTable("users", { id: uuid("id").primaryKey().defaultRandom(), @@ -11,6 +13,7 @@ export const users = pgTable("users", { password: varchar("password", { length: 255 }).notNull(), // scrypt hash fullName: varchar("full_name", { length: 255 }).notNull(), // ФИО role: roleNameEnum("role").notNull().default("manager"), + currentBranchId: uuid("current_branch_id").references(() => branches.id), // Текущий выбранный филиал createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), @@ -24,9 +27,14 @@ export const insertUserSchema = createInsertSchema(users); export const selectUserSchema = createSelectSchema(users); // Relations -export const usersRelations = relations(users, ({ many }) => ({ +export const usersRelations = relations(users, ({ one, many }) => ({ serverSessions: many(serverSessions), authSessions: many(authSessions), + userBranches: many(userBranches), + currentBranch: one(branches, { + fields: [users.currentBranchId], + references: [branches.id], + }), })); // Type exports diff --git a/server/src/index.ts b/server/src/index.ts index a56ac02..4dffc45 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1,6 +1,9 @@ import { Elysia } from "elysia"; import cors from "@elysiajs/cors"; import { authController } from "./controllers/auth"; +import { sessionController } from "./controllers/session"; +import { companyController } from "./controllers/company"; +import { branchController } from "./controllers/branch"; const app = new Elysia(); @@ -12,6 +15,9 @@ app.use( ); app.use(authController); +app.use(sessionController); +app.use(companyController); +app.use(branchController); app.listen(3000); diff --git a/server/src/services/auth/login.ts b/server/src/services/auth/login.ts index ffe0ada..8928693 100644 --- a/server/src/services/auth/login.ts +++ b/server/src/services/auth/login.ts @@ -32,7 +32,10 @@ export const loginService = { } // Создать новую сессию - const { sessionId, accessToken } = await sessionService.create(user.id, metadata); + const { sessionId, accessToken } = await sessionService.create( + user.id, + metadata + ); // Вычислить дату истечения токена const expiresAt = new Date(); @@ -48,4 +51,3 @@ export const loginService = { }; }, }; - diff --git a/server/src/services/auth/register.ts b/server/src/services/auth/register.ts index 47dae31..be1bbe2 100644 --- a/server/src/services/auth/register.ts +++ b/server/src/services/auth/register.ts @@ -30,9 +30,7 @@ export const registerService = { // Определить роль для нового пользователя // Только администраторы могут указывать кастомную роль const role = - callerRole === "admin" && data.role - ? data.role - : DEFAULT_ROLE_NAME; + callerRole === "admin" && data.role ? data.role : DEFAULT_ROLE_NAME; // Создать пользователя const newUser = await userService.create({ @@ -45,4 +43,3 @@ export const registerService = { return userService.sanitize(newUser); }, }; - diff --git a/server/src/services/auth/types.ts b/server/src/services/auth/types.ts index d774ab4..60acdfa 100644 --- a/server/src/services/auth/types.ts +++ b/server/src/services/auth/types.ts @@ -2,12 +2,28 @@ export type RoleName = "admin" | "director" | "manager"; +export type BranchResponse = { + id: string; + name: string; + address: string | null; + city: string | null; + country: string | null; +}; + +export type CompanyResponse = { + id: string; + name: string; + description: string | null; +}; + export type UserResponse = { id: string; email: string; fullName: string; role: RoleName; createdAt: Date; + currentBranch?: BranchResponse; + currentCompany?: CompanyResponse; }; export type LoginResult = { @@ -35,4 +51,3 @@ export type SessionMetadata = { userAgent: string | null; ipAddress: string | null; }; - diff --git a/server/src/services/auth/user.ts b/server/src/services/auth/user.ts index f455688..34442d6 100644 --- a/server/src/services/auth/user.ts +++ b/server/src/services/auth/user.ts @@ -71,7 +71,7 @@ export const userService = { /** * Убрать пароль из объекта пользователя */ - sanitize(user: User): UserResponse { + sanitize(user: User, includeRelations: boolean = false): UserResponse { return { id: user.id, email: user.email, @@ -80,4 +80,55 @@ export const userService = { createdAt: user.createdAt, }; }, + + /** + * Получить пользователя по ID с полной информацией (филиал и компания) + */ + async findByIdWithRelations(userId: string): Promise { + const user = await db.query.users.findFirst({ + where: eq(users.id, userId), + with: { + currentBranch: { + with: { + company: true, + }, + }, + }, + }); + + return user || null; + }, + + /** + * Убрать пароль из объекта пользователя и добавить информацию о филиале и компании + */ + sanitizeWithRelations(user: any): UserResponse { + const response: UserResponse = { + id: user.id, + email: user.email, + fullName: user.fullName, + role: user.role, + createdAt: user.createdAt, + }; + + if (user.currentBranch) { + response.currentBranch = { + id: user.currentBranch.id, + name: user.currentBranch.name, + address: user.currentBranch.address, + city: user.currentBranch.city, + country: user.currentBranch.country, + }; + + if (user.currentBranch.company) { + response.currentCompany = { + id: user.currentBranch.company.id, + name: user.currentBranch.company.name, + description: user.currentBranch.company.description, + }; + } + } + + return response; + }, }; diff --git a/server/src/services/branch/index.ts b/server/src/services/branch/index.ts new file mode 100644 index 0000000..02aa7dd --- /dev/null +++ b/server/src/services/branch/index.ts @@ -0,0 +1,208 @@ +import { eq, and } from "drizzle-orm"; +import db from "../../db"; +import { branches, userBranches, users } from "../../db/schema"; + +export interface CreateBranchParams { + companyId: string; + name: string; + address?: string; + city?: string; + country?: string; +} + +export interface UpdateBranchParams { + name?: string; + address?: string; + city?: string; + country?: string; +} + +/** + * Сервис для работы с филиалами + */ +export const branchService = { + /** + * Создать филиал + */ + async create(params: CreateBranchParams) { + const [branch] = await db + .insert(branches) + .values({ + companyId: params.companyId, + name: params.name, + address: params.address, + city: params.city, + country: params.country, + }) + .returning(); + + return branch; + }, + + /** + * Получить все филиалы компании + */ + async findByCompanyId(companyId: string) { + const companyBranches = await db.query.branches.findMany({ + where: eq(branches.companyId, companyId), + with: { + company: true, + }, + orderBy: (branches, { asc }) => [asc(branches.name)], + }); + + return companyBranches; + }, + + /** + * Найти филиал по ID + */ + async findById(branchId: string) { + const branch = await db.query.branches.findFirst({ + where: eq(branches.id, branchId), + with: { + company: true, + }, + }); + + return branch || null; + }, + + /** + * Получить филиалы пользователя + */ + async findByUserId(userId: string) { + const userBranchRecords = await db.query.userBranches.findMany({ + where: eq(userBranches.userId, userId), + with: { + branch: { + with: { + company: true, + }, + }, + }, + }); + + return userBranchRecords.map((record) => record.branch); + }, + + /** + * Обновить филиал + */ + async update(branchId: string, params: UpdateBranchParams) { + const updateData: any = { + updatedAt: new Date(), + }; + + if (params.name) { + updateData.name = params.name; + } + + if (params.address !== undefined) { + updateData.address = params.address; + } + + if (params.city !== undefined) { + updateData.city = params.city; + } + + if (params.country !== undefined) { + updateData.country = params.country; + } + + const [updatedBranch] = await db + .update(branches) + .set(updateData) + .where(eq(branches.id, branchId)) + .returning(); + + return updatedBranch; + }, + + /** + * Удалить филиал + */ + async delete(branchId: string) { + await db.delete(branches).where(eq(branches.id, branchId)); + }, + + /** + * Привязать пользователя к филиалу + */ + async assignUserToBranch(userId: string, branchId: string) { + const [userBranch] = await db + .insert(userBranches) + .values({ + userId, + branchId, + }) + .returning(); + + return userBranch; + }, + + /** + * Отвязать пользователя от филиала + */ + async removeUserFromBranch(userId: string, branchId: string) { + await db + .delete(userBranches) + .where( + and( + eq(userBranches.userId, userId), + eq(userBranches.branchId, branchId) + ) + ); + }, + + /** + * Установить текущий филиал для пользователя + */ + async setUserCurrentBranch(userId: string, branchId: string) { + // Проверить, что пользователь привязан к этому филиалу + const userBranch = await db.query.userBranches.findFirst({ + where: and( + eq(userBranches.userId, userId), + eq(userBranches.branchId, branchId) + ), + }); + + if (!userBranch) { + throw new Error("User is not assigned to this branch"); + } + + // Обновить текущий филиал пользователя + const [updatedUser] = await db + .update(users) + .set({ + currentBranchId: branchId, + updatedAt: new Date(), + }) + .where(eq(users.id, userId)) + .returning(); + + return updatedUser; + }, + + /** + * Получить пользователей филиала + */ + async getUsersByBranchId(branchId: string) { + const branchUsers = await db.query.userBranches.findMany({ + where: eq(userBranches.branchId, branchId), + with: { + user: { + columns: { + id: true, + email: true, + fullName: true, + role: true, + currentBranchId: true, + }, + }, + }, + }); + + return branchUsers.map((record) => record.user); + }, +}; diff --git a/server/src/services/company/index.ts b/server/src/services/company/index.ts new file mode 100644 index 0000000..575639e --- /dev/null +++ b/server/src/services/company/index.ts @@ -0,0 +1,93 @@ +import { eq } from "drizzle-orm"; +import db from "../../db"; +import { companies, branches, userBranches } from "../../db/schema"; + +export interface CreateCompanyParams { + name: string; + description?: string; +} + +export interface UpdateCompanyParams { + name?: string; + description?: string; +} + +/** + * Сервис для работы с компаниями + */ +export const companyService = { + /** + * Создать компанию + */ + async create(params: CreateCompanyParams) { + const [company] = await db + .insert(companies) + .values({ + name: params.name, + description: params.description, + }) + .returning(); + + return company; + }, + + /** + * Получить все компании + */ + async findAll() { + const allCompanies = await db.query.companies.findMany({ + with: { + branches: true, + }, + orderBy: (companies, { asc }) => [asc(companies.name)], + }); + + return allCompanies; + }, + + /** + * Найти компанию по ID + */ + async findById(companyId: string) { + const company = await db.query.companies.findFirst({ + where: eq(companies.id, companyId), + with: { + branches: true, + }, + }); + + return company || null; + }, + + /** + * Обновить компанию + */ + async update(companyId: string, params: UpdateCompanyParams) { + const updateData: any = { + updatedAt: new Date(), + }; + + if (params.name) { + updateData.name = params.name; + } + + if (params.description !== undefined) { + updateData.description = params.description; + } + + const [updatedCompany] = await db + .update(companies) + .set(updateData) + .where(eq(companies.id, companyId)) + .returning(); + + return updatedCompany; + }, + + /** + * Удалить компанию (каскадно удалит все филиалы) + */ + async delete(companyId: string) { + await db.delete(companies).where(eq(companies.id, companyId)); + }, +}; diff --git a/server/src/services/serverSession/index.ts b/server/src/services/serverSession/index.ts new file mode 100644 index 0000000..c93ece0 --- /dev/null +++ b/server/src/services/serverSession/index.ts @@ -0,0 +1,266 @@ +import { eq, and, or } from "drizzle-orm"; +import db from "../../db"; +import { serverSessions } from "../../db/schema/serverSessions"; +import { servers } from "../../db/schema/servers"; +import { apps } from "../../db/schema/apps"; + +export type SessionMode = "stream" | "local"; +export type SessionStatus = "starting" | "started" | "ending" | "ended"; + +export interface CreateSessionParams { + appId: string; + userId: string; + mode: SessionMode; + serverId?: string; +} + +export interface UpdateSessionParams { + status?: SessionStatus; + appPid?: number; + cirrusPid?: number; + endAt?: Date; +} + +/** + * Сервис для работы с игровыми/streaming сессиями + */ +export const serverSessionService = { + /** + * Найти сессию по ID + */ + async findById(sessionId: string) { + const session = await db.query.serverSessions.findFirst({ + where: eq(serverSessions.id, sessionId), + with: { + app: true, + user: { + columns: { + id: true, + email: true, + role: true, + }, + }, + server: true, + }, + }); + + return session || null; + }, + + /** + * Найти сессию по ID для конкретного пользователя + */ + async findByIdForUser(sessionId: string, userId: string) { + const session = await db.query.serverSessions.findFirst({ + where: and( + eq(serverSessions.id, sessionId), + eq(serverSessions.userId, userId) + ), + with: { + app: true, + user: { + columns: { + id: true, + email: true, + role: true, + }, + }, + server: true, + }, + }); + + return session || null; + }, + + /** + * Получить все сессии пользователя + */ + async findByUserId( + userId: string, + filters?: { + status?: SessionStatus; + mode?: SessionMode; + } + ) { + const conditions = [eq(serverSessions.userId, userId)]; + + if (filters?.status) { + conditions.push(eq(serverSessions.status, filters.status)); + } + + if (filters?.mode) { + conditions.push(eq(serverSessions.mode, filters.mode)); + } + + const sessions = await db + .select({ + id: serverSessions.id, + serverId: serverSessions.serverId, + appId: serverSessions.appId, + userId: serverSessions.userId, + startAt: serverSessions.startAt, + endAt: serverSessions.endAt, + appPid: serverSessions.appPid, + cirrusPid: serverSessions.cirrusPid, + mode: serverSessions.mode, + status: serverSessions.status, + createdAt: serverSessions.createdAt, + updatedAt: serverSessions.updatedAt, + app: { + id: apps.id, + name: apps.name, + title: apps.title, + }, + }) + .from(serverSessions) + .leftJoin(apps, eq(serverSessions.appId, apps.id)) + .where(and(...conditions)) + .orderBy(serverSessions.createdAt); + + return sessions; + }, + + /** + * Проверить, есть ли у пользователя активная сессия для данного приложения + */ + async hasActiveSession(userId: string, appId: string) { + const session = await db.query.serverSessions.findFirst({ + where: and( + eq(serverSessions.userId, userId), + eq(serverSessions.appId, appId), + or( + eq(serverSessions.status, "starting"), + eq(serverSessions.status, "started") + ) + ), + }); + + return !!session; + }, + + /** + * Выбрать доступный сервер + */ + async selectAvailableServer(mode: SessionMode): Promise { + if (mode === "stream") { + const server = await db.query.servers.findFirst({ + where: and(eq(servers.type, "stream"), eq(servers.tier, "prod")), + }); + return server?.id; + } else { + const server = await db.query.servers.findFirst({ + where: eq(servers.type, "local"), + }); + return server?.id; + } + }, + + /** + * Создать новую сессию + */ + async create(params: CreateSessionParams) { + const { appId, userId, mode, serverId } = params; + + // Выбрать сервер (если не указан) + let selectedServerId = serverId; + if (!selectedServerId) { + selectedServerId = await this.selectAvailableServer(mode); + if (!selectedServerId) { + throw new Error(`No available ${mode} servers`); + } + } + + // Вычислить время окончания (по умолчанию +30 минут) + const endAt = new Date(); + endAt.setMinutes(endAt.getMinutes() + 30); + + // Создать сессию + const [newSession] = await db + .insert(serverSessions) + .values({ + serverId: selectedServerId, + appId, + userId, + mode, + status: "starting", + endAt, + }) + .returning(); + + return newSession; + }, + + /** + * Обновить сессию + */ + async update(sessionId: string, params: UpdateSessionParams) { + const updateData: any = { + updatedAt: new Date(), + }; + + if (params.status) { + updateData.status = params.status; + } + + if (params.appPid !== undefined) { + updateData.appPid = params.appPid; + } + + if (params.cirrusPid !== undefined) { + updateData.cirrusPid = params.cirrusPid; + } + + if (params.endAt) { + updateData.endAt = params.endAt; + } + + const [updatedSession] = await db + .update(serverSessions) + .set(updateData) + .where(eq(serverSessions.id, sessionId)) + .returning(); + + return updatedSession; + }, + + /** + * Завершить сессию (изменить статус на "ending") + */ + async end(sessionId: string) { + const [updatedSession] = await db + .update(serverSessions) + .set({ + status: "ending", + updatedAt: new Date(), + }) + .where(eq(serverSessions.id, sessionId)) + .returning(); + + return updatedSession; + }, + + /** + * Продлить сессию + */ + async extend(sessionId: string, minutes: number) { + const session = await this.findById(sessionId); + + if (!session) { + throw new Error("Session not found"); + } + + const newEndAt = session.endAt ? new Date(session.endAt) : new Date(); + newEndAt.setMinutes(newEndAt.getMinutes() + minutes); + + const [updatedSession] = await db + .update(serverSessions) + .set({ + endAt: newEndAt, + updatedAt: new Date(), + }) + .where(eq(serverSessions.id, sessionId)) + .returning(); + + return updatedSession; + }, +};