All checks were successful
Deploy / deploy (push) Successful in 2m7s
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 на файл — для локальной разработки.
260 lines
8.7 KiB
TypeScript
260 lines
8.7 KiB
TypeScript
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 })
|
||
}
|
||
}
|