Refactor Protected and Public Routes for Consistent Loading UI; Enhance HomePage with User Company and Branch Details; Update LoginPage Layout; Introduce User Relations in Auth Services

This commit is contained in:
2025-10-03 17:52:56 +05:00
parent 531e2d2e7e
commit 0b024af454
31 changed files with 1770 additions and 115 deletions
+1 -2
View File
@@ -14,7 +14,7 @@ function ProtectedRoute({ children }: ProtectedRouteProps) {
// Показываем загрузку пока проверяем авторизацию
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex justify-center items-center min-h-screen">
<div className="text-xl">Проверка авторизации...</div>
</div>
);
@@ -30,4 +30,3 @@ function ProtectedRoute({ children }: ProtectedRouteProps) {
}
export default ProtectedRoute;
+1 -2
View File
@@ -15,7 +15,7 @@ function PublicRoute({ children }: PublicRouteProps) {
// Показываем загрузку пока проверяем авторизацию
if (isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex justify-center items-center min-h-screen">
<div className="text-xl">Загрузка...</div>
</div>
);
@@ -31,4 +31,3 @@ function PublicRoute({ children }: PublicRouteProps) {
}
export default PublicRoute;
-1
View File
@@ -18,4 +18,3 @@ export const api = ky.create({
],
},
});
-1
View File
@@ -12,4 +12,3 @@ export const queryClient = new QueryClient({
},
},
});
+51 -7
View File
@@ -12,24 +12,68 @@ function HomePage() {
};
return (
<div className="min-h-screen bg-gray-50 py-8">
<div className="max-w-4xl mx-auto px-4">
<div className="bg-white rounded-lg shadow-md p-8">
<h1 className="text-3xl font-bold mb-6">Главная страница</h1>
<div className="py-8 min-h-screen bg-gray-50">
<div className="px-4 mx-auto max-w-4xl">
<div className="p-8 bg-white rounded-lg shadow-md">
<h1 className="mb-6 text-3xl font-bold">Главная страница</h1>
<div className="space-y-4">
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h2 className="text-xl font-semibold mb-2">
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<h2 className="mb-2 text-xl font-semibold">
Добро пожаловать, {user?.fullName}!
</h2>
<p className="text-gray-600">Email: {user?.email}</p>
<p className="text-gray-600">Роль: {user?.role}</p>
</div>
{user?.currentCompany && (
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<h3 className="mb-2 text-lg font-semibold">Компания</h3>
<p className="font-medium text-gray-700">
{user.currentCompany.name}
</p>
{user.currentCompany.description && (
<p className="mt-1 text-sm text-gray-600">
{user.currentCompany.description}
</p>
)}
</div>
)}
{user?.currentBranch && (
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
<h3 className="mb-2 text-lg font-semibold">Филиал</h3>
<p className="font-medium text-gray-700">
{user.currentBranch.name}
</p>
{user.currentBranch.address && (
<p className="mt-1 text-sm text-gray-600">
Адрес: {user.currentBranch.address}
</p>
)}
{(user.currentBranch.city || user.currentBranch.country) && (
<p className="text-sm text-gray-600">
{[user.currentBranch.city, user.currentBranch.country]
.filter(Boolean)
.join(", ")}
</p>
)}
</div>
)}
{!user?.currentBranch && !user?.currentCompany && (
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<p className="text-yellow-800">
Вы не привязаны ни к одному филиалу. Обратитесь к
администратору.
</p>
</div>
)}
<button
onClick={handleLogout}
disabled={logoutMutation.isPending}
className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 disabled:opacity-50"
className="px-4 py-2 text-white bg-red-600 rounded-md hover:bg-red-700 disabled:opacity-50"
>
{logoutMutation.isPending ? "Выход..." : "Выйти"}
</button>
+11 -8
View File
@@ -22,8 +22,8 @@ function LoginPage() {
};
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-lg shadow-md">
<div className="flex justify-center items-center min-h-screen bg-gray-50">
<div className="p-8 space-y-8 w-full max-w-md bg-white rounded-lg shadow-md">
<div>
<h2 className="text-3xl font-bold text-center text-gray-900">
Вход в аккаунт
@@ -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"
/>
</div>
@@ -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="••••••••"
/>
</div>
</div>
{loginMutation.isError && (
<div className="text-red-600 text-sm text-center">
<div className="text-sm text-center text-red-600">
Неверный email или пароль
</div>
)}
@@ -78,14 +78,17 @@ function LoginPage() {
<button
type="submit"
disabled={loginMutation.isPending}
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
className="flex justify-center px-4 py-2 w-full text-sm font-medium text-white bg-blue-600 rounded-md border border-transparent shadow-sm hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
>
{loginMutation.isPending ? "Вход..." : "Войти"}
</button>
<div className="text-center text-sm text-gray-600">
<div className="text-sm text-center text-gray-600">
Нет аккаунта?{" "}
<Link to="/register" className="text-blue-600 hover:text-blue-700 font-medium">
<Link
to="/register"
className="font-medium text-blue-600 hover:text-blue-700"
>
Зарегистрироваться
</Link>
</div>
+16 -1
View File
@@ -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;
}
+376
View File
@@ -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` в таблице пользователей позволяет отслеживать, в каком филиале пользователь работает в данный момент
+12 -1
View File
@@ -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 }) => {
+194
View File
@@ -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");
}
});
+93
View File
@@ -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" };
});
+208
View File
@@ -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 }),
}),
}
);
+24
View File
@@ -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;
+18
View File
@@ -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;
-1
View File
@@ -6,4 +6,3 @@ export const roleNameEnum = pgEnum("role_name", [
"director",
"manager",
]);
+22 -2
View File
@@ -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),
}));
-27
View File
@@ -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;
+2 -9
View File
@@ -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;
+13 -13
View File
@@ -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],
}),
}));
+31
View File
@@ -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;
-31
View File
@@ -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;
+42
View File
@@ -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;
+9 -1
View File
@@ -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
+6
View File
@@ -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);
+4 -2
View File
@@ -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 = {
};
},
};
+1 -4
View File
@@ -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);
},
};
+16 -1
View File
@@ -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;
};
+52 -1
View File
@@ -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<User | null> {
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;
},
};
+208
View File
@@ -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);
},
};
+93
View File
@@ -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));
},
};
+266
View File
@@ -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<string | undefined> {
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;
},
};