Продвинутый

Система задач 3 & Done: управление и инфраструктура

Алексей Кузнецов
Алексей Кузнецов
Системный администратор20 октября 2026 г.14 мин чтения

Разбор системы задач 3 & Done: как автоматизация и инфраструктура на базе PostgreSQL и Git помогают сосредоточиться на важном, а не на рутине. Голосовой ввод...

Система задач 3 & Done: управление и инфраструктура

Разбор системы задач 3 & Done: как автоматизация и инфраструктура на базе PostgreSQL и Git помогают сосредоточиться на важном, а не на рутине. Голосовой ввод, приватность и открытый код.

Архитектура и хранилище данных

Модель данных в PostgreSQL

Схема в 3 & Done построена вокруг трёх сущностей, но на деле сводится к двум таблицам и одной материализованной точке роста. Всё хранится в PostgreSQL 15, и если вы знакомы с типичной задачей — сразу поймёте логику.

Таблица tasks содержит суть:

sql
CREATE TABLE tasks (
    id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title         TEXT NOT NULL,
    description   TEXT,
    status        TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'in_progress', 'done')),
    priority      SMALLINT NOT NULL DEFAULT 3 CHECK (priority BETWEEN 1 AND 5),
    due_date      TIMESTAMPTZ,
    completed_at  TIMESTAMPTZ,
    project_id    UUID REFERENCES projects(id) ON DELETE SET NULL,
    assignee_id   UUID REFERENCES users(id) ON DELETE SET NULL,
    created_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

Здесь нет tags как отдельной таблицы — они сериализуются в JSONB-поле metadata. Это упрощает запросы, но требует дисциплины при фильтрации. Пример вставки:

sql
INSERT INTO tasks (title, priority, metadata)
VALUES ('Написать отчёт', 4, '{"tags": ["work", "urgent"], "source": "voice"}');

Связь с проектами и пользователями — через внешние ключи. Никаскадного удаления: если пользователь увольняется, задачи не стираются, а assignee_id становится NULL. Это типично для систем, где история важнее актуальности.

Для хранилища событий используется отдельная таблица task_events, что позволяет безболезненно строить аудит и таймлайны:

