+
Нет аккаунта?{" "}
-
+
Зарегистрироваться
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;
+ },
+};