From 56844a539d6664c7d73840e50d67d63eb6fcc02a Mon Sep 17 00:00:00 2001 From: Cosmo Date: Thu, 23 Apr 2026 14:34:32 +0000 Subject: [PATCH] =?UTF-8?q?feat(voice/events):=20full=20CRUD=20=E2=80=94?= =?UTF-8?q?=20POST/PUT/DELETE=20with=20owner=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Голосовой ассистент теперь может создавать, изменять и удалять события в календарях Даниила и Светы. - POST /api/voice/tools/events — create (title, date, start_time, end_time, all_day, owner). Маппит owner (daniil/sveta) в calendar_id и проксирует в /api/calendar POST. - PUT — update (event_id, owner, fields). Передаёт только изменённые поля + нужный calendarId. - DELETE ?event_id=X&owner=Y — удаление. - GET — теперь возвращает id события и owner (daniil/sveta), чтобы скрипт мог их передать в update/delete. - range=month поддержан с year/month query params. Все три метода под bearer auth (VOICE_API_KEY), как остальные voice tools. Loopback к /api/calendar идёт через internalHeaders() x-voice-internal. --- app/api/voice/tools/events/route.ts | 159 ++++++++++++++++++++++++++-- 1 file changed, 151 insertions(+), 8 deletions(-) diff --git a/app/api/voice/tools/events/route.ts b/app/api/voice/tools/events/route.ts index 98fd46e..e1a0903 100644 --- a/app/api/voice/tools/events/route.ts +++ b/app/api/voice/tools/events/route.ts @@ -4,26 +4,169 @@ export const runtime = 'nodejs' import { NextResponse } from 'next/server' import { isBearerAuthorized, unauthorized, internalHeaders } from '@/lib/voice-tools' +const baseUrl = () => `http://localhost:${process.env.PORT || '3000'}` + +// Resolve agent-style owner to the Google calendar id used by /api/calendar. +// daniil → primary env DANIIL_CALENDAR_ID or default address +// sveta → SVETA_CALENDAR_ID +function calendarIdFor(owner: string): string { + const o = (owner || '').toLowerCase() + if (o === 'sveta' || o === 'света' || o === 'lusya') { + return process.env.SVETA_CALENDAR_ID || '' + } + return process.env.DANIIL_CALENDAR_ID || 'daniilklimov25@gmail.com' +} + +function ownerTag(owner: string): 'daniil' | 'sveta' { + const o = (owner || '').toLowerCase() + if (o === 'sveta' || o === 'света' || o === 'lusya') return 'sveta' + return 'daniil' +} + +// ─────────── GET ─────────── list events export async function GET(req: Request) { if (!isBearerAuthorized(req)) return unauthorized() const { searchParams } = new URL(req.url) - const range = searchParams.get('range') || 'today' // today | week - - const baseUrl = `http://localhost:${process.env.PORT || '3000'}` - const r = await fetch(`${baseUrl}/api/calendar?range=${encodeURIComponent(range)}`, { - cache: 'no-store', - headers: internalHeaders(), - }).catch(() => null) + const range = searchParams.get('range') || 'today' // today | week | month + let url = `${baseUrl()}/api/calendar?range=${encodeURIComponent(range)}` + // For month: forward year/month if supplied + if (range === 'month') { + const year = searchParams.get('year') + const month = searchParams.get('month') + if (year) url += `&year=${encodeURIComponent(year)}` + if (month) url += `&month=${encodeURIComponent(month)}` + } + const r = await fetch(url, { cache: 'no-store', headers: internalHeaders() }).catch(() => null) if (!r || !r.ok) return NextResponse.json({ events: [], error: 'unreachable' }, { status: 502 }) + const j = await r.json() const events = (j.events || []).map((e: any) => ({ + id: e.id, title: e.title, start: e.start, end: e.end, all_day: e.allDay, - owner: e.ownerName || e.owner, + owner: e.owner, // 'daniil' | 'sveta' + owner_name: e.ownerName, })) return NextResponse.json({ events }) } + +// ─────────── POST ─────────── create event +export async function POST(req: Request) { + if (!isBearerAuthorized(req)) return unauthorized() + + const body = await req.json().catch(() => null) + if (!body) return NextResponse.json({ error: 'body required' }, { status: 400 }) + + const title = typeof body.title === 'string' ? body.title.trim() : '' + const date = typeof body.date === 'string' ? body.date.trim() : '' + const owner = ownerTag(body.owner || 'daniil') + const all_day = Boolean(body.all_day) + const start_time = typeof body.start_time === 'string' ? body.start_time.trim() : '' + const end_time = typeof body.end_time === 'string' ? body.end_time.trim() : '' + + if (!title || !date) { + return NextResponse.json({ error: 'title and date required' }, { status: 400 }) + } + + const payload: Record = { + title, + date, + allDay: all_day, + owner, + } + if (!all_day) { + payload.startTime = start_time || '12:00' + // Default end = start + 1 hour + if (end_time) { + payload.endTime = end_time + } else { + const [h, m] = (start_time || '12:00').split(':').map((n: string) => parseInt(n, 10)) + const endH = (h + 1) % 24 + payload.endTime = `${endH.toString().padStart(2, '0')}:${(m || 0).toString().padStart(2, '0')}` + } + } + + const r = await fetch(`${baseUrl()}/api/calendar`, { + method: 'POST', + headers: { ...internalHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + cache: 'no-store', + }).catch(() => null) + + if (!r || !r.ok) { + const detail = r ? await r.text().catch(() => '') : '' + return NextResponse.json({ error: 'create_failed', detail: detail.slice(0, 300) }, { status: 502 }) + } + const j = await r.json() + if (j.error) return NextResponse.json({ error: j.error }, { status: 502 }) + return NextResponse.json({ event: j.event }) +} + +// ─────────── PUT ─────────── update event +export async function PUT(req: Request) { + if (!isBearerAuthorized(req)) return unauthorized() + + const body = await req.json().catch(() => null) + if (!body) return NextResponse.json({ error: 'body required' }, { status: 400 }) + + const event_id = typeof body.event_id === 'string' ? body.event_id : '' + if (!event_id) return NextResponse.json({ error: 'event_id required' }, { status: 400 }) + + const owner = ownerTag(body.owner || 'daniil') + const calendarId = calendarIdFor(owner) + + const payload: Record = { + eventId: event_id, + calendarId, + allDay: Boolean(body.all_day), + } + if (typeof body.title === 'string') payload.title = body.title.trim() + if (typeof body.date === 'string') payload.date = body.date.trim() + if (typeof body.start_time === 'string') payload.startTime = body.start_time.trim() + if (typeof body.end_time === 'string') payload.endTime = body.end_time.trim() + + const r = await fetch(`${baseUrl()}/api/calendar`, { + method: 'PUT', + headers: { ...internalHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + cache: 'no-store', + }).catch(() => null) + + if (!r || !r.ok) { + const detail = r ? await r.text().catch(() => '') : '' + return NextResponse.json({ error: 'update_failed', detail: detail.slice(0, 300) }, { status: 502 }) + } + const j = await r.json() + if (j.error) return NextResponse.json({ error: j.error }, { status: 502 }) + return NextResponse.json({ event: j.event }) +} + +// ─────────── DELETE ─────────── delete event +export async function DELETE(req: Request) { + if (!isBearerAuthorized(req)) return unauthorized() + + const { searchParams } = new URL(req.url) + const event_id = searchParams.get('event_id') || '' + const owner = searchParams.get('owner') || 'daniil' + + if (!event_id) return NextResponse.json({ error: 'event_id required' }, { status: 400 }) + + const calendarId = calendarIdFor(owner) + const url = `${baseUrl()}/api/calendar?eventId=${encodeURIComponent(event_id)}&calendarId=${encodeURIComponent(calendarId)}` + + const r = await fetch(url, { + method: 'DELETE', + headers: internalHeaders(), + cache: 'no-store', + }).catch(() => null) + + if (!r || !r.ok) { + const detail = r ? await r.text().catch(() => '') : '' + return NextResponse.json({ error: 'delete_failed', detail: detail.slice(0, 300) }, { status: 502 }) + } + return NextResponse.json({ deleted: true }) +}