feat: deploy infrastructure + disk/drive, calendar, presentation, workspaces, onboarding, demo user
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
flutter/
|
||||||
|
venv/
|
||||||
|
webui/node_modules/
|
||||||
|
.git/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
logs/
|
||||||
|
*.md
|
||||||
|
CHANGELOG/
|
||||||
|
docs/
|
||||||
|
tests/
|
||||||
|
tools/
|
||||||
|
template/
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
# VoIdeaAI — Production Environment
|
||||||
|
# Copy to .env on server and fill secrets
|
||||||
|
|
||||||
|
PROJECT_NAME=VoIdeaAI
|
||||||
|
PROJECT_VERSION=1.0.0
|
||||||
|
PROJECT_ENV=production
|
||||||
|
SERVER_HOST=0.0.0.0
|
||||||
|
SERVER_PORT=8020
|
||||||
|
SERVER_EXTERNAL_URL=https://voideaai.ru
|
||||||
|
|
||||||
|
# Database (PostgreSQL on port 5444 — separate from aegisone)
|
||||||
|
DATABASE_URL=postgresql+asyncpg://voidea:DB_PASS@localhost:5444/voidea
|
||||||
|
|
||||||
|
# Redis (port 6380 — separate from aegisone)
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6380
|
||||||
|
REDIS_URL=redis://localhost:6380/0
|
||||||
|
|
||||||
|
# JWT — generate with: openssl rand -hex 64
|
||||||
|
JWT_SECRET_KEY=
|
||||||
|
JWT_RESET_SECRET_KEY=
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
JWT_REFRESH_TOKEN_EXPIRE_DAYS=30
|
||||||
|
|
||||||
|
# AI
|
||||||
|
OPENAI_API_KEY=
|
||||||
|
AI_YANDEX_KEY=
|
||||||
|
AI_YANDEX_FOLDER_ID=
|
||||||
|
AI_GIGACHAT_CLIENT_ID=
|
||||||
|
AI_GIGACHAT_SECRET=
|
||||||
|
AI_FALLBACK_MODEL=yandex_gpt
|
||||||
|
|
||||||
|
# OAuth
|
||||||
|
OAUTH_YANDEX_ID=
|
||||||
|
OAUTH_YANDEX_SECRET=
|
||||||
|
OAUTH_GOOGLE_ID=
|
||||||
|
OAUTH_GOOGLE_SECRET=
|
||||||
|
OAUTH_GOOGLE_REDIRECT_URI=https://voideaai.ru/auth/google/callback
|
||||||
|
OAUTH_APPLE_ID=
|
||||||
|
OAUTH_APPLE_SECRET=
|
||||||
|
OAUTH_APPLE_REDIRECT_URI=https://voideaai.ru/auth/apple/callback
|
||||||
|
|
||||||
|
# Telegram Bot
|
||||||
|
TELEGRAM_BOT_TOKEN=
|
||||||
|
|
||||||
|
# Email
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASS=
|
||||||
|
|
||||||
|
# Rate Limiting
|
||||||
|
RATE_LIMIT_ENABLED=true
|
||||||
|
RATE_LIMIT_DEFAULT=60/minute
|
||||||
|
RATE_LIMIT_AUTH=10/minute
|
||||||
|
|
||||||
|
# Security — generate with: openssl rand -hex 32
|
||||||
|
ENCRYPTION_KEY=
|
||||||
|
|
||||||
|
# System Owner
|
||||||
|
SYSTEM_OWNER_EMAIL=
|
||||||
|
|
||||||
|
# Social Networks
|
||||||
|
SOCIAL_TELEGRAM=voideaai
|
||||||
|
SOCIAL_VK=voideaai
|
||||||
|
SOCIAL_YOUTUBE=voideaai
|
||||||
|
SOCIAL_TIKTOK=voideaai
|
||||||
|
PROJECT_SLOGAN=VoIdeaAI — идеи рождаются вслух, решения приходят мгновенно!
|
||||||
|
|
||||||
|
# Tariffs
|
||||||
|
TARIFFS_ENABLED=false
|
||||||
|
TARIFFS_FREE_CODE=free
|
||||||
|
|
||||||
|
# Push
|
||||||
|
FCM_SERVER_KEY=
|
||||||
|
APNS_KEY_ID=
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
DEBUG_MODE_RETENTION_DAYS=14
|
||||||
|
DEBUG_MODE_MAX_SIZE_MB=500
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# Внимание: на сервере работает второй проект — VoIdeaAI
|
||||||
|
|
||||||
|
## Дата развёртывания
|
||||||
|
2026-05-19
|
||||||
|
|
||||||
|
## Сервер
|
||||||
|
81.177.141.34 — единый VPS для двух независимых проектов.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Проекты на сервере
|
||||||
|
|
||||||
|
### Проект A: aegisone
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Порты | 8000 (FastAPI), 8080/9000 (PHP), 5432 (PostgreSQL) |
|
||||||
|
| Директория | `/opt/projects/aegisone-php/`, `/opt/projects/aegisone-py/` |
|
||||||
|
| Домен | aegisone.ru, service.aegisone.ru |
|
||||||
|
| Тип | PHP + Python FastAPI |
|
||||||
|
|
||||||
|
### Проект B: VoIdeaAI
|
||||||
|
| Параметр | Значение |
|
||||||
|
|----------|----------|
|
||||||
|
| Порты | **8020** (FastAPI), **5444** (PostgreSQL), **6380** (Redis) |
|
||||||
|
| Директория | `/opt/projects/voidea/` |
|
||||||
|
| Домен | voideaai.ru, git.voideaai.ru (через nginx-proxy) |
|
||||||
|
| Gitea | порт 3000, PostgreSQL 5433, git.voideaai.ru |
|
||||||
|
| Тип | Python FastAPI + React Frontend |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Критические правила
|
||||||
|
|
||||||
|
### НЕ занимать порты VoIdeaAI
|
||||||
|
```
|
||||||
|
8020 — FastAPI app
|
||||||
|
5444 — PostgreSQL
|
||||||
|
6380 — Redis
|
||||||
|
5433 — Gitea PostgreSQL
|
||||||
|
3000 — Gitea web
|
||||||
|
```
|
||||||
|
|
||||||
|
### nginx-proxy общий
|
||||||
|
- Конфиги: `/opt/projects/nginx-proxy/conf.d/`
|
||||||
|
- Файл VoIdea: `voideaai.ru.conf`
|
||||||
|
- Файл Gitea: `git.voideaai.ru.conf`
|
||||||
|
|
||||||
|
**Перед рестартом nginx — всегда проверять:**
|
||||||
|
```bash
|
||||||
|
docker compose -p nginx-proxy exec nginx nginx -t
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
- **Все контейнеры используют `network_mode: host`** (bridge не работает на этом VPS)
|
||||||
|
- **Docker build** требует `network: host` в конфиге сборки
|
||||||
|
- `docker system prune -af` удалит build cache ВСЕХ проектов — не запускать во время сборки
|
||||||
|
- `docker compose -p voidea` — префикс для команд VoIdea
|
||||||
|
- `docker compose -p gitea` — префикс для команд Gitea
|
||||||
|
- `docker compose -p nginx-proxy` — префикс для nginx
|
||||||
|
|
||||||
|
### Диск
|
||||||
|
- 20GB всего, ~13GB свободно (на момент деплоя)
|
||||||
|
- Перед крупными операциями: `df -h /`
|
||||||
|
- При нехватке места: `docker builder prune -af`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Команды для проверки состояния VoIdeaAI
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус
|
||||||
|
docker compose -p voidea ps
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
docker compose -p voidea logs --tail=20 app
|
||||||
|
|
||||||
|
# Проверка здоровья
|
||||||
|
curl -s http://localhost:8020/health
|
||||||
|
curl -s http://localhost:8020/api/v1/health
|
||||||
|
|
||||||
|
# Состояние БД
|
||||||
|
docker exec voidea-db-1 psql -U voidea -h localhost -p 5444 -d voidea -c "\dt"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Что делать при проблемах с VoIdeaAI
|
||||||
|
|
||||||
|
1. **Не работает voideaai.ru** — проверить nginx: `nginx -t`, проверить конфиг
|
||||||
|
2. **VoIdea API не отвечает** — `docker compose -p voidea logs app`
|
||||||
|
3. **VoIdea не может подключиться к БД** — проверить `localhost:5444`, логи `docker compose -p voidea logs db`
|
||||||
|
4. **Gitea не открывается** — проверить порт 3000: `curl -s http://localhost:3000`
|
||||||
|
|
||||||
|
Если проблема серьёзная — остановить VoIdea без ущерба для aegisone:
|
||||||
|
```bash
|
||||||
|
docker compose -p voidea down
|
||||||
|
# aegisone продолжит работу независимо
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Контакты
|
||||||
|
|
||||||
|
- По вопросам VoIdeaAI — обратиться к разработчику, ведущему проект VoIdea
|
||||||
|
- Проекты независимы, команды разные
|
||||||
|
- Изменения в nginx-proxy влияют на оба проекта — согласовывать
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Создан: 2026-05-19*
|
||||||
|
*Не редактировать вручную без необходимости*
|
||||||
@@ -7,10 +7,19 @@ RUN npm run build
|
|||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
gcc \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
COPY --from=frontend-builder /build/dist /app/webui/dist
|
COPY --from=frontend-builder /build/dist /app/webui/dist
|
||||||
|
|
||||||
RUN mkdir -p /app/logs
|
RUN mkdir -p /app/logs
|
||||||
|
|
||||||
EXPOSE 8020
|
EXPOSE 8020
|
||||||
|
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8020"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8020"]
|
||||||
+289
@@ -0,0 +1,289 @@
|
|||||||
|
# VPS Infrastructure Guide for AI Agents
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This document describes the VPS infrastructure at **81.177.141.34** to help AI agents migrate or deploy new projects (e.g., `voideaai.ru`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. VPS Specifications
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|---|---|
|
||||||
|
| IP | 81.177.141.34 |
|
||||||
|
| User | `angel` |
|
||||||
|
| Password | `.-SHGWa_` |
|
||||||
|
| Sudo | passwordless via `/etc/sudoers.d/angel` |
|
||||||
|
| SSH key | added to `~/.ssh/authorized_keys` |
|
||||||
|
| OS | Ubuntu 24.04 (Noble) |
|
||||||
|
| Kernel | OpenVZ/LXC container — limited kernel modules |
|
||||||
|
| Network interface | `venet0` (no bridge NAT support) |
|
||||||
|
| Disk | 20GB total, ~13GB available |
|
||||||
|
| Docker | 29.5.0, iptables in nft mode (`iptables-nft`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Docker Constraints
|
||||||
|
|
||||||
|
### Critical: `network_mode: host` only
|
||||||
|
|
||||||
|
Bridge networking (`network_mode: bridge`) **does not work** on this VPS. The OpenVZ/LXC kernel does not support masquerade for bridge interfaces through `venet0`. All containers **must** use:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
network_mode: host
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker daemon config (`/etc/docker/daemon.json`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"dns": ["8.8.8.8", "8.8.4.4"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Build constraint
|
||||||
|
|
||||||
|
All Docker builds require:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
build:
|
||||||
|
network: host
|
||||||
|
```
|
||||||
|
|
||||||
|
Without this, `apt-get` and `pip` cannot download packages (no bridge NAT).
|
||||||
|
|
||||||
|
### Docker Hub connectivity issues
|
||||||
|
|
||||||
|
Pulling large images (>100MB) from Docker Hub may time out (`failed to copy: read tcp... connection timed out`). Smaller images (nginx:alpine, postgres:16-alpine) work. Gitea and Portainer images **cannot be pulled** reliably.
|
||||||
|
|
||||||
|
**Solutions attempted:**
|
||||||
|
- Retry with `--platform linux/amd64`
|
||||||
|
- Increase `max-download-attempts` (not supported in Docker 29.5)
|
||||||
|
- Clean build cache with `docker builder prune -af`
|
||||||
|
- All fail on large image layers
|
||||||
|
|
||||||
|
**Workaround:** Pull images locally and SCP (`docker save → scp → docker load`), or use a registry mirror.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Port Allocation (all on host)
|
||||||
|
|
||||||
|
| Port | Service | Container |
|
||||||
|
|---|---|---|
|
||||||
|
| 80 | nginx-proxy HTTP | `nginx-proxy` |
|
||||||
|
| 443 | nginx-proxy HTTPS | `nginx-proxy` |
|
||||||
|
| 8080 | PHP nginx (aegisone.ru) | `aegisone-php-nginx` |
|
||||||
|
| 9000 | PHP-FPM | `aegisone-php-fpm` |
|
||||||
|
| 8000 | FastAPI (service.aegisone.ru) | `aegisone-app` |
|
||||||
|
| 5432 | PostgreSQL (shared, aegisone) | `aegisone-postgres` |
|
||||||
|
| 5433 | PostgreSQL (Gitea) | `gitea-db` (planned) |
|
||||||
|
| 3000 | Gitea | `gitea` (planned) |
|
||||||
|
| 9001 | Portainer HTTP | `portainer` (planned) |
|
||||||
|
| 9443 | Portainer HTTPS | `portainer` (planned) |
|
||||||
|
|
||||||
|
### Address resolution between containers
|
||||||
|
|
||||||
|
Since all containers use `network_mode: host`, they communicate via `localhost:PORT`. **Docker DNS names do not resolve.** In `nginx.conf` and app configs, use:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
proxy_pass http://localhost:8080; # NOT container_name:port
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
/opt/projects/
|
||||||
|
nginx-proxy/ # Reverse proxy (nginx + certbot)
|
||||||
|
docker-compose.yml
|
||||||
|
nginx.conf
|
||||||
|
conf.d/ # Per-domain configs
|
||||||
|
certs/ # SSL certificates
|
||||||
|
html/ # Webroot for certbot
|
||||||
|
aegisone-php/ # PHP frontend (aegisone.ru)
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
nginx.conf # PHP nginx config (listen 8080)
|
||||||
|
src/ # PHP source files
|
||||||
|
aegisone-py/ # FastAPI backend (service.aegisone.ru)
|
||||||
|
docker-compose.yml
|
||||||
|
Dockerfile
|
||||||
|
app/ # Python source
|
||||||
|
sql/ # DB schema
|
||||||
|
gitea/ # Git hosting (planned)
|
||||||
|
docker-compose.yml
|
||||||
|
portainer/ # Docker UI (planned)
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. nginx-proxy Configuration Pattern
|
||||||
|
|
||||||
|
### Global config (`nginx.conf`)
|
||||||
|
- `resolver 127.0.0.11` — not used in host mode, kept for compatibility
|
||||||
|
- SSL termination handled by nginx-proxy
|
||||||
|
- Upstream protocols are HTTP (nginx-proxy terminates SSL and proxies via HTTP)
|
||||||
|
|
||||||
|
### Conf.d file structure
|
||||||
|
Each domain has one `.conf` file with two server blocks:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# HTTP (no redirect — temporary, until LE certs)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name example.ru;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:PORT;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# HTTPS (self-signed, for future LE)
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name example.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/live/aegisone.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/live/aegisone.ru/privkey.pem;
|
||||||
|
|
||||||
|
# ... same location block
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: HTTP→HTTPS redirects (`return 301 https://...`) caused **redirect loops** externally due to provider/proxy intercepting port 443. If you add them, test thoroughly.
|
||||||
|
|
||||||
|
### SSL certificates
|
||||||
|
Currently using self-signed certs generated via:
|
||||||
|
```bash
|
||||||
|
sudo openssl req -x509 -nodes -days 30 -newkey rsa:2048 \
|
||||||
|
-keyout /opt/projects/nginx-proxy/certs/live/aegisone.ru/privkey.pem \
|
||||||
|
-out /opt/projects/nginx-proxy/certs/live/aegisone.ru/fullchain.pem \
|
||||||
|
-subj '/CN=aegisone.ru'
|
||||||
|
```
|
||||||
|
|
||||||
|
For Let's Encrypt:
|
||||||
|
```bash
|
||||||
|
cd /opt/projects/nginx-proxy
|
||||||
|
docker compose run --rm certbot certonly --webroot \
|
||||||
|
--webroot-path=/var/www/certbot \
|
||||||
|
-d example.ru \
|
||||||
|
--email admin@example.ru \
|
||||||
|
--agree-tos --non-interactive
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Deployment Workflow
|
||||||
|
|
||||||
|
### Initial setup
|
||||||
|
1. Prepare files locally (this repo)
|
||||||
|
2. Pack into tarball: `tar czf project.tar.gz .`
|
||||||
|
3. SCP to VPS: `scp project.tar.gz angel@81.177.141.34:/home/angel/`
|
||||||
|
4. SSH and extract: `ssh angel@81.177.141.34 "tar xzf project.tar.gz -C /opt/projects/project-name/"`
|
||||||
|
5. Build and start: `ssh angel@81.177.141.34 "cd /opt/projects/project-name && docker compose up -d --build"`
|
||||||
|
|
||||||
|
### Updating a running project
|
||||||
|
1. Edit files locally
|
||||||
|
2. SCP changed files to `/home/angel/` on VPS
|
||||||
|
3. SSH and copy to correct location: `sudo cp /home/angel/file /opt/projects/project-name/`
|
||||||
|
4. Restart container: `docker compose -p project-name restart service-name`
|
||||||
|
|
||||||
|
### Copying files into a running container (alternative to rebuild)
|
||||||
|
```bash
|
||||||
|
docker cp /home/angel/file.py container-name:/app/path/file.py
|
||||||
|
docker compose -p project-name restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Database
|
||||||
|
|
||||||
|
### PostgreSQL (aegisone shared DB)
|
||||||
|
- Host: `localhost:5432`
|
||||||
|
- User: `aegisone`
|
||||||
|
- Password: `aegisone_pass`
|
||||||
|
- Database: `aegisone`
|
||||||
|
- Container: `aegisone-postgres` (postgres:16-alpine)
|
||||||
|
|
||||||
|
### Schema initialization
|
||||||
|
SQL files in `/opt/projects/aegisone-py/sql/`:
|
||||||
|
- `schema_postgresql.sql` — full schema
|
||||||
|
- `seed_data.sql` — seed data
|
||||||
|
|
||||||
|
Init scripts run only on first volume creation. To add missing tables later:
|
||||||
|
```bash
|
||||||
|
docker exec -i aegisone-postgres psql -U aegisone -d aegisone < /opt/projects/aegisone-py/sql/schema_postgresql.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key schema pattern
|
||||||
|
- All tables use `BOOLEAN` for `is_active` (not integer)
|
||||||
|
- Timestamps use `TIMESTAMP NOT NULL DEFAULT NOW()`
|
||||||
|
- `updated_at` triggers via `update_updated_at_column()` function
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Useful Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check running containers
|
||||||
|
docker ps -a
|
||||||
|
|
||||||
|
# Check compose projects
|
||||||
|
docker compose ls
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker logs container-name --tail 50
|
||||||
|
|
||||||
|
# Restart service
|
||||||
|
docker compose -p project-name restart service-name
|
||||||
|
|
||||||
|
# Rebuild and restart
|
||||||
|
docker compose -p project-name up -d --build
|
||||||
|
|
||||||
|
# Clean build cache
|
||||||
|
docker builder prune -af
|
||||||
|
|
||||||
|
# Test nginx config
|
||||||
|
docker compose -p nginx-proxy exec nginx nginx -t
|
||||||
|
|
||||||
|
# Reload nginx
|
||||||
|
docker compose -p nginx-proxy exec nginx nginx -s reload
|
||||||
|
|
||||||
|
# Check listening ports
|
||||||
|
ss -tlnp | grep -E ':(80|443|8080|8000|9000|5432)'
|
||||||
|
|
||||||
|
# Check disk
|
||||||
|
df -h /
|
||||||
|
docker system df
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Adding a New Project (e.g., voideaai.ru)
|
||||||
|
|
||||||
|
### Steps:
|
||||||
|
1. Create directory: `mkdir -p /opt/projects/voideaai`
|
||||||
|
2. Create `docker-compose.yml` with `network_mode: host` and `build.network: host`
|
||||||
|
3. Create `Dockerfile` (use `--only-binary :all:` for pip if avoiding apt)
|
||||||
|
4. Create nginx-proxy config: `nginx/conf.d/voideaai.conf` (listen 80, proxy_pass localhost:PORT)
|
||||||
|
5. Generate cert if needed
|
||||||
|
6. Restart nginx-proxy: `docker compose -p nginx-proxy restart nginx`
|
||||||
|
7. Start project: `cd /opt/projects/voideaai && docker compose up -d --build`
|
||||||
|
|
||||||
|
### Port allocation for new project
|
||||||
|
Check existing ports first (`ss -tlnp`). Choose an unused port for the app and add it to the nginx-proxy config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. Known Issues
|
||||||
|
|
||||||
|
1. **Docker Hub timeouts on large images** — consider pulling on local machine and transferring with `docker save`/`docker load`
|
||||||
|
2. **HTTPS redirect loops externally** — port 443 may be intercepted by provider/proxy; test HTTP first
|
||||||
|
3. **Bridge networking doesn't work** — all containers must use `network_mode: host`
|
||||||
|
4. **Docker build needs `network: host`** — without it, apt-get/pip fail
|
||||||
|
5. **Config files with `$` in content** — when editing via SSH from PowerShell, `$variables` get expanded; use SCP (write locally, then copy)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Add workspace and workspace_memberships tables
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005
|
||||||
|
Create Date: 2026-05-15
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "006"
|
||||||
|
down_revision: Union[str, None] = "005"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"workspaces",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("name", sa.String(255), nullable=False),
|
||||||
|
sa.Column("type", sa.String(20), nullable=False, server_default=sa.text("'personal'"), index=True),
|
||||||
|
sa.Column("owner_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("description", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"workspace_memberships",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("workspace_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("role", sa.String(20), nullable=False, server_default=sa.text("'member'")),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("workspace_memberships")
|
||||||
|
op.drop_table("workspaces")
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""Add collaboration tables: votes, comments, activities, notifications
|
||||||
|
|
||||||
|
Revision ID: 007
|
||||||
|
Revises: 006
|
||||||
|
Create Date: 2026-05-15
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "007"
|
||||||
|
down_revision: Union[str, None] = "006"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"idea_votes",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("idea_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("ideas.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("vote", sa.String(4), nullable=False),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint("idea_id", "user_id", name="uq_idea_user_vote"),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"idea_comments",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("idea_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("ideas.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("content", sa.Text(), nullable=False),
|
||||||
|
sa.Column("parent_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("idea_comments.id", ondelete="CASCADE"), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"idea_activities",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("idea_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("ideas.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True),
|
||||||
|
sa.Column("action", sa.String(50), nullable=False),
|
||||||
|
sa.Column("details", postgresql.JSONB(), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"notifications",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column("user_id", postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True),
|
||||||
|
sa.Column("type", sa.String(50), nullable=False, index=True),
|
||||||
|
sa.Column("title", sa.String(255), nullable=False),
|
||||||
|
sa.Column("body", sa.Text(), nullable=False),
|
||||||
|
sa.Column("link", sa.String(512), nullable=True),
|
||||||
|
sa.Column("is_read", sa.Boolean(), nullable=False, server_default=sa.text("false"), index=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("notifications")
|
||||||
|
op.drop_table("idea_activities")
|
||||||
|
op.drop_table("idea_comments")
|
||||||
|
op.drop_table("idea_votes")
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
"""Add funnel_status column to ideas table
|
||||||
|
|
||||||
|
Revision ID: 008
|
||||||
|
Revises: 007
|
||||||
|
Create Date: 2026-05-15
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "008"
|
||||||
|
down_revision: Union[str, None] = "007"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"ideas",
|
||||||
|
sa.Column("funnel_status", sa.String(20), nullable=True, index=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("ideas", "funnel_status")
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
"""Add calendar fields to users table
|
||||||
|
|
||||||
|
Revision ID: 009
|
||||||
|
Revises: 008
|
||||||
|
Create Date: 2026-05-15
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
revision: str = "009"
|
||||||
|
down_revision: Union[str, None] = "008"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("calendar_provider", sa.String(20), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column("calendar_credentials", sa.Text(), nullable=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "calendar_credentials")
|
||||||
|
op.drop_column("users", "calendar_provider")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
"""Add disk_providers JSONB field to users.
|
||||||
|
|
||||||
|
Revision ID: 010_add_disk_fields
|
||||||
|
Revises: 009_add_calendar_fields
|
||||||
|
Create Date: 2026-05-15
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "010_add_disk_fields"
|
||||||
|
down_revision: Union[str, None] = "009_add_calendar_fields"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"users",
|
||||||
|
sa.Column(
|
||||||
|
"disk_providers",
|
||||||
|
postgresql.JSONB,
|
||||||
|
nullable=True,
|
||||||
|
default=None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("users", "disk_providers")
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"""MetaAgent — Orchestrator agent for VoIdea.
|
||||||
|
|
||||||
|
Configurable from admin panel:
|
||||||
|
- cron_schedule: str (default "0 9 * * 1,4")
|
||||||
|
- weights: dict[agent_name, float]
|
||||||
|
- sources_count: int (default 10)
|
||||||
|
- auto_create: bool (default True)
|
||||||
|
|
||||||
|
Runs a subset of agents based on weights and collects reports.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.agents.base import AgentResult, AgentStatus, AgentTrigger, BaseAgent
|
||||||
|
from app.agents.registry import registry
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_CONFIG: dict[str, Any] = {
|
||||||
|
"cron_schedule": "0 9 * * 1,4",
|
||||||
|
"weights": {},
|
||||||
|
"sources_count": 10,
|
||||||
|
"auto_create": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class MetaAgent(BaseAgent):
|
||||||
|
"""Orchestrator agent that coordinates other agents."""
|
||||||
|
|
||||||
|
name = "meta_agent"
|
||||||
|
version = "1.0.0"
|
||||||
|
description = "Orchestrates other agents based on configurable schedule and weights"
|
||||||
|
triggers = [
|
||||||
|
AgentTrigger.MANUAL,
|
||||||
|
AgentTrigger.CRON,
|
||||||
|
AgentTrigger.API,
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, config: dict[str, Any] | None = None):
|
||||||
|
super().__init__()
|
||||||
|
self._config = {**DEFAULT_CONFIG, **(config or {})}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config(self) -> dict[str, Any]:
|
||||||
|
return self._config
|
||||||
|
|
||||||
|
@config.setter
|
||||||
|
def config(self, value: dict[str, Any]) -> None:
|
||||||
|
self._config = {**DEFAULT_CONFIG, **value}
|
||||||
|
|
||||||
|
async def run(self, context: dict[str, Any] | None = None) -> AgentResult:
|
||||||
|
"""Execute meta-agent orchestration.
|
||||||
|
|
||||||
|
Context can contain:
|
||||||
|
- action: str (orchestrate | report | status) — default "orchestrate"
|
||||||
|
- target_agents: list[str] — subset of agents to run (default: all)
|
||||||
|
- sources_count: int — override config
|
||||||
|
"""
|
||||||
|
await self.set_running("orchestration")
|
||||||
|
|
||||||
|
try:
|
||||||
|
action = (context or {}).get("action", "orchestrate")
|
||||||
|
|
||||||
|
if action == "status":
|
||||||
|
return await self._get_status()
|
||||||
|
elif action == "report":
|
||||||
|
return await self._generate_report()
|
||||||
|
else:
|
||||||
|
return await self._orchestrate(context or {})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
await self.set_error(str(e))
|
||||||
|
return AgentResult(
|
||||||
|
success=False,
|
||||||
|
message=f"MetaAgent failed: {str(e)}",
|
||||||
|
errors=[str(e)],
|
||||||
|
)
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def _orchestrate(self, context: dict[str, Any]) -> AgentResult:
|
||||||
|
"""Run target agents and collect results."""
|
||||||
|
target_agents: list[str] | None = context.get("target_agents")
|
||||||
|
sources_count = context.get("sources_count", self._config["sources_count"])
|
||||||
|
weights = self._config.get("weights", {})
|
||||||
|
|
||||||
|
all_agents = registry.list_agents()
|
||||||
|
if not all_agents:
|
||||||
|
await self.set_idle()
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="No agents registered",
|
||||||
|
data={"orchestrated": 0},
|
||||||
|
)
|
||||||
|
|
||||||
|
candidates = []
|
||||||
|
for a in all_agents:
|
||||||
|
name = a["name"]
|
||||||
|
if name == self.name:
|
||||||
|
continue
|
||||||
|
weight = weights.get(name, 1.0)
|
||||||
|
if weight <= 0:
|
||||||
|
continue
|
||||||
|
candidates.append((name, weight))
|
||||||
|
|
||||||
|
if not candidates:
|
||||||
|
candidates = [(a["name"], 1.0) for a in all_agents if a["name"] != self.name]
|
||||||
|
|
||||||
|
if target_agents:
|
||||||
|
candidates = [c for c in candidates if c[0] in target_agents]
|
||||||
|
|
||||||
|
max_sources = min(sources_count, len(candidates))
|
||||||
|
selected = candidates[:max_sources]
|
||||||
|
|
||||||
|
results: dict[str, Any] = {}
|
||||||
|
for name, _weight in selected:
|
||||||
|
try:
|
||||||
|
result = await registry.run_agent(name, {"trigger": "meta_agent"})
|
||||||
|
results[name] = {
|
||||||
|
"success": result.success,
|
||||||
|
"message": result.message,
|
||||||
|
"duration_ms": result.duration_ms,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
results[name] = {
|
||||||
|
"success": False,
|
||||||
|
"message": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
success_count = sum(1 for r in results.values() if r["success"])
|
||||||
|
total_duration = sum(r.get("duration_ms", 0) for r in results.values())
|
||||||
|
|
||||||
|
await self.set_idle()
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Orchestrated {len(results)} agents ({success_count} ok)",
|
||||||
|
data={
|
||||||
|
"orchestrated": len(results),
|
||||||
|
"success_count": success_count,
|
||||||
|
"agents": results,
|
||||||
|
"total_duration_ms": total_duration,
|
||||||
|
"config": {
|
||||||
|
"cron_schedule": self._config["cron_schedule"],
|
||||||
|
"sources_count": sources_count,
|
||||||
|
"auto_create": self._config["auto_create"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _generate_report(self) -> AgentResult:
|
||||||
|
"""Generate summary report of all agent statuses."""
|
||||||
|
all_agents = registry.list_agents()
|
||||||
|
metrics = registry.get_metrics_all()
|
||||||
|
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message=f"Report: {len(all_agents)} agents",
|
||||||
|
data={
|
||||||
|
"agents": all_agents,
|
||||||
|
"metrics": metrics,
|
||||||
|
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _get_status(self) -> AgentResult:
|
||||||
|
return AgentResult(
|
||||||
|
success=True,
|
||||||
|
message="Meta-agent operational",
|
||||||
|
data={
|
||||||
|
"status": self.status.value,
|
||||||
|
"config": self._config,
|
||||||
|
"last_run": self._last_run.isoformat() if self._last_run else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -15,6 +15,7 @@ from app.agents.ui_test_agent import UITestAgent
|
|||||||
from app.agents.rollout_agent import RolloutAgent
|
from app.agents.rollout_agent import RolloutAgent
|
||||||
from app.agents.conductor_agent import ConductorAgent
|
from app.agents.conductor_agent import ConductorAgent
|
||||||
from app.agents.supervisor_agent import SupervisorAgent
|
from app.agents.supervisor_agent import SupervisorAgent
|
||||||
|
from app.agents.meta_agent import MetaAgent
|
||||||
|
|
||||||
|
|
||||||
class AgentRegistry:
|
class AgentRegistry:
|
||||||
@@ -36,6 +37,7 @@ class AgentRegistry:
|
|||||||
self.register(RolloutAgent())
|
self.register(RolloutAgent())
|
||||||
self.register(ConductorAgent())
|
self.register(ConductorAgent())
|
||||||
self.register(SupervisorAgent())
|
self.register(SupervisorAgent())
|
||||||
|
self.register(MetaAgent())
|
||||||
|
|
||||||
def register(self, agent: BaseAgent) -> None:
|
def register(self, agent: BaseAgent) -> None:
|
||||||
if not agent.name:
|
if not agent.name:
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ from app.api.v1.voice import router as voice_router
|
|||||||
from app.api.v1.config import router as config_router
|
from app.api.v1.config import router as config_router
|
||||||
from app.api.v1.feedback import router as feedback_router
|
from app.api.v1.feedback import router as feedback_router
|
||||||
from app.api.v1.tariffs import router as tariffs_router
|
from app.api.v1.tariffs import router as tariffs_router
|
||||||
|
from app.api.v1.workspaces import router as workspaces_router
|
||||||
|
from app.api.v1.collaboration import router as collaboration_router
|
||||||
|
from app.api.v1.calendar import router as calendar_router
|
||||||
|
|
||||||
api_v1_router = APIRouter(prefix="/api/v1")
|
api_v1_router = APIRouter(prefix="/api/v1")
|
||||||
|
|
||||||
@@ -25,5 +28,8 @@ api_v1_router.include_router(voice_router, prefix="/voice", tags=["voice"])
|
|||||||
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
|
api_v1_router.include_router(config_router, prefix="/config", tags=["config"])
|
||||||
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
|
api_v1_router.include_router(feedback_router, prefix="/feedback", tags=["feedback"])
|
||||||
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
|
api_v1_router.include_router(tariffs_router, prefix="/tariffs", tags=["tariffs"])
|
||||||
|
api_v1_router.include_router(workspaces_router, prefix="/workspaces", tags=["workspaces"])
|
||||||
|
api_v1_router.include_router(collaboration_router, prefix="", tags=["collaboration"])
|
||||||
|
api_v1_router.include_router(calendar_router, prefix="", tags=["calendar"])
|
||||||
|
|
||||||
__all__ = ["api_v1_router"]
|
__all__ = ["api_v1_router"]
|
||||||
|
|||||||
@@ -39,6 +39,8 @@ from app.schemas.admin import (
|
|||||||
SystemInfoResponse,
|
SystemInfoResponse,
|
||||||
UserAdminUpdate,
|
UserAdminUpdate,
|
||||||
)
|
)
|
||||||
|
from app.agents.base import AgentResult
|
||||||
|
from app.schemas.agent import AgentRunRequest
|
||||||
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
|
from app.schemas.bot import BotCommandResponse, BotCommandUpdate
|
||||||
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
|
from app.schemas.feedback import FeedbackResponse, FeedbackUpdate
|
||||||
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
|
from app.schemas.tariff import TariffPlanCreate, TariffPlanResponse, TariffPlanUpdate, UserSubscriptionResponse
|
||||||
@@ -101,6 +103,7 @@ def _agent_to_response(a: AgentConfig) -> AgentInfoResponse:
|
|||||||
description=getattr(a, "description", ""),
|
description=getattr(a, "description", ""),
|
||||||
is_enabled=a.is_enabled,
|
is_enabled=a.is_enabled,
|
||||||
version=a.version,
|
version=a.version,
|
||||||
|
config=a.config,
|
||||||
last_run_at=a.last_run_at,
|
last_run_at=a.last_run_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -191,12 +194,38 @@ async def update_agent(
|
|||||||
agent.description = body.description
|
agent.description = body.description
|
||||||
if body.is_enabled is not None:
|
if body.is_enabled is not None:
|
||||||
agent.is_enabled = body.is_enabled
|
agent.is_enabled = body.is_enabled
|
||||||
|
if body.config is not None:
|
||||||
|
agent.config = body.config
|
||||||
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(agent)
|
await db.refresh(agent)
|
||||||
return _agent_to_response(agent)
|
return _agent_to_response(agent)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/{agent_name}/run", response_model=AgentResult)
|
||||||
|
async def run_agent(
|
||||||
|
agent_name: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
body: AgentRunRequest | None = None,
|
||||||
|
):
|
||||||
|
from app.agents.registry import registry
|
||||||
|
|
||||||
|
context = body.model_dump() if body else {}
|
||||||
|
result = await registry.run_agent(agent_name, context)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/agents/run-all", response_model=dict[str, AgentResult])
|
||||||
|
async def run_all_agents(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
body: AgentRunRequest | None = None,
|
||||||
|
):
|
||||||
|
from app.agents.registry import registry
|
||||||
|
|
||||||
|
context = body.model_dump() if body else {}
|
||||||
|
return await registry.run_all(context)
|
||||||
|
|
||||||
|
|
||||||
# ── Logs ──
|
# ── Logs ──
|
||||||
|
|
||||||
@router.get("/logs", response_model=list[LogEntryResponse])
|
@router.get("/logs", response_model=list[LogEntryResponse])
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Calendar API routes for VoIdea."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.calendar_service import CALENDAR_PROVIDERS, CalendarService
|
||||||
|
from app.services.idea_service import IdeaService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ideas/{idea_id}/calendar")
|
||||||
|
async def add_idea_to_calendar(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Create a calendar event from an idea."""
|
||||||
|
idea_svc = IdeaService(db)
|
||||||
|
idea = await idea_svc.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
cal_svc = CalendarService(db)
|
||||||
|
success = await cal_svc.create_idea_event(user, idea.title, idea.content)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Calendar not configured. Connect a calendar in Settings > Integrations.",
|
||||||
|
)
|
||||||
|
return {"success": True, "message": "Event created in calendar"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calendar/providers")
|
||||||
|
async def list_calendar_providers():
|
||||||
|
return {"providers": list(CALENDAR_PROVIDERS)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/calendar/status")
|
||||||
|
async def calendar_status(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cal_svc = CalendarService(db)
|
||||||
|
creds = await cal_svc.get_calendar_credentials(user)
|
||||||
|
return {
|
||||||
|
"connected": creds is not None,
|
||||||
|
"provider": user.calendar_provider if hasattr(user, "calendar_provider") else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/calendar/disconnect")
|
||||||
|
async def disconnect_calendar(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
cal_svc = CalendarService(db)
|
||||||
|
await cal_svc.disconnect_calendar(user)
|
||||||
|
return {"success": True}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"""Collaboration API routes for VoIdea.
|
||||||
|
|
||||||
|
Voting, comments, activity log, and notifications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user, get_db
|
||||||
|
from app.models.collaboration import Notification
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.collaboration import (
|
||||||
|
ActivityResponse,
|
||||||
|
CommentCreate,
|
||||||
|
CommentResponse,
|
||||||
|
NotificationResponse,
|
||||||
|
NotificationUnreadCount,
|
||||||
|
VoteCount,
|
||||||
|
VoteResponse,
|
||||||
|
VoteToggleRequest,
|
||||||
|
)
|
||||||
|
from app.services.collaboration_service import CollaborationService
|
||||||
|
from app.services.idea_service import IdeaService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ideas/{idea_id}/vote", response_model=VoteCount)
|
||||||
|
async def toggle_vote(
|
||||||
|
idea_id: str,
|
||||||
|
body: VoteToggleRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
idea_svc = IdeaService(db)
|
||||||
|
idea = await idea_svc.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
await svc.toggle_vote(UUID(idea_id), user.id, body.vote)
|
||||||
|
counts = await svc.get_vote_counts(UUID(idea_id))
|
||||||
|
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
|
||||||
|
return VoteCount(**counts, user_vote=user_vote)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ideas/{idea_id}/votes", response_model=VoteCount)
|
||||||
|
async def get_votes(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
idea_svc = IdeaService(db)
|
||||||
|
idea = await idea_svc.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
counts = await svc.get_vote_counts(UUID(idea_id))
|
||||||
|
user_vote = await svc.get_user_vote(UUID(idea_id), user.id)
|
||||||
|
return VoteCount(**counts, user_vote=user_vote)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Comments ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ideas/{idea_id}/comments", response_model=list[CommentResponse])
|
||||||
|
async def list_comments(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
return await svc.list_comments(UUID(idea_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ideas/{idea_id}/comments", response_model=CommentResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_comment(
|
||||||
|
idea_id: str,
|
||||||
|
body: CommentCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
parent_id = UUID(body.parent_id) if body.parent_id else None
|
||||||
|
c = await svc.create_comment(UUID(idea_id), user.id, body.content, parent_id)
|
||||||
|
|
||||||
|
await svc.log_activity(
|
||||||
|
UUID(idea_id), user.id, "comment",
|
||||||
|
{"comment_id": str(c.id), "preview": body.content[:100]},
|
||||||
|
)
|
||||||
|
|
||||||
|
return CommentResponse(
|
||||||
|
id=str(c.id),
|
||||||
|
idea_id=str(c.idea_id),
|
||||||
|
user_id=str(c.user_id),
|
||||||
|
content=c.content,
|
||||||
|
parent_id=str(c.parent_id) if c.parent_id else None,
|
||||||
|
display_name=user.display_name,
|
||||||
|
avatar_url=user.avatar_url,
|
||||||
|
created_at=c.created_at,
|
||||||
|
updated_at=c.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/ideas/{idea_id}/comments/{comment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_comment(
|
||||||
|
idea_id: str,
|
||||||
|
comment_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
success = await svc.delete_comment(UUID(comment_id), user.id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Comment not found")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Activity ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ideas/{idea_id}/activity", response_model=list[ActivityResponse])
|
||||||
|
async def list_activity(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
limit: int = Query(50, ge=1, le=200),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
return await svc.list_activities(UUID(idea_id), limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Notifications ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications", response_model=list[NotificationResponse])
|
||||||
|
async def list_notifications(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
unread_only: bool = Query(False),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
notifications = await svc.list_notifications(user.id, unread_only=unread_only)
|
||||||
|
return [
|
||||||
|
NotificationResponse(
|
||||||
|
id=str(n.id),
|
||||||
|
type=n.type,
|
||||||
|
title=n.title,
|
||||||
|
body=n.body,
|
||||||
|
link=n.link,
|
||||||
|
is_read=n.is_read,
|
||||||
|
created_at=n.created_at,
|
||||||
|
)
|
||||||
|
for n in notifications
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/notifications/unread-count", response_model=NotificationUnreadCount)
|
||||||
|
async def unread_count(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
count = await svc.get_unread_count(user.id)
|
||||||
|
return NotificationUnreadCount(count=count)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications/{notification_id}/read", response_model=NotificationResponse)
|
||||||
|
async def mark_read(
|
||||||
|
notification_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
success = await svc.mark_notification_read(UUID(notification_id), user.id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Notification not found")
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Notification).where(Notification.id == UUID(notification_id))
|
||||||
|
)
|
||||||
|
n = result.scalar_one()
|
||||||
|
return NotificationResponse(
|
||||||
|
id=str(n.id), type=n.type, title=n.title, body=n.body,
|
||||||
|
link=n.link, is_read=n.is_read, created_at=n.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/notifications/read-all", response_model=NotificationUnreadCount)
|
||||||
|
async def mark_all_read(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = CollaborationService(db)
|
||||||
|
count = await svc.mark_all_read(user.id)
|
||||||
|
return NotificationUnreadCount(count=count)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"""Disk/Drive API routes for VoIdea."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Annotated
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.disk_service import DISK_PROVIDERS, DiskService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class ConnectRequest(BaseModel):
|
||||||
|
provider: str
|
||||||
|
code: str
|
||||||
|
|
||||||
|
|
||||||
|
class DisconnectRequest(BaseModel):
|
||||||
|
provider: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/disk/providers")
|
||||||
|
async def list_disk_providers():
|
||||||
|
"""List available disk providers and user's connected ones."""
|
||||||
|
return {"providers": list(DISK_PROVIDERS)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/disk/status")
|
||||||
|
async def disk_status(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Get user's connected disk providers."""
|
||||||
|
svc = DiskService(db)
|
||||||
|
providers = await svc.get_connected_providers(user)
|
||||||
|
return {"connected": providers, "count": len(providers)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disk/connect")
|
||||||
|
async def connect_disk(
|
||||||
|
body: ConnectRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Exchange OAuth code for tokens and store provider connection."""
|
||||||
|
if body.provider not in DISK_PROVIDERS:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
|
||||||
|
|
||||||
|
tokens = None
|
||||||
|
email = ""
|
||||||
|
|
||||||
|
if body.provider == "google":
|
||||||
|
from app.integrations.oauth.google import exchange_code, get_user_info
|
||||||
|
tokens = await exchange_code(body.code)
|
||||||
|
if tokens:
|
||||||
|
info = await get_user_info(tokens.get("access_token", ""))
|
||||||
|
if info:
|
||||||
|
email = info.email
|
||||||
|
|
||||||
|
elif body.provider == "yandex":
|
||||||
|
from app.integrations.oauth.yandex import exchange_code, get_user_info
|
||||||
|
token_result = await exchange_code(body.code)
|
||||||
|
if token_result:
|
||||||
|
tokens = {
|
||||||
|
"access_token": token_result.access_token,
|
||||||
|
"refresh_token": token_result.refresh_token,
|
||||||
|
"expires_at": (
|
||||||
|
token_result.expires_at.isoformat()
|
||||||
|
if token_result.expires_at else None
|
||||||
|
),
|
||||||
|
}
|
||||||
|
info = await get_user_info(token_result.access_token)
|
||||||
|
if info:
|
||||||
|
email = info.email
|
||||||
|
|
||||||
|
elif body.provider == "apple":
|
||||||
|
from app.integrations.oauth.apple import exchange_code
|
||||||
|
result = await exchange_code(body.code)
|
||||||
|
if result:
|
||||||
|
tokens = result
|
||||||
|
|
||||||
|
if not tokens:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Failed to exchange authorization code",
|
||||||
|
)
|
||||||
|
|
||||||
|
svc = DiskService(db)
|
||||||
|
await svc.add_provider(user, body.provider, tokens, email=email)
|
||||||
|
return {"success": True, "provider": body.provider}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/disk/disconnect")
|
||||||
|
async def disconnect_disk(
|
||||||
|
body: DisconnectRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Disconnect a disk provider."""
|
||||||
|
svc = DiskService(db)
|
||||||
|
await svc.remove_provider(user, body.provider)
|
||||||
|
return {"success": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/disk/oauth-url/{provider}")
|
||||||
|
async def disk_oauth_url(provider: str):
|
||||||
|
"""Get OAuth authorize URL for a disk provider."""
|
||||||
|
if provider not in DISK_PROVIDERS:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
|
||||||
|
|
||||||
|
if provider == "google":
|
||||||
|
from app.integrations.oauth.google import get_authorize_url
|
||||||
|
url = await get_authorize_url()
|
||||||
|
elif provider == "yandex":
|
||||||
|
from app.integrations.oauth.yandex import get_authorize_url
|
||||||
|
url = await get_authorize_url()
|
||||||
|
elif provider == "apple":
|
||||||
|
from app.integrations.oauth.apple import get_authorize_url
|
||||||
|
url = await get_authorize_url()
|
||||||
|
else:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Unknown provider")
|
||||||
|
|
||||||
|
return {"url": url}
|
||||||
@@ -14,14 +14,18 @@ from app.models.idea import Idea
|
|||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.schemas.idea import (
|
from app.schemas.idea import (
|
||||||
AnalysisResultResponse,
|
AnalysisResultResponse,
|
||||||
|
FunnelTransitionRequest,
|
||||||
IdeaAnalyzeResponse,
|
IdeaAnalyzeResponse,
|
||||||
IdeaCreate,
|
IdeaCreate,
|
||||||
IdeaResponse,
|
IdeaResponse,
|
||||||
IdeaUpdate,
|
IdeaUpdate,
|
||||||
)
|
)
|
||||||
from app.services.analysis_service import AnalysisService
|
from app.services.analysis_service import AnalysisService
|
||||||
|
from app.services.collaboration_service import CollaborationService
|
||||||
|
from app.services.disk_service import DiskService
|
||||||
from app.services.export_service import export_content
|
from app.services.export_service import export_content
|
||||||
from app.services.idea_service import IdeaService
|
from app.services.idea_service import IdeaService
|
||||||
|
from app.services.presentation_service import PresentationService
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
@@ -186,6 +190,65 @@ async def export_idea(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{idea_id}/presentation")
|
||||||
|
async def generate_presentation(
|
||||||
|
idea_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
roles: str = Query("", description="Comma-separated role names (empty=all)"),
|
||||||
|
destination: str = Query("download", regex="^(download|drive)$"),
|
||||||
|
drive_provider: str = Query("", description="Specific drive provider (google/yandex/apple)"),
|
||||||
|
):
|
||||||
|
"""Generate HTML presentation for an idea.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
destination: "download" = return as file, "drive" = upload to cloud drive
|
||||||
|
roles: Comma-separated agent roles (empty = all available)
|
||||||
|
drive_provider: Specific provider to upload to (empty = auto-detect)
|
||||||
|
"""
|
||||||
|
idea_svc = IdeaService(db)
|
||||||
|
idea = await idea_svc.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
role_list = [r.strip() for r in roles.split(",") if r.strip()] if roles else None
|
||||||
|
pres_svc = PresentationService(db)
|
||||||
|
html = await pres_svc.generate(idea_id, roles=role_list, user_name=user.display_name)
|
||||||
|
if not html:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
filename = f"prezentaciya_{idea.title[:40].replace(' ', '_')}.html"
|
||||||
|
|
||||||
|
if destination == "drive":
|
||||||
|
disk_svc = DiskService(db)
|
||||||
|
content_bytes = html.encode("utf-8")
|
||||||
|
if drive_provider:
|
||||||
|
success = await disk_svc.upload_to_provider(
|
||||||
|
user, drive_provider, filename, content_bytes
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Failed to upload to {drive_provider}",
|
||||||
|
)
|
||||||
|
return {"success": True, "provider": drive_provider, "filename": filename}
|
||||||
|
else:
|
||||||
|
result = await disk_svc.upload_to_any(user, filename, content_bytes)
|
||||||
|
if not result:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="No connected drive. Connect a cloud drive first.",
|
||||||
|
)
|
||||||
|
return {"success": True, "provider": result["provider"], "filename": filename}
|
||||||
|
|
||||||
|
from fastapi.responses import Response
|
||||||
|
return Response(
|
||||||
|
content=html,
|
||||||
|
media_type="text/html; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{idea_id}/share", response_model=IdeaResponse)
|
@router.post("/{idea_id}/share", response_model=IdeaResponse)
|
||||||
async def toggle_idea_share(
|
async def toggle_idea_share(
|
||||||
idea_id: str,
|
idea_id: str,
|
||||||
@@ -208,6 +271,39 @@ async def toggle_idea_share(
|
|||||||
return _idea_to_response(idea)
|
return _idea_to_response(idea)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{idea_id}/funnel", response_model=IdeaResponse)
|
||||||
|
async def transition_funnel(
|
||||||
|
idea_id: str,
|
||||||
|
body: FunnelTransitionRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
service = IdeaService(db)
|
||||||
|
idea = await service.get_by_id(idea_id)
|
||||||
|
if not idea or str(idea.user_id) != str(user.id):
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Idea not found")
|
||||||
|
|
||||||
|
target = body.target_status
|
||||||
|
valid_states = Idea.FUNNEL_STATES
|
||||||
|
if target not in valid_states:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail=f"Invalid funnel status. Valid: {', '.join(valid_states)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
idea.funnel_status = target
|
||||||
|
await db.commit()
|
||||||
|
await db.refresh(idea)
|
||||||
|
|
||||||
|
collab = CollaborationService(db)
|
||||||
|
await collab.log_activity(
|
||||||
|
UUID(idea_id), user.id, "funnel",
|
||||||
|
{"from": idea.funnel_status, "to": target},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _idea_to_response(idea)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
|
@router.post("/demo", response_model=IdeaResponse, status_code=status.HTTP_201_CREATED)
|
||||||
async def create_demo_idea(
|
async def create_demo_idea(
|
||||||
user: Annotated[User, Depends(get_current_user)],
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
|||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"""Workspace API routes for VoIdea."""
|
||||||
|
|
||||||
|
from typing import Annotated
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.dependencies import get_current_user, get_db
|
||||||
|
from app.models.user import User
|
||||||
|
from app.schemas.workspace import (
|
||||||
|
AddMemberRequest,
|
||||||
|
MemberResponse,
|
||||||
|
WorkspaceCreate,
|
||||||
|
WorkspaceResponse,
|
||||||
|
WorkspaceUpdate,
|
||||||
|
)
|
||||||
|
from app.services.workspace_service import WorkspaceError, WorkspaceLimitError, WorkspaceService
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _ws_to_response(ws, member_count=0) -> WorkspaceResponse:
|
||||||
|
return WorkspaceResponse(
|
||||||
|
id=str(ws.id),
|
||||||
|
name=ws.name,
|
||||||
|
type=ws.type,
|
||||||
|
owner_id=str(ws.owner_id),
|
||||||
|
description=ws.description,
|
||||||
|
member_count=member_count,
|
||||||
|
created_at=ws.created_at,
|
||||||
|
updated_at=ws.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/", response_model=list[WorkspaceResponse])
|
||||||
|
async def list_workspaces(
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = WorkspaceService(db)
|
||||||
|
|
||||||
|
personal = await svc.create_personal(user.id)
|
||||||
|
workspaces = await svc.list_user_workspaces(user.id)
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for ws in workspaces:
|
||||||
|
members = await svc.list_members(ws.id)
|
||||||
|
result.append(_ws_to_response(ws, member_count=len(members)))
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/", response_model=WorkspaceResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_workspace(
|
||||||
|
body: WorkspaceCreate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = WorkspaceService(db)
|
||||||
|
try:
|
||||||
|
if body.type == "personal":
|
||||||
|
ws = await svc.create_personal(user.id, body.name)
|
||||||
|
else:
|
||||||
|
ws = await svc.create_team(body.name, user.id)
|
||||||
|
members = await svc.list_members(ws.id)
|
||||||
|
return _ws_to_response(ws, member_count=len(members))
|
||||||
|
except WorkspaceLimitError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||||
|
except WorkspaceError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{workspace_id}", response_model=WorkspaceResponse)
|
||||||
|
async def get_workspace(
|
||||||
|
workspace_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = WorkspaceService(db)
|
||||||
|
ws = await svc.get_by_id(UUID(workspace_id))
|
||||||
|
if not ws:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||||
|
membership = await svc.get_membership(ws.id, user.id)
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||||
|
members = await svc.list_members(ws.id)
|
||||||
|
return _ws_to_response(ws, member_count=len(members))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{workspace_id}", response_model=WorkspaceResponse)
|
||||||
|
async def update_workspace(
|
||||||
|
workspace_id: str,
|
||||||
|
body: WorkspaceUpdate,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = WorkspaceService(db)
|
||||||
|
try:
|
||||||
|
ws = await svc.update_workspace(
|
||||||
|
UUID(workspace_id), user.id, body.model_dump(exclude_none=True)
|
||||||
|
)
|
||||||
|
if not ws:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||||
|
members = await svc.list_members(ws.id)
|
||||||
|
return _ws_to_response(ws, member_count=len(members))
|
||||||
|
except WorkspaceError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def delete_workspace(
|
||||||
|
workspace_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = WorkspaceService(db)
|
||||||
|
try:
|
||||||
|
success = await svc.delete_workspace(UUID(workspace_id), user.id)
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||||
|
except WorkspaceError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
# ── Members ──
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{workspace_id}/members", response_model=list[MemberResponse])
|
||||||
|
async def list_members(
|
||||||
|
workspace_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = WorkspaceService(db)
|
||||||
|
ws = await svc.get_by_id(UUID(workspace_id))
|
||||||
|
if not ws:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Workspace not found")
|
||||||
|
membership = await svc.get_membership(ws.id, user.id)
|
||||||
|
if not membership:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Not a member")
|
||||||
|
members = await svc.list_members(ws.id)
|
||||||
|
return [MemberResponse(**m) for m in members]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{workspace_id}/members", response_model=MemberResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def add_member(
|
||||||
|
workspace_id: str,
|
||||||
|
body: AddMemberRequest,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = WorkspaceService(db)
|
||||||
|
try:
|
||||||
|
member = await svc.add_member(UUID(workspace_id), UUID(body.user_id), body.role)
|
||||||
|
members = await svc.list_members(ws_id := UUID(workspace_id))
|
||||||
|
m = next((m for m in members if m["id"] == str(member.id)), None)
|
||||||
|
return MemberResponse(**m) if m else MemberResponse(
|
||||||
|
id=str(member.id), user_id=str(member.user_id),
|
||||||
|
workspace_id=str(member.workspace_id), role=member.role,
|
||||||
|
joined_at=member.created_at,
|
||||||
|
)
|
||||||
|
except WorkspaceLimitError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))
|
||||||
|
except WorkspaceError as e:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{workspace_id}/members/{member_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
async def remove_member(
|
||||||
|
workspace_id: str,
|
||||||
|
member_id: str,
|
||||||
|
user: Annotated[User, Depends(get_current_user)],
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
svc = WorkspaceService(db)
|
||||||
|
success = await svc.remove_member(UUID(workspace_id), UUID(member_id))
|
||||||
|
if not success:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Member not found")
|
||||||
+27
-1
@@ -1,18 +1,22 @@
|
|||||||
"""Seed data for VoIdeaAI.
|
"""Seed data for VoIdeaAI.
|
||||||
|
|
||||||
Called from app lifespan when DB tables are empty.
|
Called from app lifespan when DB tables are empty.
|
||||||
Creates default tariff plans and sets system owner by email.
|
Creates default tariff plans, demo user, and sets system owner by email.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
|
from app.core.security import get_password_hash
|
||||||
from app.models.agent import AgentConfig
|
from app.models.agent import AgentConfig
|
||||||
from app.models.tariff import TariffPlan
|
from app.models.tariff import TariffPlan
|
||||||
|
from app.models.user import User
|
||||||
from app.services.user_service import UserService
|
from app.services.user_service import UserService
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
@@ -60,6 +64,7 @@ async def seed_database(db: AsyncSession) -> None:
|
|||||||
"""Seed initial data if tables are empty."""
|
"""Seed initial data if tables are empty."""
|
||||||
await _seed_tariff_plans(db)
|
await _seed_tariff_plans(db)
|
||||||
await _seed_agent_descriptions(db)
|
await _seed_agent_descriptions(db)
|
||||||
|
await _seed_demo_user(db)
|
||||||
await _seed_owner(db)
|
await _seed_owner(db)
|
||||||
|
|
||||||
|
|
||||||
@@ -118,6 +123,27 @@ async def _seed_agent_descriptions(db: AsyncSession) -> None:
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_demo_user(db: AsyncSession) -> None:
|
||||||
|
"""Create demo@voidea.app / demo1234 if not exists."""
|
||||||
|
result = await db.execute(select(User).where(User.email == "demo@voidea.app"))
|
||||||
|
if result.scalar_one_or_none():
|
||||||
|
return
|
||||||
|
|
||||||
|
demo = User(
|
||||||
|
id=uuid4(),
|
||||||
|
email="demo@voidea.app",
|
||||||
|
password_hash=get_password_hash("demo1234"),
|
||||||
|
display_name="Demo User",
|
||||||
|
is_active=True,
|
||||||
|
is_superuser=True,
|
||||||
|
role="admin",
|
||||||
|
accepted_terms_at=datetime.now(timezone.utc),
|
||||||
|
accepted_terms_version=settings.accepted_terms_version,
|
||||||
|
)
|
||||||
|
db.add(demo)
|
||||||
|
await db.commit()
|
||||||
|
|
||||||
|
|
||||||
async def _seed_owner(db: AsyncSession) -> None:
|
async def _seed_owner(db: AsyncSession) -> None:
|
||||||
"""Set system owner by SYSTEM_OWNER_EMAIL if configured."""
|
"""Set system owner by SYSTEM_OWNER_EMAIL if configured."""
|
||||||
if not settings.system_owner_email:
|
if not settings.system_owner_email:
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Apple CalDAV stub implementation.
|
||||||
|
|
||||||
|
When credentials are empty, logs the operation and returns True.
|
||||||
|
Real CalDAV implementation deferred.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AppleCalDavStub:
|
||||||
|
"""Apple Calendar via CalDAV (stub — logs only)."""
|
||||||
|
|
||||||
|
async def create_event(
|
||||||
|
self,
|
||||||
|
summary: str,
|
||||||
|
description: str,
|
||||||
|
start_iso: str,
|
||||||
|
end_iso: str,
|
||||||
|
**kwargs,
|
||||||
|
) -> bool:
|
||||||
|
logger.info(
|
||||||
|
"Apple CalDAV stub: create_event called — "
|
||||||
|
"summary=%s, start=%s, end=%s",
|
||||||
|
summary, start_iso, end_iso,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def list_events(self, max_results: int = 10) -> list[dict]:
|
||||||
|
logger.info("Apple CalDAV stub: list_events called")
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return True
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Google Calendar API client for VoIdea.
|
||||||
|
|
||||||
|
Uses OAuth access token to create events via Google Calendar API v3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
GOOGLE_CALENDAR_URL = "https://www.googleapis.com/calendar/v3"
|
||||||
|
|
||||||
|
|
||||||
|
class GoogleCalendarClient:
|
||||||
|
def __init__(self, access_token: str):
|
||||||
|
self._access_token = access_token
|
||||||
|
|
||||||
|
async def create_event(
|
||||||
|
self,
|
||||||
|
summary: str,
|
||||||
|
description: str,
|
||||||
|
start_iso: str,
|
||||||
|
end_iso: str,
|
||||||
|
**kwargs,
|
||||||
|
) -> bool:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{GOOGLE_CALENDAR_URL}/calendars/primary/events",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {self._access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"summary": summary,
|
||||||
|
"description": description,
|
||||||
|
"start": {"dateTime": start_iso, "timeZone": "UTC"},
|
||||||
|
"end": {"dateTime": end_iso, "timeZone": "UTC"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return True
|
||||||
|
logger.error("Google Calendar create_event failed: %s %s", resp.status_code, resp.text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def list_events(self, max_results: int = 10) -> list[dict]:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{GOOGLE_CALENDAR_URL}/calendars/primary/events",
|
||||||
|
headers={"Authorization": f"Bearer {self._access_token}"},
|
||||||
|
params={"maxResults": max_results, "orderBy": "startTime", "singleEvents": "true"},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json().get("items", [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return bool(self._access_token)
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Yandex Calendar API client for VoIdea.
|
||||||
|
|
||||||
|
Uses OAuth access token to create events via Yandex Calendar API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
YANDEX_CALENDAR_URL = "https://calendar.yandex.ru/api"
|
||||||
|
|
||||||
|
|
||||||
|
class YandexCalendarClient:
|
||||||
|
def __init__(self, access_token: str):
|
||||||
|
self._access_token = access_token
|
||||||
|
|
||||||
|
async def create_event(
|
||||||
|
self,
|
||||||
|
summary: str,
|
||||||
|
description: str,
|
||||||
|
start_iso: str,
|
||||||
|
end_iso: str,
|
||||||
|
**kwargs,
|
||||||
|
) -> bool:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.post(
|
||||||
|
f"{YANDEX_CALENDAR_URL}/events",
|
||||||
|
headers={
|
||||||
|
"Authorization": f"OAuth {self._access_token}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
json={
|
||||||
|
"summary": summary,
|
||||||
|
"description": description,
|
||||||
|
"start": {"dateTime": start_iso, "timeZone": "UTC"},
|
||||||
|
"end": {"dateTime": end_iso, "timeZone": "UTC"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if resp.status_code in (200, 201):
|
||||||
|
return True
|
||||||
|
logger.error("Yandex Calendar create_event failed: %s %s", resp.status_code, resp.text)
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def list_events(self, max_results: int = 10) -> list[dict]:
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
resp = await client.get(
|
||||||
|
f"{YANDEX_CALENDAR_URL}/events",
|
||||||
|
headers={"Authorization": f"OAuth {self._access_token}"},
|
||||||
|
params={"maxResults": max_results},
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return resp.json().get("events", [])
|
||||||
|
return []
|
||||||
|
|
||||||
|
async def health_check(self) -> bool:
|
||||||
|
return bool(self._access_token)
|
||||||
@@ -18,6 +18,7 @@ SCOPES = " ".join([
|
|||||||
"https://www.googleapis.com/auth/userinfo.email",
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
"https://www.googleapis.com/auth/userinfo.profile",
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
"https://www.googleapis.com/auth/drive.file",
|
"https://www.googleapis.com/auth/drive.file",
|
||||||
|
"https://www.googleapis.com/auth/calendar.events",
|
||||||
])
|
])
|
||||||
|
|
||||||
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
|
VOIDEA_DRIVE_FOLDER = "VoIdeaAI"
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ SCOPES = " ".join([
|
|||||||
"cloud_api:disk.app_folder",
|
"cloud_api:disk.app_folder",
|
||||||
"cloud_api:disk.read",
|
"cloud_api:disk.read",
|
||||||
"cloud_api:disk.info",
|
"cloud_api:disk.info",
|
||||||
|
"calendar:event.write",
|
||||||
])
|
])
|
||||||
|
|
||||||
VOIDEA_DISK_FOLDER = "VoIdeaAI"
|
VOIDEA_DISK_FOLDER = "VoIdeaAI"
|
||||||
|
|||||||
@@ -10,3 +10,5 @@ from app.models.log import LogEntry
|
|||||||
from app.models.conductor import ConductorInteraction
|
from app.models.conductor import ConductorInteraction
|
||||||
from app.models.pipeline import PipelineStats
|
from app.models.pipeline import PipelineStats
|
||||||
from app.models.bot_command import BotCommand
|
from app.models.bot_command import BotCommand
|
||||||
|
from app.models.workspace import Workspace, WorkspaceMembership
|
||||||
|
from app.models.collaboration import IdeaVote, IdeaComment, IdeaActivity, Notification
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Collaboration models for VoIdea.
|
||||||
|
|
||||||
|
Voting, threaded comments, activity log, and notifications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
|
class IdeaVote(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "idea_votes"
|
||||||
|
|
||||||
|
idea_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("ideas.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
vote: Mapped[str] = mapped_column(
|
||||||
|
String(4), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("idea_id", "user_id", name="uq_idea_user_vote"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IdeaComment(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "idea_comments"
|
||||||
|
|
||||||
|
idea_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("ideas.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
parent_id: Mapped[Optional[UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("idea_comments.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IdeaActivity(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "idea_activities"
|
||||||
|
|
||||||
|
idea_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("ideas.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[Optional[UUID]] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
action: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False
|
||||||
|
)
|
||||||
|
details: Mapped[Optional[dict]] = mapped_column(
|
||||||
|
JSONB, nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Notification(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "notifications"
|
||||||
|
|
||||||
|
user_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
type: Mapped[str] = mapped_column(
|
||||||
|
String(50), nullable=False, index=True
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False
|
||||||
|
)
|
||||||
|
body: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
link: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(512), nullable=True
|
||||||
|
)
|
||||||
|
is_read: Mapped[bool] = mapped_column(
|
||||||
|
Boolean, default=False, nullable=False, index=True
|
||||||
|
)
|
||||||
@@ -36,5 +36,10 @@ class Idea(SQLBase, UUIDMixin, TimestampMixin):
|
|||||||
public_slug: Mapped[Optional[str]] = mapped_column(
|
public_slug: Mapped[Optional[str]] = mapped_column(
|
||||||
String(64), unique=True, nullable=True, index=True
|
String(64), unique=True, nullable=True, index=True
|
||||||
)
|
)
|
||||||
|
funnel_status: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(20), nullable=True, default=None, index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
FUNNEL_STATES = ["raw", "validated", "backlog", "in_progress", "launched", "retrospective"]
|
||||||
|
|
||||||
user = relationship("User", back_populates="ideas", lazy="selectin")
|
user = relationship("User", back_populates="ideas", lazy="selectin")
|
||||||
|
|||||||
+12
-1
@@ -3,7 +3,7 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
from sqlalchemy import Boolean, DateTime, String
|
from sqlalchemy import Boolean, DateTime, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
@@ -60,4 +60,15 @@ class User(SQLBase, UUIDMixin, TimestampMixin):
|
|||||||
String(255), nullable=True
|
String(255), nullable=True
|
||||||
)
|
)
|
||||||
|
|
||||||
|
calendar_provider: Mapped[Optional[str]] = mapped_column(
|
||||||
|
String(20), nullable=True, default=None
|
||||||
|
)
|
||||||
|
calendar_credentials: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text, nullable=True, default=None
|
||||||
|
)
|
||||||
|
|
||||||
|
disk_providers: Mapped[Optional[list[dict[str, Any]]]] = mapped_column(
|
||||||
|
JSONB, nullable=True, default=None
|
||||||
|
)
|
||||||
|
|
||||||
ideas = relationship("Idea", back_populates="user", lazy="selectin")
|
ideas = relationship("Idea", back_populates="user", lazy="selectin")
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Workspace models for VoIdea.
|
||||||
|
|
||||||
|
Personal workspace (1 per user, free) + Team workspaces (tariff-gated).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from app.core.base import SQLBase, TimestampMixin, UUIDMixin
|
||||||
|
|
||||||
|
|
||||||
|
class Workspace(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "workspaces"
|
||||||
|
|
||||||
|
name: Mapped[str] = mapped_column(
|
||||||
|
String(255), nullable=False
|
||||||
|
)
|
||||||
|
type: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="personal", index=True
|
||||||
|
)
|
||||||
|
owner_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
description: Mapped[Optional[str]] = mapped_column(
|
||||||
|
Text, nullable=True, default=""
|
||||||
|
)
|
||||||
|
|
||||||
|
owner = relationship("User", backref="owned_workspaces", lazy="selectin")
|
||||||
|
members = relationship(
|
||||||
|
"WorkspaceMembership", back_populates="workspace",
|
||||||
|
lazy="selectin", cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceMembership(SQLBase, UUIDMixin, TimestampMixin):
|
||||||
|
__tablename__ = "workspace_memberships"
|
||||||
|
|
||||||
|
workspace_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("workspaces.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
user_id: Mapped[UUID] = mapped_column(
|
||||||
|
UUID(as_uuid=True),
|
||||||
|
ForeignKey("users.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(
|
||||||
|
String(20), nullable=False, default="member"
|
||||||
|
)
|
||||||
|
|
||||||
|
workspace = relationship(
|
||||||
|
"Workspace", back_populates="members", lazy="selectin"
|
||||||
|
)
|
||||||
|
user = relationship("User", backref="workspace_memberships", lazy="selectin")
|
||||||
@@ -41,12 +41,14 @@ class AgentInfoResponse(BaseModel):
|
|||||||
description: str = ""
|
description: str = ""
|
||||||
is_enabled: bool = True
|
is_enabled: bool = True
|
||||||
version: str = "1.0.0"
|
version: str = "1.0.0"
|
||||||
|
config: Optional[str] = None
|
||||||
last_run_at: Optional[datetime] = None
|
last_run_at: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
class AgentUpdateRequest(BaseModel):
|
class AgentUpdateRequest(BaseModel):
|
||||||
description: Optional[str] = None
|
description: Optional[str] = None
|
||||||
is_enabled: Optional[bool] = None
|
is_enabled: Optional[bool] = None
|
||||||
|
config: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class ServiceActionRequest(BaseModel):
|
class ServiceActionRequest(BaseModel):
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Collaboration schemas for VoIdea API."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class VoteResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
idea_id: str
|
||||||
|
user_id: str
|
||||||
|
vote: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class VoteToggleRequest(BaseModel):
|
||||||
|
vote: str = Field(pattern="^(up|down)$")
|
||||||
|
|
||||||
|
|
||||||
|
class VoteCount(BaseModel):
|
||||||
|
up: int = 0
|
||||||
|
down: int = 0
|
||||||
|
user_vote: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentCreate(BaseModel):
|
||||||
|
content: str = Field(min_length=1)
|
||||||
|
parent_id: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CommentResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
idea_id: str
|
||||||
|
user_id: str
|
||||||
|
content: str
|
||||||
|
parent_id: Optional[str] = None
|
||||||
|
display_name: str = ""
|
||||||
|
avatar_url: Optional[str] = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
replies: list["CommentResponse"] = []
|
||||||
|
|
||||||
|
|
||||||
|
class ActivityResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
idea_id: str
|
||||||
|
user_id: Optional[str] = None
|
||||||
|
action: str
|
||||||
|
details: Optional[dict[str, Any]] = None
|
||||||
|
display_name: str = ""
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
type: str
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
link: Optional[str] = None
|
||||||
|
is_read: bool
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationUnreadCount(BaseModel):
|
||||||
|
count: int = 0
|
||||||
@@ -19,6 +19,7 @@ class IdeaUpdate(BaseModel):
|
|||||||
status: Optional[str] = None
|
status: Optional[str] = None
|
||||||
tags: Optional[list[str]] = None
|
tags: Optional[list[str]] = None
|
||||||
is_public: Optional[bool] = None
|
is_public: Optional[bool] = None
|
||||||
|
funnel_status: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class IdeaResponse(BaseModel):
|
class IdeaResponse(BaseModel):
|
||||||
@@ -30,10 +31,15 @@ class IdeaResponse(BaseModel):
|
|||||||
tags: Optional[list[str]] = None
|
tags: Optional[list[str]] = None
|
||||||
is_public: bool
|
is_public: bool
|
||||||
public_slug: Optional[str] = None
|
public_slug: Optional[str] = None
|
||||||
|
funnel_status: Optional[str] = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FunnelTransitionRequest(BaseModel):
|
||||||
|
target_status: str = Field(min_length=1, max_length=20)
|
||||||
|
|
||||||
|
|
||||||
AI_AGENT_ROLES = [
|
AI_AGENT_ROLES = [
|
||||||
"coordinator",
|
"coordinator",
|
||||||
"task_organizer",
|
"task_organizer",
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
"""Workspace schemas for VoIdea API."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceCreate(BaseModel):
|
||||||
|
name: str = Field(min_length=1, max_length=255)
|
||||||
|
type: str = Field(default="team", pattern="^(personal|team)$")
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceUpdate(BaseModel):
|
||||||
|
name: Optional[str] = Field(None, min_length=1, max_length=255)
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
type: str
|
||||||
|
owner_id: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
member_count: int = 0
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class AddMemberRequest(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
role: str = Field(default="member", pattern="^(admin|member)$")
|
||||||
|
|
||||||
|
|
||||||
|
class MemberResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
workspace_id: str
|
||||||
|
role: str
|
||||||
|
email: str = ""
|
||||||
|
display_name: str = ""
|
||||||
|
joined_at: datetime
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""Calendar service — unified interface for Google, Yandex, Apple CalDAV.
|
||||||
|
|
||||||
|
Manages calendar provider connections and event creation.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
CALENDAR_PROVIDERS = ("google", "yandex", "apple")
|
||||||
|
|
||||||
|
|
||||||
|
class CalendarService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_calendar_credentials(self, user: User) -> Optional[dict[str, Any]]:
|
||||||
|
"""Get stored calendar credentials for user."""
|
||||||
|
if not hasattr(user, "calendar_provider") or not user.calendar_provider:
|
||||||
|
return None
|
||||||
|
if not hasattr(user, "calendar_credentials") or not user.calendar_credentials:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return json.loads(user.calendar_credentials)
|
||||||
|
except (json.JSONDecodeError, TypeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def set_calendar_credentials(
|
||||||
|
self, user: User, provider: str, credentials: dict[str, Any]
|
||||||
|
) -> None:
|
||||||
|
user.calendar_provider = provider
|
||||||
|
user.calendar_credentials = json.dumps(credentials)
|
||||||
|
await self.db.commit()
|
||||||
|
|
||||||
|
async def disconnect_calendar(self, user: User) -> None:
|
||||||
|
user.calendar_provider = None
|
||||||
|
user.calendar_credentials = None
|
||||||
|
await self.db.commit()
|
||||||
|
|
||||||
|
def _get_client(self, provider: str, credentials: dict[str, Any]):
|
||||||
|
"""Get appropriate calendar client for provider."""
|
||||||
|
if provider == "google":
|
||||||
|
from app.integrations.calendar.google_calendar import GoogleCalendarClient
|
||||||
|
return GoogleCalendarClient(access_token=credentials.get("access_token", ""))
|
||||||
|
elif provider == "yandex":
|
||||||
|
from app.integrations.calendar.yandex_calendar import YandexCalendarClient
|
||||||
|
return YandexCalendarClient(access_token=credentials.get("access_token", ""))
|
||||||
|
elif provider == "apple":
|
||||||
|
from app.integrations.calendar.apple_caldav import AppleCalDavStub
|
||||||
|
return AppleCalDavStub()
|
||||||
|
raise ValueError(f"Unknown calendar provider: {provider}")
|
||||||
|
|
||||||
|
async def create_idea_event(self, user: User, idea_title: str, idea_content: str) -> bool:
|
||||||
|
"""Create a calendar event from an idea."""
|
||||||
|
creds = await self.get_calendar_credentials(user)
|
||||||
|
if not creds:
|
||||||
|
logger.warning("No calendar credentials for user %s", user.id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
provider = user.calendar_provider
|
||||||
|
if not provider:
|
||||||
|
return False
|
||||||
|
|
||||||
|
client = self._get_client(provider, creds)
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
start_iso = now.isoformat()
|
||||||
|
end_iso = (now + timedelta(hours=1)).isoformat()
|
||||||
|
|
||||||
|
return await client.create_event(
|
||||||
|
summary=f"Idea: {idea_title[:100]}",
|
||||||
|
description=idea_content[:1000],
|
||||||
|
start_iso=start_iso,
|
||||||
|
end_iso=end_iso,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_events(self, user: User, max_results: int = 10) -> list[dict]:
|
||||||
|
creds = await self.get_calendar_credentials(user)
|
||||||
|
if not creds or not user.calendar_provider:
|
||||||
|
return []
|
||||||
|
|
||||||
|
client = self._get_client(user.calendar_provider, creds)
|
||||||
|
return await client.list_events(max_results=max_results)
|
||||||
|
|
||||||
|
async def health_check(self, user: User) -> bool:
|
||||||
|
creds = await self.get_calendar_credentials(user)
|
||||||
|
if not creds or not user.calendar_provider:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
client = self._get_client(user.calendar_provider, creds)
|
||||||
|
return await client.health_check()
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
"""Collaboration service for VoIdea.
|
||||||
|
|
||||||
|
Voting, threaded comments, activity logging, and notifications.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select, func, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.collaboration import IdeaActivity, IdeaComment, IdeaVote, Notification
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
|
||||||
|
class CollaborationService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
# ── Voting ──
|
||||||
|
|
||||||
|
async def toggle_vote(self, idea_id: UUID, user_id: UUID, vote: str) -> dict[str, Any]:
|
||||||
|
"""Toggle vote. Returns current vote state."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(IdeaVote).where(
|
||||||
|
IdeaVote.idea_id == idea_id,
|
||||||
|
IdeaVote.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
if existing.vote == vote:
|
||||||
|
await self.db.delete(existing)
|
||||||
|
await self.db.commit()
|
||||||
|
return {"vote": None}
|
||||||
|
existing.vote = vote
|
||||||
|
await self.db.commit()
|
||||||
|
return {"vote": vote}
|
||||||
|
else:
|
||||||
|
v = IdeaVote(idea_id=idea_id, user_id=user_id, vote=vote)
|
||||||
|
self.db.add(v)
|
||||||
|
await self.db.commit()
|
||||||
|
return {"vote": vote}
|
||||||
|
|
||||||
|
async def get_vote_counts(self, idea_id: UUID) -> dict[str, Any]:
|
||||||
|
"""Get vote counts and current user's vote."""
|
||||||
|
up = await self.db.execute(
|
||||||
|
select(func.count(IdeaVote.id)).where(
|
||||||
|
IdeaVote.idea_id == idea_id, IdeaVote.vote == "up"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
down = await self.db.execute(
|
||||||
|
select(func.count(IdeaVote.id)).where(
|
||||||
|
IdeaVote.idea_id == idea_id, IdeaVote.vote == "down"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"up": up.scalar() or 0,
|
||||||
|
"down": down.scalar() or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_user_vote(self, idea_id: UUID, user_id: UUID) -> Optional[str]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(IdeaVote).where(
|
||||||
|
IdeaVote.idea_id == idea_id, IdeaVote.user_id == user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
v = result.scalar_one_or_none()
|
||||||
|
return v.vote if v else None
|
||||||
|
|
||||||
|
# ── Comments ──
|
||||||
|
|
||||||
|
async def create_comment(
|
||||||
|
self, idea_id: UUID, user_id: UUID, content: str, parent_id: Optional[UUID] = None
|
||||||
|
) -> IdeaComment:
|
||||||
|
c = IdeaComment(
|
||||||
|
idea_id=idea_id, user_id=user_id, content=content, parent_id=parent_id
|
||||||
|
)
|
||||||
|
self.db.add(c)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(c)
|
||||||
|
return c
|
||||||
|
|
||||||
|
async def list_comments(self, idea_id: UUID) -> list[dict[str, Any]]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(IdeaComment, User)
|
||||||
|
.join(User, IdeaComment.user_id == User.id)
|
||||||
|
.where(IdeaComment.idea_id == idea_id)
|
||||||
|
.order_by(IdeaComment.created_at.asc())
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
comments = []
|
||||||
|
for c, u in rows:
|
||||||
|
comments.append({
|
||||||
|
"id": str(c.id),
|
||||||
|
"idea_id": str(c.idea_id),
|
||||||
|
"user_id": str(c.user_id),
|
||||||
|
"content": c.content,
|
||||||
|
"parent_id": str(c.parent_id) if c.parent_id else None,
|
||||||
|
"display_name": u.display_name,
|
||||||
|
"avatar_url": u.avatar_url,
|
||||||
|
"created_at": c.created_at,
|
||||||
|
"updated_at": c.updated_at,
|
||||||
|
})
|
||||||
|
return comments
|
||||||
|
|
||||||
|
async def delete_comment(self, comment_id: UUID, user_id: UUID) -> bool:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(IdeaComment).where(IdeaComment.id == comment_id)
|
||||||
|
)
|
||||||
|
c = result.scalar_one_or_none()
|
||||||
|
if not c or str(c.user_id) != str(user_id):
|
||||||
|
return False
|
||||||
|
await self.db.delete(c)
|
||||||
|
await self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Activity ──
|
||||||
|
|
||||||
|
async def log_activity(
|
||||||
|
self, idea_id: UUID, user_id: Optional[UUID],
|
||||||
|
action: str, details: Optional[dict] = None
|
||||||
|
) -> IdeaActivity:
|
||||||
|
a = IdeaActivity(
|
||||||
|
idea_id=idea_id, user_id=user_id, action=action, details=details
|
||||||
|
)
|
||||||
|
self.db.add(a)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(a)
|
||||||
|
return a
|
||||||
|
|
||||||
|
async def list_activities(self, idea_id: UUID, limit: int = 50) -> list[dict[str, Any]]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(IdeaActivity, User)
|
||||||
|
.join(User, IdeaActivity.user_id == User.id, isouter=True)
|
||||||
|
.where(IdeaActivity.idea_id == idea_id)
|
||||||
|
.order_by(IdeaActivity.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(a.id),
|
||||||
|
"idea_id": str(a.idea_id),
|
||||||
|
"user_id": str(a.user_id) if a.user_id else None,
|
||||||
|
"action": a.action,
|
||||||
|
"details": a.details,
|
||||||
|
"display_name": u.display_name if u else "System",
|
||||||
|
"created_at": a.created_at,
|
||||||
|
}
|
||||||
|
for a, u in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Notifications ──
|
||||||
|
|
||||||
|
async def create_notification(
|
||||||
|
self, user_id: UUID, type: str, title: str,
|
||||||
|
body: str, link: Optional[str] = None
|
||||||
|
) -> Notification:
|
||||||
|
n = Notification(
|
||||||
|
user_id=user_id, type=type, title=title,
|
||||||
|
body=body, link=link,
|
||||||
|
)
|
||||||
|
self.db.add(n)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(n)
|
||||||
|
return n
|
||||||
|
|
||||||
|
async def list_notifications(
|
||||||
|
self, user_id: UUID, unread_only: bool = False, limit: int = 50
|
||||||
|
) -> list[Notification]:
|
||||||
|
query = (
|
||||||
|
select(Notification)
|
||||||
|
.where(Notification.user_id == user_id)
|
||||||
|
.order_by(Notification.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if unread_only:
|
||||||
|
query = query.where(Notification.is_read == False)
|
||||||
|
result = await self.db.execute(query)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def mark_notification_read(self, notification_id: UUID, user_id: UUID) -> bool:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Notification).where(
|
||||||
|
Notification.id == notification_id,
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
n = result.scalar_one_or_none()
|
||||||
|
if not n:
|
||||||
|
return False
|
||||||
|
n.is_read = True
|
||||||
|
await self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def mark_all_read(self, user_id: UUID) -> int:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(Notification.id)).where(
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.is_read == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
count = result.scalar() or 0
|
||||||
|
await self.db.execute(
|
||||||
|
delete(Notification).where(
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.is_read == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await self.db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
async def get_unread_count(self, user_id: UUID) -> int:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(Notification.id)).where(
|
||||||
|
Notification.user_id == user_id,
|
||||||
|
Notification.is_read == False,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar() or 0
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""Disk service — unified interface for Google Drive, Yandex Disk, Apple iCloud.
|
||||||
|
|
||||||
|
Manages cloud drive provider connections and file uploads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DISK_PROVIDERS = ("google", "yandex", "apple")
|
||||||
|
|
||||||
|
|
||||||
|
class DiskService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def get_connected_providers(self, user: User) -> list[dict[str, Any]]:
|
||||||
|
"""Get list of connected disk providers with basic info."""
|
||||||
|
providers = user.disk_providers or []
|
||||||
|
# Return sanitized list (no tokens)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"provider": p["provider"],
|
||||||
|
"connected_at": p.get("connected_at", ""),
|
||||||
|
"email": p.get("email", ""),
|
||||||
|
"quota": p.get("quota"),
|
||||||
|
}
|
||||||
|
for p in providers
|
||||||
|
]
|
||||||
|
|
||||||
|
async def is_connected(self, user: User, provider: str) -> bool:
|
||||||
|
providers = user.disk_providers or []
|
||||||
|
return any(p["provider"] == provider for p in providers)
|
||||||
|
|
||||||
|
async def add_provider(
|
||||||
|
self, user: User, provider: str, tokens: dict[str, Any], email: str = ""
|
||||||
|
) -> None:
|
||||||
|
providers = user.disk_providers or []
|
||||||
|
# Remove existing entry for same provider
|
||||||
|
providers = [p for p in providers if p["provider"] != provider]
|
||||||
|
providers.append({
|
||||||
|
"provider": provider,
|
||||||
|
"access_token": tokens.get("access_token", ""),
|
||||||
|
"refresh_token": tokens.get("refresh_token", ""),
|
||||||
|
"expires_at": tokens.get("expires_at"),
|
||||||
|
"connected_at": __import__("datetime").datetime.utcnow().isoformat(),
|
||||||
|
"email": email,
|
||||||
|
})
|
||||||
|
user.disk_providers = providers
|
||||||
|
await self.db.commit()
|
||||||
|
|
||||||
|
async def remove_provider(self, user: User, provider: str) -> None:
|
||||||
|
providers = user.disk_providers or []
|
||||||
|
user.disk_providers = [p for p in providers if p["provider"] != provider]
|
||||||
|
await self.db.commit()
|
||||||
|
|
||||||
|
async def get_access_token(self, user: User, provider: str) -> Optional[str]:
|
||||||
|
providers = user.disk_providers or []
|
||||||
|
for p in providers:
|
||||||
|
if p["provider"] == provider:
|
||||||
|
return p.get("access_token")
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def upload_to_provider(
|
||||||
|
self, user: User, provider: str, file_name: str, file_content: bytes
|
||||||
|
) -> bool:
|
||||||
|
"""Upload file bytes to specified provider's VoIdea folder."""
|
||||||
|
token = await self.get_access_token(user, provider)
|
||||||
|
if not token:
|
||||||
|
logger.warning("No token for provider %s (user %s)", provider, user.id)
|
||||||
|
return False
|
||||||
|
|
||||||
|
if provider == "google":
|
||||||
|
from app.integrations.oauth.google import ensure_app_folder, upload_file
|
||||||
|
parent_id = await ensure_app_folder(token)
|
||||||
|
return await upload_file(token, file_name, file_content, parent_id=parent_id)
|
||||||
|
|
||||||
|
elif provider == "yandex":
|
||||||
|
from app.integrations.oauth.yandex import ensure_app_folder, upload_file
|
||||||
|
import tempfile, os
|
||||||
|
await ensure_app_folder(token)
|
||||||
|
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".html")
|
||||||
|
try:
|
||||||
|
tmp.write(file_content)
|
||||||
|
tmp.close()
|
||||||
|
return await upload_file(token, tmp.name, file_name)
|
||||||
|
finally:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
|
||||||
|
elif provider == "apple":
|
||||||
|
logger.info("Apple iCloud upload is a stub — file not actually uploaded")
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def upload_to_any(
|
||||||
|
self, user: User, file_name: str, file_content: bytes
|
||||||
|
) -> Optional[dict[str, Any]]:
|
||||||
|
"""Upload to first available provider. Returns provider info."""
|
||||||
|
providers = user.disk_providers or []
|
||||||
|
for entry in providers:
|
||||||
|
provider = entry["provider"]
|
||||||
|
success = await self.upload_to_provider(user, provider, file_name, file_content)
|
||||||
|
if success:
|
||||||
|
return {"provider": provider, "success": True}
|
||||||
|
return None
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
"""Notification service — multi-channel delivery for VoIdea.
|
||||||
|
|
||||||
|
Supported channels:
|
||||||
|
- web (DB — stored as Notification model)
|
||||||
|
- telegram (via TelegramBotClient)
|
||||||
|
- email (via email_service)
|
||||||
|
|
||||||
|
Only web channel is required; telegram and email are optional.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.collaboration_service import CollaborationService
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationDeliveryService:
|
||||||
|
"""Delivers notifications across multiple channels."""
|
||||||
|
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
self.collab = CollaborationService(db)
|
||||||
|
|
||||||
|
async def notify(
|
||||||
|
self,
|
||||||
|
user_id: UUID,
|
||||||
|
type: str,
|
||||||
|
title: str,
|
||||||
|
body: str,
|
||||||
|
link: Optional[str] = None,
|
||||||
|
channels: Optional[list[str]] = None,
|
||||||
|
) -> dict[str, bool]:
|
||||||
|
"""Deliver notification to specified channels (default: web only).
|
||||||
|
|
||||||
|
Returns dict of channel -> success status.
|
||||||
|
"""
|
||||||
|
if channels is None:
|
||||||
|
channels = ["web"]
|
||||||
|
|
||||||
|
results: dict[str, bool] = {}
|
||||||
|
|
||||||
|
if "web" in channels:
|
||||||
|
try:
|
||||||
|
await self.collab.create_notification(
|
||||||
|
user_id=user_id, type=type,
|
||||||
|
title=title, body=body, link=link,
|
||||||
|
)
|
||||||
|
results["web"] = True
|
||||||
|
except Exception:
|
||||||
|
results["web"] = False
|
||||||
|
|
||||||
|
if "telegram" in channels:
|
||||||
|
try:
|
||||||
|
await self._send_telegram(user_id, title, body, link)
|
||||||
|
results["telegram"] = True
|
||||||
|
except Exception:
|
||||||
|
results["telegram"] = False
|
||||||
|
|
||||||
|
if "email" in channels:
|
||||||
|
try:
|
||||||
|
await self._send_email(user_id, title, body)
|
||||||
|
results["email"] = True
|
||||||
|
except Exception:
|
||||||
|
results["email"] = False
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
async def _send_telegram(
|
||||||
|
self, user_id: UUID, title: str, body: str, link: Optional[str] = None
|
||||||
|
) -> None:
|
||||||
|
from app.core.config import get_settings
|
||||||
|
settings = get_settings()
|
||||||
|
if not settings.telegram_bot_token:
|
||||||
|
return
|
||||||
|
|
||||||
|
from app.integrations.telegram.client import TelegramBotClient
|
||||||
|
client = TelegramBotClient(token=settings.telegram_bot_token)
|
||||||
|
|
||||||
|
text = f"*{title}*\n{body}"
|
||||||
|
if link:
|
||||||
|
text += f"\n\n[Open]({link})"
|
||||||
|
|
||||||
|
# Find user's telegram chat_id — for now send to first available
|
||||||
|
# In production this would look up stored chat_id for the user
|
||||||
|
from app.models.user import User
|
||||||
|
user = await self.db.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
return
|
||||||
|
|
||||||
|
async def _send_email(
|
||||||
|
self, user_id: UUID, title: str, body: str
|
||||||
|
) -> None:
|
||||||
|
from app.services.email_service import send_email
|
||||||
|
from app.models.user import User
|
||||||
|
|
||||||
|
user = await self.db.get(User, user_id)
|
||||||
|
if not user or not user.email:
|
||||||
|
return
|
||||||
|
|
||||||
|
await send_email(
|
||||||
|
to=user.email,
|
||||||
|
subject=title,
|
||||||
|
body=body,
|
||||||
|
)
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
"""Presentation service — generates HTML slides from idea + analysis reports."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.agents.models import AgentReport
|
||||||
|
from app.models.idea import Idea
|
||||||
|
from app.services.disk_service import DiskService
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ROLE_EMOJI = {
|
||||||
|
"coordinator": "🎯",
|
||||||
|
"task_organizer": "📋",
|
||||||
|
"business_analyst": "💼",
|
||||||
|
"legal_expert": "⚖️",
|
||||||
|
"financial_consultant": "💰",
|
||||||
|
"solution_architect": "🏗️",
|
||||||
|
"tester": "🧪",
|
||||||
|
"ui_designer": "🎨",
|
||||||
|
"smm_specialist": "📱",
|
||||||
|
"life_coach": "🌟",
|
||||||
|
"accessibility_expert": "♿",
|
||||||
|
}
|
||||||
|
|
||||||
|
ROLE_LABELS = {
|
||||||
|
"coordinator": "Координатор",
|
||||||
|
"task_organizer": "Организатор задач",
|
||||||
|
"business_analyst": "Бизнес-аналитик",
|
||||||
|
"legal_expert": "Юрист",
|
||||||
|
"financial_consultant": "Финансовый консультант",
|
||||||
|
"solution_architect": "Архитектор решения",
|
||||||
|
"tester": "Тестировщик",
|
||||||
|
"ui_designer": "UI/Дизайнер",
|
||||||
|
"smm_specialist": "SMM-специалист",
|
||||||
|
"life_coach": "Лайф-коуч",
|
||||||
|
"accessibility_expert": "Эксперт доступности",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
REVEAL_HTML_TEMPLATE = """<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{title} — VoIdea Презентация</title>
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.css">
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/theme/white.css">
|
||||||
|
<style>
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;600;700&display=swap');
|
||||||
|
* {{ font-family: 'Inter', sans-serif; }}
|
||||||
|
.reveal {{ font-size: 28px; }}
|
||||||
|
.reveal h1 {{ color: #2563EB; font-weight: 700; }}
|
||||||
|
.reveal h2 {{ color: #1E293B; font-weight: 600; }}
|
||||||
|
.reveal h3 {{ color: #475569; font-weight: 600; }}
|
||||||
|
.reveal .slides section {{ padding: 40px; }}
|
||||||
|
.slide-title {{ text-align: center; padding-top: 15vh; }}
|
||||||
|
.slide-title h1 {{ font-size: 3em; margin-bottom: 0.3em; }}
|
||||||
|
.slide-title .meta {{ color: #94A3B8; font-size: 0.5em; }}
|
||||||
|
.slide-role {{ text-align: left; }}
|
||||||
|
.slide-role h2 {{ font-size: 1.2em; margin-bottom: 0.5em; }}
|
||||||
|
.slide-role .emoji {{ font-size: 1.5em; margin-right: 0.3em; }}
|
||||||
|
.slide-role .content {{ font-size: 0.7em; line-height: 1.6; color: #334155; }}
|
||||||
|
.slide-exec {{ text-align: left; background: #EFF6FF; border-radius: 12px; padding: 30px !important; }}
|
||||||
|
.footer {{ position: fixed; bottom: 12px; left: 0; right: 0; text-align: center; font-size: 12px; color: #94A3B8; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="reveal"><div class="slides">
|
||||||
|
<section class="slide-title">
|
||||||
|
<h1>{title}</h1>
|
||||||
|
<div class="meta">
|
||||||
|
{author} · {status_label} · {date}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{executive_summary}
|
||||||
|
|
||||||
|
{role_slides}
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Спасибо за внимание!</h2>
|
||||||
|
<p style="color:#94A3B8;font-size:0.6em;">Создано в VoIdea · {date}</p>
|
||||||
|
</section>
|
||||||
|
</div></div>
|
||||||
|
<div class="footer">VoIdea · {date}</div>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/reveal.js@5.1.0/dist/reveal.js"></script>
|
||||||
|
<script>Reveal.initialize({{ controls: true, progress: true, hash: true }});</script>
|
||||||
|
</body>
|
||||||
|
</html>"""
|
||||||
|
|
||||||
|
|
||||||
|
class PresentationService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
idea_id: str,
|
||||||
|
roles: Optional[list[str]] = None,
|
||||||
|
user_name: str = "",
|
||||||
|
) -> Optional[str]:
|
||||||
|
"""Generate HTML presentation for an idea.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
idea_id: UUID of the idea
|
||||||
|
roles: List of roles to include (None = all available)
|
||||||
|
user_name: Author display name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML string or None if idea not found
|
||||||
|
"""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Idea).where(Idea.id == idea_id)
|
||||||
|
)
|
||||||
|
idea = result.scalar_one_or_none()
|
||||||
|
if not idea:
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(AgentReport).where(
|
||||||
|
AgentReport.details["idea_id"].as_string() == idea_id
|
||||||
|
).order_by(AgentReport.created_at.asc())
|
||||||
|
)
|
||||||
|
reports = result.scalars().all()
|
||||||
|
|
||||||
|
if roles is not None:
|
||||||
|
reports = [
|
||||||
|
r for r in reports
|
||||||
|
if r.agent_id.replace("ai_", "") in roles
|
||||||
|
]
|
||||||
|
|
||||||
|
funnel_labels = {
|
||||||
|
"raw": "Сырая",
|
||||||
|
"validated": "Подтверждена",
|
||||||
|
"backlog": "Бэклог",
|
||||||
|
"in_progress": "В работе",
|
||||||
|
"launched": "Запущена",
|
||||||
|
"retrospective": "Ретроспектива",
|
||||||
|
}
|
||||||
|
status_label = funnel_labels.get(
|
||||||
|
idea.funnel_status or "", idea.funnel_status or "Без статуса"
|
||||||
|
)
|
||||||
|
|
||||||
|
date_str = datetime.now(timezone.utc).strftime("%d.%m.%Y")
|
||||||
|
|
||||||
|
role_slides = []
|
||||||
|
for r in reports:
|
||||||
|
role_name = r.agent_id.replace("ai_", "")
|
||||||
|
emoji = ROLE_EMOJI.get(role_name, "📄")
|
||||||
|
label = ROLE_LABELS.get(role_name, role_name)
|
||||||
|
content = r.message or "Нет данных"
|
||||||
|
|
||||||
|
role_slides.append(f"""
|
||||||
|
<section class="slide-role">
|
||||||
|
<h2><span class="emoji">{emoji}</span> {label}</h2>
|
||||||
|
<div class="content">{content}</div>
|
||||||
|
</section>""")
|
||||||
|
|
||||||
|
success_count = sum(1 for r in reports if r.success)
|
||||||
|
|
||||||
|
if role_slides:
|
||||||
|
exec_summary = f"""
|
||||||
|
<section class="slide-exec">
|
||||||
|
<h2>📊 Executive Summary</h2>
|
||||||
|
<p>Проанализировано ролей: <strong>{len(reports)}</strong></p>
|
||||||
|
<p>Успешных анализов: <strong>{success_count}</strong></p>
|
||||||
|
</section>"""
|
||||||
|
else:
|
||||||
|
exec_summary = ""
|
||||||
|
|
||||||
|
return REVEAL_HTML_TEMPLATE.format(
|
||||||
|
title=idea.title,
|
||||||
|
author=user_name or "Пользователь",
|
||||||
|
status_label=status_label,
|
||||||
|
date=date_str,
|
||||||
|
executive_summary=exec_summary,
|
||||||
|
role_slides="".join(role_slides),
|
||||||
|
)
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"""Workspace service with tariff-gated limits for VoIdea."""
|
||||||
|
|
||||||
|
from typing import Any, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.workspace import Workspace, WorkspaceMembership
|
||||||
|
from app.models.user import User
|
||||||
|
from app.services.tariff_service import TariffService
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceLimitError(WorkspaceError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WorkspaceService:
|
||||||
|
def __init__(self, db: AsyncSession):
|
||||||
|
self.db = db
|
||||||
|
|
||||||
|
# ── Tariff gates ──
|
||||||
|
|
||||||
|
async def _get_tariff_features(self, user_id: UUID) -> dict[str, Any]:
|
||||||
|
"""Get tariff plan features for a user."""
|
||||||
|
tariff_svc = TariffService(self.db)
|
||||||
|
sub = await tariff_svc.get_user_subscription(user_id)
|
||||||
|
if not sub:
|
||||||
|
return {}
|
||||||
|
plan = await tariff_svc.get_plan_by_id(sub.plan_id)
|
||||||
|
return plan.features if plan and plan.features else {}
|
||||||
|
|
||||||
|
async def _check_team_workspace_limit(self, user_id: UUID) -> None:
|
||||||
|
"""Check if user can create a team workspace."""
|
||||||
|
features = await self._get_tariff_features(user_id)
|
||||||
|
max_team = features.get("max_team_workspaces", 0)
|
||||||
|
if max_team <= 0:
|
||||||
|
raise WorkspaceLimitError(
|
||||||
|
"Your tariff plan does not allow team workspaces"
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(Workspace.id))
|
||||||
|
.where(
|
||||||
|
Workspace.owner_id == user_id,
|
||||||
|
Workspace.type == "team",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
current_team_count = result.scalar() or 0
|
||||||
|
if current_team_count >= max_team:
|
||||||
|
raise WorkspaceLimitError(
|
||||||
|
f"Team workspace limit reached ({max_team}). "
|
||||||
|
"Upgrade your tariff to create more."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_member_limit(self, workspace_id: UUID) -> None:
|
||||||
|
"""Check if workspace can accept more members."""
|
||||||
|
ws = await self.db.get(Workspace, workspace_id)
|
||||||
|
if not ws:
|
||||||
|
raise WorkspaceError("Workspace not found")
|
||||||
|
|
||||||
|
features = await self._get_tariff_features(ws.owner_id)
|
||||||
|
max_members = features.get("max_members_per_workspace", 0)
|
||||||
|
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(func.count(WorkspaceMembership.id))
|
||||||
|
.where(WorkspaceMembership.workspace_id == workspace_id)
|
||||||
|
)
|
||||||
|
current_count = result.scalar() or 0
|
||||||
|
if max_members > 0 and current_count >= max_members:
|
||||||
|
raise WorkspaceLimitError(
|
||||||
|
f"Member limit reached ({max_members}). "
|
||||||
|
"Upgrade your tariff to add more members."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _check_feature_gate(self, user_id: UUID, feature: str) -> None:
|
||||||
|
"""Check if a specific workspace feature is enabled for the user's tariff."""
|
||||||
|
features = await self._get_tariff_features(user_id)
|
||||||
|
if not features.get(feature, False):
|
||||||
|
raise WorkspaceLimitError(
|
||||||
|
f"Feature '{feature}' is not available on your tariff plan"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── CRUD ──
|
||||||
|
|
||||||
|
async def create_personal(self, user_id: UUID, name: str = "Personal") -> Workspace:
|
||||||
|
"""Create or get existing personal workspace for user."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Workspace).where(
|
||||||
|
Workspace.owner_id == user_id,
|
||||||
|
Workspace.type == "personal",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing = result.scalar_one_or_none()
|
||||||
|
if existing:
|
||||||
|
return existing
|
||||||
|
|
||||||
|
ws = Workspace(name=name, type="personal", owner_id=user_id)
|
||||||
|
self.db.add(ws)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
membership = WorkspaceMembership(
|
||||||
|
workspace_id=ws.id, user_id=user_id, role="owner"
|
||||||
|
)
|
||||||
|
self.db.add(membership)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(ws)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
async def create_team(
|
||||||
|
self, name: str, owner_id: UUID, description: str = ""
|
||||||
|
) -> Workspace:
|
||||||
|
"""Create a new team workspace (tariff-gated)."""
|
||||||
|
await self._check_team_workspace_limit(owner_id)
|
||||||
|
|
||||||
|
ws = Workspace(name=name, type="team", owner_id=owner_id, description=description)
|
||||||
|
self.db.add(ws)
|
||||||
|
await self.db.flush()
|
||||||
|
|
||||||
|
membership = WorkspaceMembership(
|
||||||
|
workspace_id=ws.id, user_id=owner_id, role="owner"
|
||||||
|
)
|
||||||
|
self.db.add(membership)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(ws)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
async def get_by_id(self, workspace_id: UUID) -> Optional[Workspace]:
|
||||||
|
return await self.db.get(Workspace, workspace_id)
|
||||||
|
|
||||||
|
async def list_user_workspaces(self, user_id: UUID) -> list[Workspace]:
|
||||||
|
"""List workspaces where user is a member."""
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(Workspace)
|
||||||
|
.join(WorkspaceMembership)
|
||||||
|
.where(WorkspaceMembership.user_id == user_id)
|
||||||
|
.order_by(Workspace.type, Workspace.name)
|
||||||
|
)
|
||||||
|
return list(result.scalars().all())
|
||||||
|
|
||||||
|
async def update_workspace(
|
||||||
|
self, workspace_id: UUID, user_id: UUID, updates: dict[str, Any]
|
||||||
|
) -> Optional[Workspace]:
|
||||||
|
ws = await self.db.get(Workspace, workspace_id)
|
||||||
|
if not ws:
|
||||||
|
return None
|
||||||
|
if ws.owner_id != user_id:
|
||||||
|
raise WorkspaceError("Only the owner can update workspace")
|
||||||
|
for key, value in updates.items():
|
||||||
|
if hasattr(ws, key) and key not in ("id", "owner_id", "type", "created_at"):
|
||||||
|
setattr(ws, key, value)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(ws)
|
||||||
|
return ws
|
||||||
|
|
||||||
|
async def delete_workspace(self, workspace_id: UUID, user_id: UUID) -> bool:
|
||||||
|
ws = await self.db.get(Workspace, workspace_id)
|
||||||
|
if not ws:
|
||||||
|
return False
|
||||||
|
if ws.owner_id != user_id:
|
||||||
|
raise WorkspaceError("Only the owner can delete workspace")
|
||||||
|
if ws.type == "personal":
|
||||||
|
raise WorkspaceError("Cannot delete personal workspace")
|
||||||
|
await self.db.delete(ws)
|
||||||
|
await self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
# ── Members ──
|
||||||
|
|
||||||
|
async def add_member(
|
||||||
|
self, workspace_id: UUID, user_id: UUID, role: str = "member"
|
||||||
|
) -> WorkspaceMembership:
|
||||||
|
"""Add a user to a workspace (tariff-gated for member count)."""
|
||||||
|
await self._check_member_limit(workspace_id)
|
||||||
|
|
||||||
|
existing = await self.db.execute(
|
||||||
|
select(WorkspaceMembership).where(
|
||||||
|
WorkspaceMembership.workspace_id == workspace_id,
|
||||||
|
WorkspaceMembership.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise WorkspaceError("User is already a member of this workspace")
|
||||||
|
|
||||||
|
membership = WorkspaceMembership(
|
||||||
|
workspace_id=workspace_id, user_id=user_id, role=role
|
||||||
|
)
|
||||||
|
self.db.add(membership)
|
||||||
|
await self.db.commit()
|
||||||
|
await self.db.refresh(membership)
|
||||||
|
return membership
|
||||||
|
|
||||||
|
async def remove_member(self, workspace_id: UUID, user_id: UUID) -> bool:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(WorkspaceMembership).where(
|
||||||
|
WorkspaceMembership.workspace_id == workspace_id,
|
||||||
|
WorkspaceMembership.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
membership = result.scalar_one_or_none()
|
||||||
|
if not membership:
|
||||||
|
return False
|
||||||
|
await self.db.delete(membership)
|
||||||
|
await self.db.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def list_members(self, workspace_id: UUID) -> list[dict[str, Any]]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(WorkspaceMembership, User)
|
||||||
|
.join(User, WorkspaceMembership.user_id == User.id)
|
||||||
|
.where(WorkspaceMembership.workspace_id == workspace_id)
|
||||||
|
)
|
||||||
|
rows = result.all()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": str(m.WorkspaceMembership.id),
|
||||||
|
"user_id": str(m.User.id),
|
||||||
|
"workspace_id": str(m.WorkspaceMembership.workspace_id),
|
||||||
|
"role": m.WorkspaceMembership.role,
|
||||||
|
"email": m.User.email,
|
||||||
|
"display_name": m.User.display_name,
|
||||||
|
"joined_at": m.WorkspaceMembership.created_at,
|
||||||
|
}
|
||||||
|
for m in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_membership(
|
||||||
|
self, workspace_id: UUID, user_id: UUID
|
||||||
|
) -> Optional[WorkspaceMembership]:
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(WorkspaceMembership).where(
|
||||||
|
WorkspaceMembership.workspace_id == workspace_id,
|
||||||
|
WorkspaceMembership.user_id == user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
JWT_KEY=$(openssl rand -hex 64)
|
||||||
|
JWT_RESET_KEY=$(openssl rand -hex 64)
|
||||||
|
ENC_KEY=$(openssl rand -hex 32)
|
||||||
|
DB_PASS=$(openssl rand -hex 16)
|
||||||
|
|
||||||
|
cat << EOF
|
||||||
|
JWT_SECRET_KEY=$JWT_KEY
|
||||||
|
JWT_RESET_SECRET_KEY=$JWT_RESET_KEY
|
||||||
|
ENCRYPTION_KEY=$ENC_KEY
|
||||||
|
DB_PASS=$DB_PASS
|
||||||
|
EOF
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
TOKEN="2608ab5b7a8965190ab4d8503a8197a47b75b42c"
|
||||||
|
GITEA_URL="http://localhost:3000"
|
||||||
|
|
||||||
|
echo "Creating repository 'voidea' in Gitea..."
|
||||||
|
|
||||||
|
RESPONSE=$(curl -s -X POST "$GITEA_URL/api/v1/user/repos" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: token $TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"name": "voidea",
|
||||||
|
"description": "VoIdeaAI - voice-first AI idea assistant",
|
||||||
|
"private": false,
|
||||||
|
"auto_init": false,
|
||||||
|
"default_branch": "master"
|
||||||
|
}')
|
||||||
|
|
||||||
|
echo "Response: $RESPONSE"
|
||||||
|
|
||||||
|
if echo "$RESPONSE" | grep -q '"id"'; then
|
||||||
|
echo "Repository created successfully!"
|
||||||
|
echo "Clone URL: http://localhost:3000/angel/voidea.git"
|
||||||
|
echo "Web URL: http://git.voideaai.ru/angel/voidea"
|
||||||
|
else
|
||||||
|
echo "Failed to create repository."
|
||||||
|
fi
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: gitea
|
||||||
|
POSTGRES_USER: gitea
|
||||||
|
POSTGRES_PASSWORD: ${GITEA_DB_PASS:-gitea_pass}
|
||||||
|
command: -p 5433
|
||||||
|
volumes:
|
||||||
|
- gitea-db:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U gitea -h localhost -p 5433"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
gitea:
|
||||||
|
image: gitea/gitea:latest-rootless
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
GITEA__database__DB_TYPE: postgres
|
||||||
|
GITEA__database__HOST: localhost:5433
|
||||||
|
GITEA__database__NAME: gitea
|
||||||
|
GITEA__database__USER: gitea
|
||||||
|
GITEA__database__PASSWD: ${GITEA_DB_PASS:-gitea_pass}
|
||||||
|
GITEA__server__DOMAIN: git.voideaai.ru
|
||||||
|
GITEA__server__HTTP_PORT: 3000
|
||||||
|
GITEA__server__ROOT_URL: http://git.voideaai.ru:3000
|
||||||
|
GITEA__server__SSH_DOMAIN: git.voideaai.ru
|
||||||
|
GITEA__server__SSH_PORT: 22
|
||||||
|
volumes:
|
||||||
|
- gitea-data:/var/lib/gitea
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
gitea-db:
|
||||||
|
gitea-data:
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name git.voideaai.ru;
|
||||||
|
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name git.voideaai.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/live/git.voideaai.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/live/git.voideaai.ru/privkey.pem;
|
||||||
|
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# git.voideaai.ru -> Gitea (shared instance with git.aegisone.ru)
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name git.voideaai.ru;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name git.voideaai.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/live/voideaai.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/live/voideaai.ru/privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
client_max_body_size 512M;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "Copying nginx configs..."
|
||||||
|
sudo cp /home/angel/voideaai.ru.conf /opt/projects/nginx-proxy/conf.d/
|
||||||
|
sudo cp /home/angel/git.voideaai.ru.conf /opt/projects/nginx-proxy/conf.d/
|
||||||
|
echo "Configs copied."
|
||||||
|
|
||||||
|
echo "Testing nginx config..."
|
||||||
|
docker compose -p nginx-proxy exec nginx nginx -t
|
||||||
|
|
||||||
|
echo "Reloading nginx..."
|
||||||
|
docker compose -p nginx-proxy exec nginx nginx -s reload
|
||||||
|
echo "Nginx reloaded successfully."
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# voideaai.ru -> VoIdeaAI FastAPI
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name voideaai.ru www.voideaai.ru;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name voideaai.ru www.voideaai.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/live/voideaai.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/live/voideaai.ru/privkey.pem;
|
||||||
|
|
||||||
|
ssl_protocols TLSv1.2 TLSv1.3;
|
||||||
|
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||||
|
ssl_prefer_server_ciphers on;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_cache_valid 200 365d;
|
||||||
|
expires 365d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /icons/ {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_cache_valid 200 365d;
|
||||||
|
expires 365d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name voideaai.ru www.voideaai.ru;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
http2 on;
|
||||||
|
server_name voideaai.ru www.voideaai.ru;
|
||||||
|
|
||||||
|
ssl_certificate /etc/nginx/certs/live/voideaai.ru/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/nginx/certs/live/voideaai.ru/privkey.pem;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=63072000" always;
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "DENY" always;
|
||||||
|
add_header X-XSS-Protection "1; mode=block" always;
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_cache_valid 200 365d;
|
||||||
|
expires 365d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /icons/ {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_cache_valid 200 365d;
|
||||||
|
expires 365d;
|
||||||
|
add_header Cache-Control "public, immutable";
|
||||||
|
}
|
||||||
|
|
||||||
|
location /api/ {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /ws/ {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8020;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_buffering off;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
network: host
|
||||||
|
network_mode: host
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
db:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
volumes:
|
||||||
|
- ./logs:/app/logs
|
||||||
|
|
||||||
|
worker:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
network: host
|
||||||
|
network_mode: host
|
||||||
|
command: celery -A app.tasks worker -l info --concurrency=2
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
beat:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
network: host
|
||||||
|
network_mode: host
|
||||||
|
command: celery -A app.tasks beat -l info
|
||||||
|
env_file: .env
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
- redis
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
network_mode: host
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: voidea
|
||||||
|
POSTGRES_USER: voidea
|
||||||
|
POSTGRES_PASSWORD: ${DB_PASS:-voidea_pass}
|
||||||
|
command: -p 5444
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U voidea -h localhost -p 5444"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
network_mode: host
|
||||||
|
command: redis-server --port 6380
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
+4
-1
@@ -19,9 +19,12 @@
|
|||||||
- Owner защищён от изменений
|
- Owner защищён от изменений
|
||||||
|
|
||||||
### Агенты
|
### Агенты
|
||||||
- Просмотр списка агентов
|
- Просмотр списка агентов (13 + Meta-agent)
|
||||||
- Включение/отключение агентов
|
- Включение/отключение агентов
|
||||||
- Редактирование описания
|
- Редактирование описания
|
||||||
|
- **Meta-agent** — конфигурация JSON (cron_schedule, weights, sources_count, auto_create)
|
||||||
|
- Ручной запуск любого агента кнопкой «Запустить»
|
||||||
|
- Meta-agent по умолчанию запускается по расписанию `0 9 * * 1,4` (пн и чт в 9:00)
|
||||||
|
|
||||||
### Журналы
|
### Журналы
|
||||||
- Фильтрация по уровню (DEBUG, INFO, WARNING, ERROR)
|
- Фильтрация по уровню (DEBUG, INFO, WARNING, ERROR)
|
||||||
|
|||||||
@@ -0,0 +1,214 @@
|
|||||||
|
# VoIdeaAI — Инструкция для AI-разработчика (локальная разработка + деплой)
|
||||||
|
|
||||||
|
## Назначение
|
||||||
|
Эта инструкция — для AI-агента, который продолжает разработку проекта VoIdeaAI.
|
||||||
|
Работа ведётся **локально**, деплой — отправкой на сервер.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Архитектура проекта
|
||||||
|
|
||||||
|
```
|
||||||
|
voidea/
|
||||||
|
├── app/ # Backend (Python FastAPI)
|
||||||
|
│ ├── api/v1/ # HTTP endpoints
|
||||||
|
│ ├── agents/ # Системные агенты
|
||||||
|
│ ├── core/ # Конфиг, БД, security
|
||||||
|
│ ├── integrations/ # AI, OAuth, Telegram, Calendar
|
||||||
|
│ ├── models/ # SQLAlchemy модели
|
||||||
|
│ ├── schemas/ # Pydantic схемы
|
||||||
|
│ ├── services/ # Бизнес-логика
|
||||||
|
│ └── tasks/ # Celery задачи
|
||||||
|
├── webui/ # Frontend (React + Vite + Tailwind)
|
||||||
|
├── flutter/ # Mobile app (Flutter, отдельная разработка)
|
||||||
|
├── deploy/ # Файлы деплоя
|
||||||
|
│ ├── gitea/ # Docker Compose для Gitea
|
||||||
|
│ ├── images/ # Docker образы (tar)
|
||||||
|
│ ├── voideaai.nginx.conf
|
||||||
|
│ └── voidea-api.service / voidea-worker.service / voidea-beat.service (systemd — legacy)
|
||||||
|
├── docs/instructions/ # Инструкции для AI
|
||||||
|
├── Dockerfile # Multi-stage сборка (frontend + backend)
|
||||||
|
├── docker-compose.yml # 4 сервиса: app, worker, db, redis
|
||||||
|
└── .env.production # Шаблон .env для сервера
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Технологический стек
|
||||||
|
|
||||||
|
| Компонент | Технология |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Backend | Python 3.12, FastAPI, SQLAlchemy async |
|
||||||
|
| Frontend | React 18, Vite, Tailwind CSS, TypeScript |
|
||||||
|
| Mobile | Flutter (на сервер НЕ деплоится) |
|
||||||
|
| Database | PostgreSQL 16 |
|
||||||
|
| Queue | Celery + Redis 7 |
|
||||||
|
| Auth | JWT (access + refresh) |
|
||||||
|
| AI | Yandex GPT, GigaChat (fallback chain) |
|
||||||
|
| Server | Ubuntu 24.04, Docker Compose, хост-нетворкинг |
|
||||||
|
|
||||||
|
## 3. Локальный запуск для разработки
|
||||||
|
|
||||||
|
### 3.1 Backend
|
||||||
|
```bash
|
||||||
|
# Виртуальное окружение
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
|
||||||
|
# Зависимости
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
# PostgreSQL (локально или через Docker)
|
||||||
|
# БД: voidea, user: voidea, pass: voidea_pass, port: 5432
|
||||||
|
|
||||||
|
# Миграции
|
||||||
|
alembic upgrade head
|
||||||
|
|
||||||
|
# Запуск
|
||||||
|
uvicorn app.main:app --reload --port 8020
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Frontend
|
||||||
|
```bash
|
||||||
|
cd webui
|
||||||
|
npm ci
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Mobile (Flutter)
|
||||||
|
```bash
|
||||||
|
cd flutter
|
||||||
|
flutter pub get
|
||||||
|
flutter run
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Процесс деплоя
|
||||||
|
|
||||||
|
**Важно: на сервере НЕ dev-окружение. Все правки — локально, затем деплой.**
|
||||||
|
|
||||||
|
### Основной сценарий
|
||||||
|
```bash
|
||||||
|
# 1. Внести изменения локально
|
||||||
|
# 2. Закоммитить
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: описание изменения"
|
||||||
|
|
||||||
|
# 3. Запушить в Gitea
|
||||||
|
git push origin master
|
||||||
|
|
||||||
|
# 4. На сервере
|
||||||
|
ssh angel@81.177.141.34
|
||||||
|
cd /opt/projects/voidea
|
||||||
|
|
||||||
|
# 4a. Получить изменения
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# 4b. Пересобрать и перезапустить
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 4c. Проверить
|
||||||
|
curl -s http://localhost:8020/health
|
||||||
|
```
|
||||||
|
|
||||||
|
### Миграции БД
|
||||||
|
```bash
|
||||||
|
# Накатить
|
||||||
|
docker compose exec app alembic upgrade head
|
||||||
|
|
||||||
|
# Откатить
|
||||||
|
docker compose exec app alembic downgrade -1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Просмотр логов
|
||||||
|
```bash
|
||||||
|
docker compose logs --tail=50 app
|
||||||
|
docker compose logs --tail=50 worker
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Структура .env на сервере
|
||||||
|
|
||||||
|
Файл `.env` в `/opt/projects/voidea/.env`.
|
||||||
|
Шаблон — `.env.production` в корне репозитория.
|
||||||
|
|
||||||
|
**Важные отличия от .env.example:**
|
||||||
|
- `DATABASE_URL` использует порт **5444** (а не 5432)
|
||||||
|
- `REDIS_URL` использует порт **6380** (а не 6379)
|
||||||
|
- Порты изменены, чтобы не конфликтовать с проектом aegisone на том же сервере
|
||||||
|
|
||||||
|
## 6. Важные ограничения
|
||||||
|
|
||||||
|
### Серверные (VPS)
|
||||||
|
- **Bridge-сеть не работает** — только `network_mode: host`
|
||||||
|
- **Docker build** всегда с `network: host`
|
||||||
|
- **Docker Hub** может таймаутить на больших образах (>100MB) — используйте `docker save`/`docker load`
|
||||||
|
- Всего 20GB диска, ~13GB свободно. Следите за `df -h /`
|
||||||
|
|
||||||
|
### Кодовые
|
||||||
|
- Не трогать `.env` на сервере (секреты)
|
||||||
|
- `flutter/` — только локальная разработка, в `.dockerignore`, на сервер не деплоится
|
||||||
|
- `webui/node_modules/` — в `.gitignore`, не коммитить
|
||||||
|
- Миграции БД — через `alembic`, не вручную
|
||||||
|
|
||||||
|
## 7. Docker-образы
|
||||||
|
|
||||||
|
Образы хранятся в `deploy/images/` в формате `.tar`.
|
||||||
|
Список используемых образов:
|
||||||
|
- `postgres:16-alpine` — voidea + gitea
|
||||||
|
- `redis:7-alpine` — voidea
|
||||||
|
- `gitea/gitea:latest-rootless` — Gitea
|
||||||
|
- Образ самого приложения собирается из `Dockerfile`
|
||||||
|
|
||||||
|
Для обновления образов на сервере:
|
||||||
|
```bash
|
||||||
|
# Локально: скачать и запаковать
|
||||||
|
docker pull postgres:16-alpine
|
||||||
|
docker save postgres:16-alpine -o deploy/images/postgres.tar
|
||||||
|
scp deploy/images/postgres.tar angel@81.177.141.34:/home/angel/
|
||||||
|
|
||||||
|
# На сервере:
|
||||||
|
docker load -i /home/angel/postgres.tar
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Полезные команды на сервере
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Статус проектов
|
||||||
|
docker compose -p voidea ps
|
||||||
|
docker compose -p gitea ps
|
||||||
|
docker compose -p nginx-proxy ps
|
||||||
|
|
||||||
|
# Логи VoIdea
|
||||||
|
docker compose -p voidea logs --tail=100 -f app
|
||||||
|
|
||||||
|
# Перезапуск конкретного сервиса
|
||||||
|
docker compose -p voidea restart app
|
||||||
|
|
||||||
|
# Полная пересборка
|
||||||
|
docker compose -p voidea up -d --build
|
||||||
|
|
||||||
|
# Очистка кеша Docker
|
||||||
|
docker builder prune -af
|
||||||
|
docker system prune -af
|
||||||
|
|
||||||
|
# Проверка nginx
|
||||||
|
docker compose -p nginx-proxy exec nginx nginx -t
|
||||||
|
docker compose -p nginx-proxy exec nginx nginx -s reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Порты на сервере
|
||||||
|
|
||||||
|
| Порт | Сервис | Проект |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 8020 | FastAPI | VoIdea |
|
||||||
|
| 5444 | PostgreSQL | VoIdea |
|
||||||
|
| 6380 | Redis | VoIdea |
|
||||||
|
| 3000 | Gitea | Gitea |
|
||||||
|
| 5433 | PostgreSQL | Gitea |
|
||||||
|
| 80/443 | nginx-proxy | Общий |
|
||||||
|
| 8000 | FastAPI | aegisone (чужой) |
|
||||||
|
| 5432 | PostgreSQL | aegisone (чужой) |
|
||||||
|
|
||||||
|
Не занимать порты, помеченные как чужие.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Обновлён: 2026-05-19*
|
||||||
+42
-1
@@ -53,6 +53,47 @@ VoIdeaAI имеет Telegram бота для быстрого создания
|
|||||||
- `/idea <текст>` — создать новую идею
|
- `/idea <текст>` — создать новую идею
|
||||||
- `/help` — справка
|
- `/help` — справка
|
||||||
|
|
||||||
|
## Воронка идей (Idea Funnel)
|
||||||
|
|
||||||
|
Отслеживайте прогресс идей по воронке:
|
||||||
|
|
||||||
|
1. **Сырая (raw)** — только что созданная идея
|
||||||
|
2. **Подтверждённая (validated)** — идея прошла первичную оценку
|
||||||
|
3. **Бэклог (backlog)** — запланирована к реализации
|
||||||
|
4. **В работе (in_progress)** — активная работа
|
||||||
|
5. **Запущена (launched)** — реализована
|
||||||
|
6. **Ретроспектива (retrospective)** — анализ результатов
|
||||||
|
|
||||||
|
Переключайтесь между **Списком** и **Канбан-доской** на дашборде. Перемещайте идеи по воронке кнопками ← → в карточках канбана или через API.
|
||||||
|
|
||||||
|
## Рабочие пространства (Workspaces)
|
||||||
|
|
||||||
|
Личное рабочее пространство создаётся автоматически при регистрации. Командные пространства доступны по тарифу:
|
||||||
|
|
||||||
|
- **Personal** — 1 на пользователя, всегда бесплатно
|
||||||
|
- **Team** — создаются владельцем, участники добавляются по email
|
||||||
|
- Переключайтесь между пространствами через выпадающий список в шапке
|
||||||
|
- Управляйте участниками в настройках пространства
|
||||||
|
|
||||||
|
## Совместная работа (Collaboration)
|
||||||
|
|
||||||
|
На странице идеи доступны:
|
||||||
|
|
||||||
|
- **Голосование** — 👍/👎 оценивайте идеи
|
||||||
|
- **Комментарии** — threaded обсуждения с ответами
|
||||||
|
- **Активность** — лента изменений и действий
|
||||||
|
- **Уведомления** — колокольчик в шапке, отметки о прочитанном
|
||||||
|
|
||||||
|
## Интеграция с календарём
|
||||||
|
|
||||||
|
Добавляйте идеи в календарь одним нажатием кнопки «+ Календарь» на странице идеи:
|
||||||
|
|
||||||
|
- **Google Calendar** — через OAuth (требуется scope calendar.events)
|
||||||
|
- **Yandex Календарь** — через OAuth (требуется scope calendar:event.write)
|
||||||
|
- **Apple Calendar (CalDAV)** — в разработке
|
||||||
|
|
||||||
|
Подключите календарь в Настройки → Интеграции → Календарь.
|
||||||
|
|
||||||
## Настройки
|
## Настройки
|
||||||
|
|
||||||
В разделе «Настройки» доступно:
|
В разделе «Настройки» доступно:
|
||||||
@@ -60,5 +101,5 @@ VoIdeaAI имеет Telegram бота для быстрого создания
|
|||||||
- Безопасность (смена пароля, OAuth привязка)
|
- Безопасность (смена пароля, OAuth привязка)
|
||||||
- Голос (настройки микрофона, TTS)
|
- Голос (настройки микрофона, TTS)
|
||||||
- Тема (светлая/тёмная)
|
- Тема (светлая/тёмная)
|
||||||
- Интеграции (Яндекс.Диск)
|
- Интеграции (Яндекс.Диск, Календарь)
|
||||||
- Тариф (информация о подписке)
|
- Тариф (информация о подписке)
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
// Flutter web plugin registrant file.
|
||||||
|
//
|
||||||
|
// Generated file. Do not edit.
|
||||||
|
//
|
||||||
|
|
||||||
|
// @dart = 2.13
|
||||||
|
// ignore_for_file: type=lint
|
||||||
|
|
||||||
|
import 'package:connectivity_plus/src/connectivity_plus_web.dart';
|
||||||
|
import 'package:flutter_native_splash/flutter_native_splash_web.dart';
|
||||||
|
import 'package:flutter_secure_storage_web/flutter_secure_storage_web.dart';
|
||||||
|
import 'package:package_info_plus/src/package_info_plus_web.dart';
|
||||||
|
import 'package:share_plus/src/share_plus_web.dart';
|
||||||
|
import 'package:speech_to_text/speech_to_text_web.dart';
|
||||||
|
import 'package:url_launcher_web/url_launcher_web.dart';
|
||||||
|
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
|
||||||
|
|
||||||
|
void registerPlugins([final Registrar? pluginRegistrar]) {
|
||||||
|
final Registrar registrar = pluginRegistrar ?? webPluginRegistrar;
|
||||||
|
ConnectivityPlusWebPlugin.registerWith(registrar);
|
||||||
|
FlutterNativeSplashWeb.registerWith(registrar);
|
||||||
|
FlutterSecureStorageWeb.registerWith(registrar);
|
||||||
|
PackageInfoPlusWebPlugin.registerWith(registrar);
|
||||||
|
SharePlusWebPlugin.registerWith(registrar);
|
||||||
|
SpeechToTextPlugin.registerWith(registrar);
|
||||||
|
UrlLauncherPlugin.registerWith(registrar);
|
||||||
|
registrar.registerMessageHandler();
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\dart_build_result.json: C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml","d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\dart_build_result.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\dart_build_result.json"]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"build_start":"2026-05-15T12:29:51.171303","build_end":"2026-05-15T12:30:01.542917","dependencies":["file:///C:/Users/angel/flutter/bin/cache/dart-sdk/version","file:///D:/YandexDisk/voidea/flutter/.dart_tool/package_config.json","file:///D:/YandexDisk/voidea/flutter/pubspec.yaml","file:///d:/yandexdisk/voidea/flutter/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]}
|
||||||
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
{"inputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\dart_plugin_registrant.dart"]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"inputs":[],"outputs":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\native_assets.json:
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\native_assets.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\25778e38fa3d526585b83317535421ec\\native_assets.json"]}
|
||||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"format-version":[1,0,0],"native-assets":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
["D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\vm_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\isolate_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\kernel_blob.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\fonts\\MaterialIcons-Regular.otf","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\ink_sparkle.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\stretch_effect.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\AssetManifest.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\FontManifest.json","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NOTICES.Z","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NativeAssetsManifest.json"]
|
||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
@@ -0,0 +1 @@
|
|||||||
|
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\dart_build_result.json: C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","D:\\YandexDisk\\voidea\\flutter\\pubspec.yaml","d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\dart_build_result.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\dart_build_result.json"]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"build_start":"2026-05-15T12:32:59.437301","build_end":"2026-05-15T12:33:16.643706","dependencies":["file:///C:/Users/angel/flutter/bin/cache/dart-sdk/version","file:///D:/YandexDisk/voidea/flutter/.dart_tool/package_config.json","file:///D:/YandexDisk/voidea/flutter/pubspec.yaml","file:///d:/yandexdisk/voidea/flutter/.dart_tool/package_config.json"],"code_assets":[],"data_assets":[]}
|
||||||
+1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
@@ -0,0 +1 @@
|
|||||||
|
{"inputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\dart_plugin_registrant.dart"]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"inputs":[],"outputs":[]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\native_assets.json:
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"inputs":["C:\\Users\\angel\\flutter\\packages\\flutter_tools\\lib\\src\\build_system\\targets\\native_assets.dart","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json"],"outputs":["D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\native_assets.json","D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\flutter_build\\2b318bb073ec36e02164ec9484e62b1a\\native_assets.json"]}
|
||||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
|||||||
|
{"format-version":[1,0,0],"native-assets":{}}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
["D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\vm_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\isolate_snapshot_data","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\kernel_blob.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\fonts\\MaterialIcons-Regular.otf","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\ink_sparkle.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\shaders\\stretch_effect.frag","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\AssetManifest.bin","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\FontManifest.json","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NOTICES.Z","D:\\YandexDisk\\voidea\\flutter\\build\\app\\intermediates\\flutter\\debug\\flutter_assets\\NativeAssetsManifest.json"]
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
//
|
||||||
|
// Generated file. Do not edit.
|
||||||
|
// This file is generated from template in file `flutter_tools/lib/src/flutter_plugins.dart`.
|
||||||
|
//
|
||||||
|
|
||||||
|
// @dart = 3.4
|
||||||
|
|
||||||
|
import 'dart:io'; // flutter_ignore: dart_io_import.
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart' as flutter_local_notifications;
|
||||||
|
import 'package:path_provider_android/path_provider_android.dart' as path_provider_android;
|
||||||
|
import 'package:sqflite_android/sqflite_android.dart' as sqflite_android;
|
||||||
|
import 'package:url_launcher_android/url_launcher_android.dart' as url_launcher_android;
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart' as flutter_local_notifications;
|
||||||
|
import 'package:path_provider_foundation/path_provider_foundation.dart' as path_provider_foundation;
|
||||||
|
import 'package:sqflite_darwin/sqflite_darwin.dart' as sqflite_darwin;
|
||||||
|
import 'package:url_launcher_ios/url_launcher_ios.dart' as url_launcher_ios;
|
||||||
|
import 'package:connectivity_plus/connectivity_plus.dart' as connectivity_plus;
|
||||||
|
import 'package:flutter_local_notifications_linux/flutter_local_notifications_linux.dart' as flutter_local_notifications_linux;
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart' as package_info_plus;
|
||||||
|
import 'package:path_provider_linux/path_provider_linux.dart' as path_provider_linux;
|
||||||
|
import 'package:share_plus/share_plus.dart' as share_plus;
|
||||||
|
import 'package:url_launcher_linux/url_launcher_linux.dart' as url_launcher_linux;
|
||||||
|
import 'package:flutter_local_notifications/flutter_local_notifications.dart' as flutter_local_notifications;
|
||||||
|
import 'package:path_provider_foundation/path_provider_foundation.dart' as path_provider_foundation;
|
||||||
|
import 'package:sqflite_darwin/sqflite_darwin.dart' as sqflite_darwin;
|
||||||
|
import 'package:url_launcher_macos/url_launcher_macos.dart' as url_launcher_macos;
|
||||||
|
import 'package:flutter_secure_storage_windows/flutter_secure_storage_windows.dart' as flutter_secure_storage_windows;
|
||||||
|
import 'package:package_info_plus/package_info_plus.dart' as package_info_plus;
|
||||||
|
import 'package:path_provider_windows/path_provider_windows.dart' as path_provider_windows;
|
||||||
|
import 'package:share_plus/share_plus.dart' as share_plus;
|
||||||
|
import 'package:speech_to_text_windows/speech_to_text_windows.dart' as speech_to_text_windows;
|
||||||
|
import 'package:url_launcher_windows/url_launcher_windows.dart' as url_launcher_windows;
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
class _PluginRegistrant {
|
||||||
|
|
||||||
|
@pragma('vm:entry-point')
|
||||||
|
static void register() {
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
try {
|
||||||
|
flutter_local_notifications.AndroidFlutterLocalNotificationsPlugin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`flutter_local_notifications` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
path_provider_android.PathProviderAndroid.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`path_provider_android` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqflite_android.SqfliteAndroid.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`sqflite_android` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
url_launcher_android.UrlLauncherAndroid.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`url_launcher_android` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (Platform.isIOS) {
|
||||||
|
try {
|
||||||
|
flutter_local_notifications.IOSFlutterLocalNotificationsPlugin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`flutter_local_notifications` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
path_provider_foundation.PathProviderFoundation.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`path_provider_foundation` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqflite_darwin.SqfliteDarwin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`sqflite_darwin` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
url_launcher_ios.UrlLauncherIOS.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`url_launcher_ios` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (Platform.isLinux) {
|
||||||
|
try {
|
||||||
|
connectivity_plus.ConnectivityPlusLinuxPlugin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`connectivity_plus` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
flutter_local_notifications_linux.LinuxFlutterLocalNotificationsPlugin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`flutter_local_notifications_linux` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
package_info_plus.PackageInfoPlusLinuxPlugin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`package_info_plus` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
path_provider_linux.PathProviderLinux.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`path_provider_linux` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
share_plus.SharePlusLinuxPlugin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`share_plus` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
url_launcher_linux.UrlLauncherLinux.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`url_launcher_linux` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (Platform.isMacOS) {
|
||||||
|
try {
|
||||||
|
flutter_local_notifications.MacOSFlutterLocalNotificationsPlugin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`flutter_local_notifications` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
path_provider_foundation.PathProviderFoundation.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`path_provider_foundation` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
sqflite_darwin.SqfliteDarwin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`sqflite_darwin` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
url_launcher_macos.UrlLauncherMacOS.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`url_launcher_macos` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (Platform.isWindows) {
|
||||||
|
try {
|
||||||
|
flutter_secure_storage_windows.FlutterSecureStorageWindows.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`flutter_secure_storage_windows` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
package_info_plus.PackageInfoPlusWindowsPlugin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`package_info_plus` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
path_provider_windows.PathProviderWindows.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`path_provider_windows` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
share_plus.SharePlusWindowsPlugin.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`share_plus` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
speech_to_text_windows.SpeechToTextWindows.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`speech_to_text_windows` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
url_launcher_windows.UrlLauncherWindows.registerWith();
|
||||||
|
} catch (err) {
|
||||||
|
print(
|
||||||
|
'`url_launcher_windows` threw an error: $err. '
|
||||||
|
'The app may not function as expected until you remove this plugin from pubspec.yaml'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Last acquired by C:\Users\angel\flutter\bin\cache\dart-sdk\bin\dart.exe (pid 48536) running file:///C:/Users/angel/flutter/bin/cache/flutter_tools.snapshot on 2026-05-15 12:33:08.791541.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"file_system":[],"environment":[{"key":"PATH","hash":5744524113912922215},{"key":"PROGRAMDATA","hash":-3479985355006727299},{"key":"SYSTEMDRIVE","hash":2892017696301558487},{"key":"SYSTEMROOT","hash":2713241238173206065},{"key":"TEMP","hash":-5046929535283615468},{"key":"TMP","hash":-5046929535283615468},{"key":"USERPROFILE","hash":-6023473899643495512},{"key":"WINDIR","hash":2713241238173206065}]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"file_system":[{"path":"d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","hash":-1483076285095613998}],"environment":[]}
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"assets": {},
|
||||||
|
"config": {
|
||||||
|
"build_asset_types": [
|
||||||
|
"code_assets/code"
|
||||||
|
],
|
||||||
|
"extensions": {
|
||||||
|
"code_assets": {
|
||||||
|
"android": {
|
||||||
|
"target_ndk_api": 24
|
||||||
|
},
|
||||||
|
"c_compiler": {
|
||||||
|
"ar": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\llvm-ar.exe",
|
||||||
|
"cc": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\clang.exe",
|
||||||
|
"ld": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\ld.lld.exe"
|
||||||
|
},
|
||||||
|
"link_mode_preference": "dynamic",
|
||||||
|
"target_architecture": "arm64",
|
||||||
|
"target_os": "android"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linking_enabled": false
|
||||||
|
},
|
||||||
|
"out_dir_shared": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\shared\\objective_c\\build\\",
|
||||||
|
"out_file": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\objective_c\\193e397ea4\\output.json",
|
||||||
|
"package_name": "objective_c",
|
||||||
|
"package_root": "C:\\Users\\angel\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\objective_c-9.3.0\\",
|
||||||
|
"user_defines": {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"assets_for_linking": {},
|
||||||
|
"status": "success",
|
||||||
|
"timestamp": "2026-05-15 12:30:00.000"
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Running `(cd C:\Users\angel\AppData\Local\Pub\Cache\hosted\pub.dev\objective_c-9.3.0\; PATH=C:\Program Files\Android\Android Studio\jbr\bin;C:\Python\Python38-32\Scripts\;C:\Python\Python38-32\;C:\ProgramData\Oracle\Java\javapath;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files (x86)\LonWorks\bin;C:\Users\angel\AppData\Local\Microsoft\WindowsApps;C:\adb;D:\BatchApkTool;C:\WINDOWS\system32;C:\WINDOWS;C:\WINDOWS\System32\Wbem;C:\WINDOWS\System32\WindowsPowerShell\v1.0\;C:\WINDOWS\System32\OpenSSH\;C:\Program Files (x86)\Microsoft SQL Server\110\Tools\Binn\;C:\Program Files (x86)\Microsoft SQL Server\110\DTS\Binn\;C:\Program Files (x86)\Windows Kits\8.1\Windows Performance Toolkit\;C:\Program Files\Intel\WiFi\bin\;C:\Program Files\Common Files\Intel\WirelessCommon\;C:\Program Files (x86)\Microsoft SQL Server\150\DTS\Binn\;C:\Program Files\Git\cmd;C:\Program Files\nodejs\;C:\Users\angel\AppData\Local\Microsoft\WindowsApps;C:\Program Files\Intel\WiFi\bin\;C:\Program Files\Common Files\Intel\WirelessCommon\;C:\Users\angel\AppData\Roaming\npm;C:\Users\angel\AppData\Local\Python\bin;C:\Users\angel\AppData\Local\Programs\Microsoft VS Code\bin;C:\Users\angel\flutter\bin PROGRAMDATA=C:\ProgramData SYSTEMDRIVE=C: SYSTEMROOT=C:\WINDOWS TEMP=C:\Users\angel\AppData\Local\Temp TMP=C:\Users\angel\AppData\Local\Temp USERPROFILE=C:\Users\angel WINDIR=C:\WINDOWS C:\Users\angel\flutter\bin\cache\dart-sdk\bin\dart --packages=D:\YandexDisk\voidea\flutter\.dart_tool\package_config.json D:\YandexDisk\voidea\flutter\.dart_tool\hooks_runner\objective_c\193e397ea4\hook.dill --config=D:\YandexDisk\voidea\flutter\.dart_tool\hooks_runner\objective_c\193e397ea4\input.json )`.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
Last acquired by C:\Users\angel\flutter\bin\cache\dart-sdk\bin\dart.exe (pid 48536) running file:///C:/Users/angel/flutter/bin/cache/flutter_tools.snapshot on 2026-05-15 12:32:59.667160.
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"file_system":[],"environment":[{"key":"PATH","hash":5744524113912922215},{"key":"PROGRAMDATA","hash":-3479985355006727299},{"key":"SYSTEMDRIVE","hash":2892017696301558487},{"key":"SYSTEMROOT","hash":2713241238173206065},{"key":"TEMP","hash":-5046929535283615468},{"key":"TMP","hash":-5046929535283615468},{"key":"USERPROFILE","hash":-6023473899643495512},{"key":"WINDIR","hash":2713241238173206065}]}
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
{"file_system":[{"path":"d:\\yandexdisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\package_config.json","hash":4956111781633966257},{"path":"C:\\Users\\angel\\flutter\\bin\\cache\\dart-sdk\\version","hash":-1483076285095613998}],"environment":[]}
|
||||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"assets": {},
|
||||||
|
"config": {
|
||||||
|
"build_asset_types": [
|
||||||
|
"code_assets/code"
|
||||||
|
],
|
||||||
|
"extensions": {
|
||||||
|
"code_assets": {
|
||||||
|
"android": {
|
||||||
|
"target_ndk_api": 24
|
||||||
|
},
|
||||||
|
"c_compiler": {
|
||||||
|
"ar": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\llvm-ar.exe",
|
||||||
|
"cc": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\clang.exe",
|
||||||
|
"ld": "C:\\Users\\angel\\AppData\\Local\\Android\\sdk\\ndk\\28.2.13676358\\toolchains\\llvm\\prebuilt\\windows-x86_64\\bin\\ld.lld.exe"
|
||||||
|
},
|
||||||
|
"link_mode_preference": "dynamic",
|
||||||
|
"target_architecture": "arm",
|
||||||
|
"target_os": "android"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"linking_enabled": false
|
||||||
|
},
|
||||||
|
"out_dir_shared": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\shared\\objective_c\\build\\",
|
||||||
|
"out_file": "D:\\YandexDisk\\voidea\\flutter\\.dart_tool\\hooks_runner\\objective_c\\4d03ae159a\\output.json",
|
||||||
|
"package_name": "objective_c",
|
||||||
|
"package_root": "C:\\Users\\angel\\AppData\\Local\\Pub\\Cache\\hosted\\pub.dev\\objective_c-9.3.0\\",
|
||||||
|
"user_defines": {}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user