Files
smart-home-tablet/app/api/calendar/route.ts
Cosmo 96fa78bd5c
All checks were successful
Deploy / deploy (push) Successful in 2m7s
fix(calendar): GOOGLE_SA_JSON_B64 поддержка (env-file friendly)
docker --env-file не поддерживает многострочные значения и не парсит
кавычки. Сырой JSON service-account ломается на newline'ах в private_key
поле → docker пытается парсить '-----END PRIVATE KEY-----' как имя
переменной и валится с 'contains whitespaces'.

Решение: base64 GOOGLE_SA_JSON_B64 (одна строка ASCII, никаких кавычек).
Старая GOOGLE_SA_JSON оставлена как fallback. Третий fallback на файл —
для локальной разработки.
2026-04-27 12:59:17 +00:00

260 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
export const dynamic = 'force-dynamic'
import { NextResponse } from 'next/server'
import { google } from 'googleapis'
import * as fs from 'fs'
import * as path from 'path'
function nextDayISO(date: string): string {
const d = new Date(`${date}T00:00:00Z`)
d.setUTCDate(d.getUTCDate() + 1)
return d.toISOString().slice(0, 10)
}
function getAuth(readonly = true) {
const scopes = readonly
? ['https://www.googleapis.com/auth/calendar.readonly']
: ['https://www.googleapis.com/auth/calendar']
// 1) Предпочтительно — base64. docker --env-file не парсит кавычки и не
// поддерживает многострочные значения, а private_key в JSON ломается
// на переводах строк → base64 это решает (одна строка ASCII).
const saB64 = process.env.GOOGLE_SA_JSON_B64
if (saB64) {
try {
const sa = JSON.parse(Buffer.from(saB64, 'base64').toString('utf-8'))
return new google.auth.GoogleAuth({ credentials: sa, scopes })
} catch (e) {
console.error('[calendar] GOOGLE_SA_JSON_B64 decode failed:', e)
}
}
// 2) Сырая JSON-строка (legacy).
const saJson = process.env.GOOGLE_SA_JSON
if (saJson) {
try {
const sa = JSON.parse(saJson)
return new google.auth.GoogleAuth({ credentials: sa, scopes })
} catch (e) {
console.error('[calendar] GOOGLE_SA_JSON parse failed:', e)
}
}
// 3) Fallback на файл (для локальной разработки).
const saPath = path.join(process.cwd(), 'google-sa.json')
if (fs.existsSync(saPath)) {
return new google.auth.GoogleAuth({ keyFile: saPath, scopes })
}
return null
}
export async function GET(req: Request) {
const { searchParams } = new URL(req.url)
const range = searchParams.get('range') || 'today'
const auth = getAuth(true)
if (!auth) {
return NextResponse.json({ events: [], error: 'not_configured' })
}
const daniilCalendarId = process.env.DANIIL_CALENDAR_ID || 'daniilklimov25@gmail.com'
const svetaCalendarId = process.env.SVETA_CALENDAR_ID || ''
const now = new Date()
let timeMin: string
let timeMax: string
// Moscow boundaries (UTC+3, no DST). 00:00 MSK = 21:00 UTC previous day.
const MSK_OFFSET_MS = 3 * 3600 * 1000
const mskNow = new Date(now.getTime() + MSK_OFFSET_MS)
const my = mskNow.getUTCFullYear()
const mm = mskNow.getUTCMonth()
const md = mskNow.getUTCDate()
const mskMidnight = (y: number, m: number, d: number) =>
new Date(Date.UTC(y, m, d) - MSK_OFFSET_MS).toISOString()
if (range === 'today') {
timeMin = mskMidnight(my, mm, md)
timeMax = mskMidnight(my, mm, md + 1)
} else if (range === 'week') {
timeMin = mskMidnight(my, mm, md)
timeMax = mskMidnight(my, mm, md + 7)
} else {
// month — support year/month query params
const targetYear = parseInt(searchParams.get('year') || String(now.getFullYear()))
const targetMonth = parseInt(searchParams.get('month') || String(now.getMonth()))
timeMin = new Date(targetYear, targetMonth, 1).toISOString()
timeMax = new Date(targetYear, targetMonth + 1, 0, 23, 59).toISOString()
}
const calendarClient = google.calendar({ version: 'v3', auth: auth as any })
const calendars = [
{ id: daniilCalendarId, owner: 'daniil', color: '#6366f1', name: 'Даниил' },
...(svetaCalendarId ? [{ id: svetaCalendarId, owner: 'sveta', color: '#ec4899', name: 'Света' }] : [])
]
const results = await Promise.allSettled(
calendars.map(cal =>
calendarClient.events.list({
calendarId: cal.id,
timeMin,
timeMax,
singleEvents: true,
orderBy: 'startTime',
maxResults: 100,
}).then(r => ({ ...cal, events: r.data.items || [] }))
)
)
const allEvents = results
.filter(r => r.status === 'fulfilled')
.flatMap(r => {
const val = (r as PromiseFulfilledResult<any>).value
return val.events.map((e: any) => ({
id: e.id,
title: e.summary || '(без названия)',
start: e.start?.dateTime || e.start?.date,
end: e.end?.dateTime || e.end?.date,
allDay: !e.start?.dateTime,
description: e.description || null,
location: e.location || null,
owner: val.owner,
ownerName: val.name,
color: val.color,
}))
})
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
const errors = results
.filter(r => r.status === 'rejected')
.map(r => (r as PromiseRejectedResult).reason?.message || 'unknown')
return NextResponse.json({ events: allEvents, errors: errors.length ? errors : undefined, fetchedAt: new Date().toISOString() })
}
export async function POST(req: Request) {
const body = await req.json()
const { title, date, startTime, endTime, allDay, owner } = body
const auth = getAuth(false)
if (!auth) return NextResponse.json({ error: 'not_configured' }, { status: 500 })
const calendarClient = google.calendar({ version: 'v3', auth: auth as any })
const daniilCalendarId = process.env.DANIIL_CALENDAR_ID || 'daniilklimov25@gmail.com'
const svetaCalendarId = process.env.SVETA_CALENDAR_ID || ''
const calendars: Record<string, { id: string; name: string; color: string }> = {
daniil: { id: daniilCalendarId, name: 'Даниил', color: '#6366f1' },
...(svetaCalendarId ? { sveta: { id: svetaCalendarId, name: 'Света', color: '#ec4899' } } : {}),
}
const selectedOwner = owner && calendars[owner] ? owner : 'daniil'
const cal = calendars[selectedOwner]
let start: any, end: any
if (allDay) {
start = { date, dateTime: null }
end = { date: nextDayISO(date), dateTime: null }
} else {
start = { dateTime: `${date}T${startTime}:00`, timeZone: 'Europe/Moscow', date: null }
end = { dateTime: `${date}T${endTime}:00`, timeZone: 'Europe/Moscow', date: null }
}
try {
const res = await calendarClient.events.insert({
calendarId: cal.id,
requestBody: { summary: title, start, end },
})
const e = res.data
return NextResponse.json({
event: {
id: e.id,
title: e.summary || title,
start: e.start?.dateTime || e.start?.date,
end: e.end?.dateTime || e.end?.date,
allDay: !e.start?.dateTime,
owner: selectedOwner,
ownerName: cal.name,
color: cal.color,
}
})
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to create event' }, { status: 500 })
}
}
export async function PUT(req: Request) {
const body = await req.json()
const { eventId, title, date, startTime, endTime, allDay, calendarId } = body
if (!eventId) {
return NextResponse.json({ error: 'eventId is required' }, { status: 400 })
}
const auth = getAuth(false)
if (!auth) return NextResponse.json({ error: 'not_configured' }, { status: 500 })
const targetCalendarId = calendarId || process.env.DANIIL_CALENDAR_ID || 'daniilklimov25@gmail.com'
const calendarClient = google.calendar({ version: 'v3', auth: auth as any })
let start: any, end: any
if (allDay) {
start = { date, dateTime: null }
end = { date: nextDayISO(date), dateTime: null }
} else {
start = { dateTime: `${date}T${startTime}:00`, timeZone: 'Europe/Moscow', date: null }
end = { dateTime: `${date}T${endTime}:00`, timeZone: 'Europe/Moscow', date: null }
}
try {
const res = await calendarClient.events.patch({
calendarId: targetCalendarId,
eventId,
requestBody: {
summary: title,
start,
end,
},
})
const e = res.data
return NextResponse.json({
event: {
id: e.id,
title: e.summary || title,
start: e.start?.dateTime || e.start?.date,
end: e.end?.dateTime || e.end?.date,
allDay: !e.start?.dateTime,
}
})
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to update event' }, { status: 500 })
}
}
export async function DELETE(req: Request) {
const { searchParams } = new URL(req.url)
const eventId = searchParams.get('eventId')
const calendarId = searchParams.get('calendarId')
if (!eventId) {
return NextResponse.json({ error: 'eventId is required' }, { status: 400 })
}
const auth = getAuth(false)
if (!auth) {
return NextResponse.json({ error: 'not_configured' }, { status: 500 })
}
const targetCalendarId = calendarId || process.env.DANIIL_CALENDAR_ID || 'daniilklimov25@gmail.com'
const calendarClient = google.calendar({ version: 'v3', auth: auth as any })
try {
await calendarClient.events.delete({
calendarId: targetCalendarId,
eventId,
})
return NextResponse.json({ success: true })
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to delete event' }, { status: 500 })
}
}