## Ежедневный дайджест

Цель: каждый день в 09:00 Europe/Moscow принять запрос на `/tasks/digest?mode=daily` и поставить сборку дайджеста в устойчивую очередь приложения. [Создайте задачу](./create.md) с настройками ниже и сохраните секрет из ответа в защищённой конфигурации обработчика. Это пример настройки, не подтверждение доступа вашего портала.

```json
{
  "cronExpr": "0 9 * * *",
  "timezone": "Europe/Moscow",
  "path": "/tasks/digest?mode=daily",
  "label": "daily digest",
  "enabled": true
}
```

### Обработчик

Разместите показанные на [странице подписи](./signature.md) verifier и HTTP-reader в verifier.ts и http-adapter.ts. Создайте таблицы accepted_runs/job_outbox из той же страницы. Для примера нужен пакет pg. Задайте EXPECTED_JOB_ID, JOB_SIGNING_SECRET и DATABASE_URL на сервере; не отправляйте их браузеру. Endpoint должен быть доступен через доверенный платформенный вход.

```ts
import { createServer } from 'node:http';
import { Pool } from 'pg';
import { verifyJobRequest } from './verifier.js';
import { readRawBody, singleHeader } from './http-adapter.js';

const secret = process.env.JOB_SIGNING_SECRET;
const expectedJobId = process.env.EXPECTED_JOB_ID;
if (!secret || !expectedJobId) throw new Error('Missing handler configuration');
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
createServer(async (req, res) => {
  if (req.method !== 'POST' || req.url !== '/tasks/digest?mode=daily') {
    res.writeHead(404).end(); return;
  }
  try {
    const rawBody = await readRawBody(req);
    if (!verifyJobRequest({ callerKind: singleHeader(req, 'X-Vibe-Caller-Kind'),
      singleSignatureHeader: singleHeader(req, 'Vibe-Signature'), rawBody,
      secret, nowSeconds: Math.floor(Date.now() / 1000) })) {
      res.writeHead(401).end(); return;
    }
    let p;
    try { p = JSON.parse(rawBody.toString('utf8')); }
    catch { res.writeHead(400).end(); return; }
    if (!p || p.type !== 'scheduled_job.run' || p.jobId !== expectedJobId ||
      typeof p.runId !== 'string' || p.runId.length < 1 || p.runId.length > 200 ||
      typeof p.scheduledFor !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(p.scheduledFor) ||
      !Number.isFinite(Date.parse(p.scheduledFor)) || !Number.isInteger(p.attempt) ||
      p.attempt < 1 || p.attempt > 4 || typeof p.late !== 'boolean' ||
      !['schedule', 'manual'].includes(p.trigger)) {
      res.writeHead(400).end(); return;
    }
    const db = await pool.connect();
    try {
      await db.query('BEGIN');
      await db.query(`WITH accepted AS (
        INSERT INTO accepted_runs(run_id,response_status,response_body)
        VALUES ($1,202,'{"accepted":true}'::jsonb)
        ON CONFLICT (run_id) DO NOTHING RETURNING run_id
      ) INSERT INTO job_outbox(run_id,payload)
        SELECT run_id,$2::jsonb FROM accepted`, [p.runId, JSON.stringify(p)]);
      const saved = await db.query(
        'SELECT response_status,response_body FROM accepted_runs WHERE run_id=$1', [p.runId]);
      if (saved.rows.length !== 1) throw new Error('Missing acceptance receipt');
      await db.query('COMMIT');
      res.writeHead(saved.rows[0].response_status, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(saved.rows[0].response_body));
    } catch {
      await db.query('ROLLBACK');
      res.writeHead(503).end();
    } finally { db.release(); }
  } catch (error) {
    res.writeHead(error instanceof Error && error.message === 'BODY_TOO_LARGE' ? 413 : 503).end();
  }
}).listen(3000);
```

### Работа после HTTP-ответа

202 означает, что runId и работа записаны в одной транзакции. Повтор этого runId возвращает сохранённый 202, не добавляя новую работу. Worker приложения обрабатывает job_outbox отдельно, с устойчивым захватом и восстановлением после сбоя; для отправки дайджеста внешнему сервису используйте runId как idempotency key. Отмечайте processed_at в outbox и квитанции после окончания. Квитанции храните не меньше настроенного срока журнала (по умолчанию 90 дней, минимум 7), незавершённые работы не удаляйте.

[Журнал запуска](./runs.md) покажет SUCCEEDED после HTTP 202, даже если сборка ещё идёт. Показывайте окончание в собственном статусе приложения или уведомляйте пользователя из worker. Обычный idle timer сервера не гарантирует защиту до окончания фоновой работы: выбирайте режим работы и длительность задач с учётом этого. [Ручной запуск](./run.md) подходит для проверки, но каждый POST создаёт отдельную работу.