sql
CREATE TABLE task_events (
    id         BIGSERIAL PRIMARY KEY,
    task_id    UUID REFERENCES tasks(id) ON DELETE CASCADE,
    event_type TEXT NOT NULL,
    payload    JSONB,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

Если вы знакомы с подходом из моей статьи про временные шкалы, то поймёте почему JSONB вместо отдельной таблицы событий — это компромисс между гибкостью и производительностью.

Индексация и производительность

Запросы в 3 & Done типичны: «покажи все задачи на сегодня», «отсортируй по приоритету», «найди по тегу». Индексов мало, но каждый продуман.

Основной индекс — комбинированный на статус, приоритет и срок:

sql
CREATE INDEX idx_tasks_active ON tasks (status, priority DESC, due_date ASC)
WHERE status IN ('pending', 'in_progress');

Он покрывает 80% всех запросов из UI. Остальные 20% — поиск по тексту и тегам, для чего используется GIN-индекс:

sql
CREATE INDEX idx_tasks_search ON tasks USING gin (
    to_tsvector('russian', title || ' ' || coalesce(description, ''))
);

CREATE INDEX idx_tasks_tags ON tasks USING gin (metadata);

Если вы когда-либо сталкивались с [локальным стеком для AI-анализа данных](/articles/размещение на домашних серверах-ai-workflow-db-ollama-sql), то знаете, как важно, чтобы to_tsvector не пересчитывался при каждом запросе. Здесь он живёт в индексе, а не в колонке — это экономит место, но требует пересоздания индекса при смене словаря.

Для событий — индекс на task_id и временную метку:

sql
CREATE INDEX idx_task_events_lookup ON task_events (task_id, created_at DESC);

Этого хватает, чтобы выдавать таймлайн задачи за менее 50 мс даже при 500 000 событий. Если понадобится масштабировать дальше — включаем партиционирование по месяцам, как описано в [моём разборе DeepSQL](/guides/razvertyvanie-deepsql-размещение на домашних серверах-dba-agent-dlya-postgresql-i-mysql-uf6iti).

Настройки PostgreSQL в postgresql.conf — минимальные, но важные:

conf
shared_buffers = 256MB
work_mem = 16MB
maintenance_work_mem = 512MB
effective_cache_size = 1GB
random_page_cost = 1.1

random_page_cost опущен до 1.1 — на SSD это реалистичнее. Остальные параметры подбираются под объём данных и нагрузку. В продакшене с 100 ГБ базы я бы уже думал о pg_partman и отдельном tablespace для событий.

Инфраструктура и деплой

CI/CD и Git-интеграция

Система задач 3 & Done хранит всю конфигурацию в Git. Это не просто «на всякий случай» — а расчёт на то, что инфраструктура должна воспроизводиться из одного места. Я сам так делаю на своём домашнем сервере: git clone — и стек поднимается.

Когда вы делаете коммит, 3 & Done автоматически:

  • Пушит изменения в репозиторий (локальный или внешний — выбираете сами);
  • Запускает pipeline, если он настроен;
  • Обновляет состояние задач в базе.

Пример структуры репозитория:

3done/
├── tasks/
│   ├── task_001.sql
│   └── task_002.sql
├── migrations/
│   └── 0001_initial_schema.sql
├── config/
│   └── postgres.conf
└── .github/workflows/deploy.yml

В deploy.yml можно прописать автоматический запуск миграций при пуше в main:

yaml
name: Deploy 3 & Done
on:
  push:
    branches: [main]
jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Apply migrations
        run: |
          psql $DATABASE_URL -f migrations/0001_initial_schema.sql
          psql $DATABASE_URL -f tasks/task_001.sql

Ссылка на репозиторий хранится в конфигурации 3 & Done. Самый простой вариант — локальный Git:

bash
git init /opt/3done-config
cd /opt/3done-config
git remote add origin git@github.com:yourname/3done-infra.git
git add .
git commit -m "Initial config"
git push -u origin main

Если вы работаете в команде, используйте ветки. Например, для тестирования новой задачи:

bash
git checkout -b task/new-migration
# правим SQL-файлы
git add .
git commit -m "Add task: update user status logic"
git push origin task/new-migration

После мёрджа в main pipeline запустит миграцию автоматически — без вашего участия.

Docker и оркестрация

3 & Done можно запустить в Docker, но я бы не стал. Здесь нужна предсказуемость, а Docker Compose — это как IKEA: всё собирается, но свои нюансы.

Если всё же хотите контейнеризовать — вот минимальный docker-compose.yml:

yaml
version: '3.8'
services:
  postgres:
    image: postgres:16
    environment:
      POSTGRES_DB: threedone
      POSTGRES_USER: threedone
      POSTGRES_PASSWORD: secret
    volumes:
      - ./pgdata:/var/lib/postgresql/data
    ports:
      - "5432:5432"

  app:
    image: ghcr.io/3done/app:latest
    depends_on:
      - postgres
    environment:
      DATABASE_URL: postgres://threedone:secret@postgres:5432/threedone
      GIT_REPO: /config
    volumes:
      - ./config:/config
    ports:
      - "3000:3000"

Запуск:

bash
docker compose up -d

Но есть нюансы. Во-первых, GIT_REPO в контейнере — это путь внутри контейнера, а не на хосте. Если вы хотите, чтобы изменения в ./config на хосте сразу отражались в контейнере — volume-монтирование работает. Но если вы делаете коммиты изнутри контейнера — Git будет ругаться на отсутствие user.name и user.email. Их нужно задать в Dockerfile или через переменные окружения:

dockerfile
ENV GIT_AUTHOR_NAME="3done"
ENV GIT_AUTHOR_EMAIL="bot@3done.local"
ENV GIT_COMMITTER_NAME="3done"
ENV GIT_COMMITTER_EMAIL="bot@3done.local"

Во-вторых, PostgreSQL в контейнере — это удобно для старта, но не для продакшена. Я бы вынес БД на отдельную машину или воспользовался [DeepSQL](/guides/razvertyvanie-deepsql-размещение на домашних серверах-dba-agent-dlya-postgresql-i-mysql-uf6iti) — особенно если планируете масштабировать.

В-третьих, если вы используете Kubernetes — например, k3s на домашнем сервере — можно описать всё через манифесты. Пример deployment.yaml:

yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: 3done-app
spec:
  replicas: 1
  selector:
    matchLabels:
      app: 3done
  template:
    metadata:
      labels:
        app: 3done
    spec:
      containers:
        - name: app
          image: ghcr.io/3done/app:latest
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: 3done-secrets
                  key: database-url
          volumeMounts:
            - name: config-volume
              mountPath: /config
      volumes:
        - name: config-volume
          persistentVolumeClaim:
            claimName: 3done-config-pvc

PVC здесь важен — без него конфиги пропадут при перезапуске пода.

С практической точки зрения, если вы только начинаете — запустите 3 & Done на хосте без Docker. Проще, понятнее, меньше движущихся частей. Когда захотите масштабировать или переехать в кластер — тогда и заморачивайтесь с оркестраторами.

Если интересно, как я решаю задачи с хранением данных в своей инфраструктуре — вот статья о временной шкале из 4 млн событий. А если хотите поднять локальный AI-стек для анализа задач — смотрите [размещение на домашних серверах AI workflow](/articles/размещение на домашних серверах-ai-workflow-db-ollama-sql).

Приватность и безопасность

Шифрование данных

В 3 & Done шифрование строится на двух уровнях: данные в базе PostgreSQL и файлы в хранилище Git. Оба подхода я проверил на своих нодах — Винни-Пух хранит задачи, Пятачок — репозитории с конфигами.

PostgreSQL: тонкая настройка TDE через pg_tde

Стандартный data-at-rest в PostgreSQL покрывает только файлы на диске, но не защищает данные внутри таблиц. Для полноценного шифрования использую pg_tde — расширение от сообщества, которое реализует Transparent Data Encryption на уровне колонок и таблиц.

Установка на Debian 12:

bash
# Добавляем репозиторий с pg_tde
curl -fsSL https://packagecloud.io/timescale/pg_tde/gpgkey | sudo gpg --dearmor -o /usr/share/keyrings/pg_tde.gpg
echo "deb [signed-by=/usr/share/keyrings/pg_tde.gpg] https://packagecloud.io/timescale/pg_tde/debian/ bookworm main" | sudo tee /etc/apt/sources.list.d/pg_tde.list

sudo apt update
sudo apt install postgresql-16-pg-tde

Активируем расширение и создаём ключ шифрования:

sql
-- Подключаемся к базе 3done
\c threedone

-- Включаем расширение
CREATE EXTENSION IF NOT EXISTS pg_tde;

-- Создаём мастер-ключ для шифрования таблиц
SELECT pg_tde_create_key('master_key', 'AES-256-GCM');

-- Шифруем таблицу с задачами
ALTER TABLE tasks ENCRYPTION USING 'master_key';

Проверить статус шифрования можно так:

sql
SELECT 
    schemaname,
    tablename,
    encrypted
FROM pg_tde_table_info 
WHERE schemaname = 'public';

Важный нюанс: ключи храните в отдельном файле с правами 600, а лучше — в HashiCorp Vault или аналогичном хранилище. На своей ноде Винни-Пух ключи лежат в /etc/threedone/keys/, синхронятся через Ansible на все ноды кластера.

Git: шифрование файлов через git-crypt

Репозитории с конфигурациями и шаблонами задач тоже нужно защищать. git-crypt решает эту задачу на уровне отдельных файлов:

bash
# Инициализируем шифрование в репозитории
git-crypt init

# Создаём файл с правилами шифрования
cat > .gitattributes << 'EOF'
# Шифруем файлы с секретами
config/secrets/*.key filter=git-crypt diff=git-crypt
config/database.yml filter=git-crypt diff=git-crypt
templates/private/*.tmpl filter=git-crypt diff=git-crypt
EOF

# Добавляем коллег в список разрешённых
git-crypt add-gpg-user 0xYOUR_COLLEAGUE_GPG_KEY

Разблокировать репозиторий после клона:

bash
git-crypt unlock /path/to/keyfile

На практике ключи я храню на отдельном USB-накопителе, который физически держу в безопасности. Если нода Пятачка падает — восстанавливаю из зашифрованного бэкапа на Сове.

Контроль доступа и аудит

В 3 & Done каждый запрос к API проходит через слой аутентификации и авторизации. Система поддерживает LDAP, OAuth2 и JWT-токены — выбираете, что удобнее для вашей инфраструктуры.

RBAC: гибкая модель ролей

Роли в системе иерархичны, но не жёсткие. Можно задать права на уровне проектов, задач и даже отдельных полей. Пример конфигурации ролей в PostgreSQL:

sql
-- Создаём роли
CREATE ROLE threedone_admin;
CREATE ROLE threedone_manager;
CREATE ROLE threedone_user;

-- Назначаем права на таблицы
GRANT ALL ON ALL TABLES IN SCHEMA public TO threedone_admin;
GRANT SELECT, INSERT, UPDATE ON tasks, projects TO threedone_manager;
GRANT SELECT, INSERT ON tasks TO threedone_user;

-- Ограничиваем доступ к полям с комментариями
GRANT SELECT (id, title, status, assignee_id) ON tasks TO threedone_user;
GRANT SELECT (id, title, status, assignee_id, comments) ON tasks TO threedone_manager;

В приложении роли синхронизируются с LDAP-группами через middleware. На ноде Кролика (отдельная нода для периферийных устройств) использую упрощённую схему — только threedone_user и threedone_manager, чтобы не перегружать маломощное железо.

Аудит действий через триггеры PostgreSQL

Каждое изменение в задачах логируется. Не просто «кто и когда», а и «с какого IP» и «с каким User-Agent». Триггеры в PostgreSQL пишут события в отдельную таблицу аудита:

sql
-- Таблица для логов аудита
CREATE TABLE audit_log (
    id SERIAL PRIMARY KEY,
    table_name TEXT NOT NULL,
    operation CHAR(1) NOT NULL, -- I, U, D
    row_data JSONB,
    changed_by TEXT NOT NULL,
    client_ip INET,
    user_agent TEXT,
    changed_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);

-- Триггерная функция
CREATE OR REPLACE FUNCTION audit_trigger_func()
RETURNS TRIGGER AS $$
BEGIN
    IF TG_OP = 'INSERT' THEN
        INSERT INTO audit_log (table_name, operation, row_data, changed_by, client_ip, user_agent)
        VALUES (TG_TABLE_NAME, 'I', row_to_json(NEW), current_user, inet_client_addr(), current_setting('request.user_agent', true));
        RETURN NEW;
    ELSIF TG_OP = 'UPDATE' THEN
        INSERT INTO audit_log (table_name, operation, row_data, changed_by, client_ip, user_agent)
        VALUES (TG_TABLE_NAME, 'U', row_to_json(NEW), current_user, inet_client_addr(), current_setting('request.user_agent', true));
        RETURN NEW;
    ELSIF TG_OP = 'DELETE' THEN
        INSERT INTO audit_log (table_name, operation, row_data, changed_by, client_ip, user_agent)
        VALUES (TG_TABLE_NAME, 'D', row_to_json(OLD), current_user, inet_client_addr(), current_setting('request.user_agent', true));
        RETURN OLD;
    END IF;
    RETURN NULL;
END;
$$ LANGUAGE plpgsql;

-- Применяем триггер к таблицам задач
CREATE TRIGGER audit_tasks
    AFTER INSERT OR UPDATE OR DELETE ON tasks
    FOR EACH ROW EXECUTE FUNCTION audit_trigger_func();

Периодически выгружаю логи аудита в Loki для анализа через Grafana. [Развертывание DeepSQL](/guides/razvertyvanie-deepsql-размещение на домашних серверах-dba-agent-dlya-postgresql-i-mysql-uf6iti) помогло настроить мониторинг нагрузки на базу во время пиковых синхронизаций.

Для аудита Git-репозиториев использую встроенный git log с фильтрами:

bash
# Кто и когда менял файл с конфигом
git log --follow --pretty=format:"%h %an %ad %s" -- config/database.yml

# Все изменения за последнюю неделю
git log --since="1 week ago" --name-only --pretty=format:"%h %an %s"

Если нужно построить временную шкалу событий по всей инфраструктуре — масштабируемая временная шкала из моего арсенала решает задачу с миллионами событий.

Двухфакторная аутентификация

В 3 & Done поддерживается TOTP и WebAuthn. Для себя настраиваю обязательную 2FA на всех админ-аккаунтах. Конфиг в config/auth.yaml:

yaml
auth:
  providers:
    - name: postgresql
      type: sql
      table: users
    
    - name: ldap
      type: ldap
      url: ldaps://ldap.example.com
      bind_dn: cn=admin,dc=example,dc=com
      
  mfa:
    required_for_roles: [admin, manager]
    methods:
      - type: totp
        issuer: 3done
      - type: webauthn
        rp_id: threedone.example.com

Сама по себе 2FA не спасёт, если злоумышленник получит доступ к токену. Поэтому дополнительно настраиваю ограничение по IP для админ-интерфейса через Nginx:

nginx
location /admin {
    allow 10.0.0.0/8;
    allow 192.168.1.0/24;
    deny all;
    
    proxy_pass http://threedone_backend;
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
}

Если вы работаете с локальными ИИ-моделями — [размещение на домашних серверах AI стек](/articles/размещение на домашних серверах-ai-workflow-db-ollama-sql) тоже стоит защищать по той же схеме. На практике: если сервер доступен извне, обязательна 2FA и ограничение по IP. Никаких «на всякий случае» — только конкретные меры.

Интерфейсы и ввод данных

Голосовой ввод и расознавание

Система 3 & Done поддерживает голосовой ввод через встроенный распознающий движок на базе Vosk. Это работает без выхода в интернет — вся модель лежит локально, и ни одно слово не улетает наружу.

Для начала ставим пакеты. На сервере с Debian/Ubuntu:

bash
apt install python3-vosk python3-pip
pip3 install vosk-tts

Модель распознавания русской речи весит около 50 МБ. Скачиваем её в каталог проекта:

bash
wget https://alphacephei.com/vosk/models/vosk-model-small-ru-0.21.zip
unzip vosk-model-small-ru-0.21.zip -d /opt/3anddone/models/

Теперь подключаем микрофон. Если работаете через SSH — включаем проброс аудио:

bash
ssh -R /tmp/pulse-socket:/run/user/1000/pulse/native user@server

Запускаем распознавание:

bash
python3 /opt/3anddone/voice_input.py --model /opt/3anddone/models/vosk-model-small-ru-0.21

Голосовые команды привязываются в конфиге voice_commands.json:

json
{
  "создай задачу": "task create",
  "отметь завершённой": "task complete",
  "покажи задачи": "task list"
}

Каждая фраза отправляется в PostgreSQL как текстовая команда. Никаких посредников — сразу в базу.

Текстовый ввод и быстрые команды

Не всегда удобно говорить. Иногда нужно быстро набрать задачу несколькими словами и закрыть. Здесь 3 & Done предлагает три способа.

Первый — прямой ввод в терминал. Любая строка, начинающаяся с !, считается командой:

bash
!task "Переписать отчёт для заказчика"
!complete 42
!list --assignee=me

Это удобно, когда вы уже в терминале — например, правите конфиги или проверяете логи.

Второй — быстрые команды через хоткеи. В конфиге shortcuts.json можно назначить клавиатурные сочетания:

json
{
  "Ctrl+Shift+N": "task create --quick",
  "Ctrl+Shift+C": "task complete --last",
  "Ctrl+Shift+L": "task list --active"
}

Эти шорткаты работают в любом окне терминала, если запущен локальный демон 3 & Done:

bash
systemctl --user start 3anddone-shortcutd

Третий — импорт из внешних источников. Если задачи приходят по email, из Telegram или из файла — используется парсер на Python. Пример для CSV:

python
import csv
from threedone import TaskAPI

api = TaskAPI(db_url="postgresql://3anddone:pass@localhost/tasks")

with open("tasks.csv") as f:
    reader = csv.DictReader(f)
    for row in reader:
        api.create_task(
            title=row["title"],
            description=row.get("notes", ""),
            tags=row.get("tags", "").split(",")
        )

Все варианты ввода — текстовый, голосовой, импорт — попадают в одну и ту же таблицу tasks в PostgreSQL. Никаких дублей, никаких рассинхронов. И если нужно — всегда можно вытащить историю через [Git-репозиторий команд](/guides/razvertyvanie-deepsql-размещение на домашних серверах-dba-agent-dlya-postgresql-i-mysql-uf6iti), привязанный к базе.

Для отладки ввода удобно смотреть логи в реальном времени:

bash
journalctl -u 3anddone -f

Если что-то не распознаётся или не парсится — сразу видно в логе. Без воды, без задержек.

Часто задаваемые вопросы

Почему в 3 & Done используется JSONB для тегов вместо отдельной таблицы?

Потому что в реальной работе теги редко запрашиваются по отдельности — чаще нужно «выдать все задачи с тегами work и urgent» или «показать, какие теги чаще всего встречаются в проекте». JSONB позволяет делать это одним запросом с metadata->'tags' ?| array['work','urgent'], без джоинов. Конечно, если захотите построить аналитику по тегам — придётся писать отдельный ETL, но на старте это экономит кучу времени.

Как система обрабатывает удаление пользователей и проектов?

Никогда не удаляет данные — только обнуляет внешние ключи. Если сотрудник уходит, его задачи остаются, но assignee_id становится NULL. Это не баг, а фича: в инфраструктуре важнее история, чем актуальность. Я сам так делал на 2000-серверной площадке — когда увольнялся DevOps, его тикеты не пропадали, а перераспелялись.

Можно ли использовать 3 & Done с голосовым вводом без передачи данных в облако?

Да, если подключить локальную модель распознавания речи, например Vosk или Whisper.cpp. Голосовой ввод в 3 & Done — это не про «голос в облаке», а про прямой поток текста в PostgreSQL. Я сам запускал Vosk на Raspberry Pi 4 — распознавание «Напиши отчёт к пятнице» занимает секунду, а ни один фрагмент не покидает локальную сеть.

Почему PostgreSQL 15, а не SQLite или MySQL?

Потому что JSONB, материализованные представления и надёжные транзакции — это не фичи, а база. SQLite не выдержит параллельных запросов от веб-интерфейса и бота в Telegram одновременно. MySQL — возможен, но в PostgreSQL проще построить аудит через task_events без кастылей. Я перепробовал всё это на проде — PostgreSQL даёт предсказуемость, а в инфраструктуре это дороже, чем кажется.

Поделиться:TelegramX / TwitterVK

Читайте также