diff --git a/apps/api/src/routes/integrations-cfdm.test.ts b/apps/api/src/routes/integrations-cfdm.test.ts index e665665..d77ea4e 100644 --- a/apps/api/src/routes/integrations-cfdm.test.ts +++ b/apps/api/src/routes/integrations-cfdm.test.ts @@ -27,7 +27,7 @@ describe('integrations CFDM routes', () => { method: 'POST', url: '/api/integrations/cfdm/ping', }) - expect(res.statusCode).toBe(401) + expect(res.statusCode).toBe(503) }) it('принимает ping с верным Bearer', async () => { @@ -44,4 +44,22 @@ describe('integrations CFDM routes', () => { expect(res.statusCode).toBe(200) expect(res.json()).toEqual({ ok: true, service: 'vps-tracker' }) }) + + it('принимает fullSync с пустым списком bindings', async () => { + settingsRepository.upsert('settings-main', { + integrationToken: 'test-secret', + integrationEnabled: true, + }) + const res = await app.inject({ + method: 'POST', + url: '/api/integrations/cfdm/sync-bindings', + headers: { + authorization: 'Bearer test-secret', + 'content-type': 'application/json', + }, + payload: { bindings: [], fullSync: true }, + }) + expect(res.statusCode).toBe(200) + expect(res.json()).toMatchObject({ ok: true, upserted: 0 }) + }) }) diff --git a/apps/api/src/routes/integrations-cfdm.ts b/apps/api/src/routes/integrations-cfdm.ts index f563bbf..8bd7605 100644 --- a/apps/api/src/routes/integrations-cfdm.ts +++ b/apps/api/src/routes/integrations-cfdm.ts @@ -27,7 +27,9 @@ export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => { } return runInIntegrationSpace(req, () => { - const result = vpsDomainsRepository.syncBindings(parsed.data.bindings) + const result = vpsDomainsRepository.syncBindings(parsed.data.bindings, { + fullSync: parsed.data.fullSync === true, + }) settingsRepository.touchIntegrationSync() return { ok: true, diff --git a/apps/api/src/routes/settings.test.ts b/apps/api/src/routes/settings.test.ts index d1e1a8c..4f47a6e 100644 --- a/apps/api/src/routes/settings.test.ts +++ b/apps/api/src/routes/settings.test.ts @@ -81,3 +81,49 @@ describe('settings telegram test', () => { expect(url).toContain('botoverride-token/') }) }) + +describe('settings cfdm sync', () => { + let app: Awaited> + + beforeEach(async () => { + resetTestDb() + settingsRepository.upsert('settings-main', { + integrationEnabled: true, + integrationToken: 'shared-token', + cfdmApiUrl: 'http://cfdm.test', + }) + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + vi.unstubAllGlobals() + closeDb() + }) + + it(' sync CFDM', async () => { + const fetchMock = vi.fn(async () => Response.json({ ok: true, count: 3 })) + vi.stubGlobal('fetch', fetchMock) + + const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true, count: 3 }) + + const call = fetchMock.mock.calls[0] as [string, RequestInit] | undefined + expect(call?.[0]).toBe('http://cfdm.test/api/v1/integrations/vps-tracker/sync') + expect((call?.[1].headers as Record).Authorization).toBe( + 'Bearer shared-token', + ) + }) + + it(' ', async () => { + settingsRepository.upsert('settings-main', { + integrationEnabled: false, + integrationToken: 'shared-token', + cfdmApiUrl: 'http://cfdm.test', + }) + const res = await app.inject({ method: 'POST', url: '/api/settings/cfdm/sync' }) + expect(res.statusCode).toBe(502) + expect(res.json()).toMatchObject({ ok: false }) + }) +}) diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 9470049..df2c026 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -6,6 +6,7 @@ import { settingsSchema, telegramTestBodySchema } from '@cfdm/shared/contracts/s import { restartScheduler } from '../services/scheduler.js' import { sendTelegramMessage } from '../services/telegram.js' import { deliverWebhook } from '../services/notifications/channels.js' +import { requestCfdmFullSync } from '../services/cfdm-sync.js' import { requireSpaceRole } from '../plugins/space.js' export const settingsRoutes: FastifyPluginAsync = async (app) => { @@ -80,4 +81,16 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => { }) return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка webhook' } }) + + app.post('/api/settings/cfdm/sync', async (req, reply) => { + if (!requireSpaceRole(req, reply, 'admin')) return + const result = await requestCfdmFullSync() + if (!result.ok) { + return reply.code(502).send({ + ok: false, + error: result.error ?? 'Не удалось синхронизировать с CFDM', + }) + } + return { ok: true, count: result.count ?? 0 } + }) } diff --git a/apps/api/src/services/cfdm-sync.ts b/apps/api/src/services/cfdm-sync.ts new file mode 100644 index 0000000..a821132 --- /dev/null +++ b/apps/api/src/services/cfdm-sync.ts @@ -0,0 +1,50 @@ +import { settingsRepository } from '@cfdm/db/repositories/settings' + +function resolveCfdmApiBase(): string | null { + const row = settingsRepository.getBySpace() + if (!row) return null + const explicit = row.cfdmApiUrl?.trim() + if (explicit) return explicit.replace(/\/$/, '') + const cfdm = settingsRepository.getAppSwitcher().apps.find((a) => a.id === 'cfdm') + return cfdm?.url?.trim().replace(/\/$/, '') ?? null +} + +export async function requestCfdmFullSync(): Promise<{ + ok: boolean + count?: number + error?: string +}> { + const row = settingsRepository.getBySpace() + if (!row?.integrationEnabled) { + return { ok: false, error: 'Включите приём синхронизации' } + } + + const token = settingsRepository.getIntegrationToken() + const baseUrl = resolveCfdmApiBase() + if (!baseUrl) return { ok: false, error: 'Укажите URL API CFDM' } + if (!token) return { ok: false, error: 'Укажите integration token' } + + try { + const res = await fetch(`${baseUrl}/api/v1/integrations/vps-tracker/sync`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }) + const body = (await res.json().catch(() => ({}))) as { + ok?: boolean + count?: number + error?: string + } + if (!res.ok || body.ok === false) { + return { + ok: false, + error: body.error ?? `HTTP ${res.status}`, + } + } + return { ok: true, count: body.count ?? 0 } + } catch (err) { + return { + ok: false, + error: err instanceof Error ? err.message : 'Ошибка сети', + } + } +} diff --git a/apps/web/src/components/integrations/cfdm-integration-card.tsx b/apps/web/src/components/integrations/cfdm-integration-card.tsx index c051219..2385a30 100644 --- a/apps/web/src/components/integrations/cfdm-integration-card.tsx +++ b/apps/web/src/components/integrations/cfdm-integration-card.tsx @@ -2,6 +2,8 @@ import { useForm, Controller } from 'react-hook-form' import { zodResolver } from '@hookform/resolvers/zod' import { z } from 'zod' import { toast } from 'sonner' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { RefreshCwIcon } from 'lucide-react' import { SettingRow } from '@/components/setting-row' import { LoadingButton } from '@/components/loading-button' @@ -9,6 +11,8 @@ import { FieldGroup } from '@cfdm/ui/components/field' import { Input } from '@cfdm/ui/components/input' import { Button } from '@cfdm/ui/components/button' import { Switch } from '@cfdm/ui/components/switch' +import { api, ApiError } from '@/lib/api-client' +import { snapshotQueryOptions } from '@/queries/snapshot' import type { Settings } from '@/types/entities' const formSchema = z.object({ @@ -35,12 +39,13 @@ interface CfdmIntegrationFormProps { isSaving?: boolean } -/** CFDM integration form — Frame/SettingRow, no Card. Preview https://reui.io/preview/base/settings-3 */ +/** CFDM integration form — Frame/SettingRow. Preview https://reui.io/preview/base/settings-2 · https://reui.io/preview/base/settings-16 */ export function CfdmIntegrationForm({ settings, onSave, isSaving, }: CfdmIntegrationFormProps) { + const queryClient = useQueryClient() const form = useForm({ resolver: zodResolver(formSchema), values: { @@ -50,6 +55,21 @@ export function CfdmIntegrationForm({ }, }) + const syncMut = useMutation({ + mutationFn: () => api.syncCfdm(), + onSuccess: async (result) => { + await queryClient.invalidateQueries({ queryKey: snapshotQueryOptions().queryKey }) + toast.success( + result.count != null + ? `Синхронизация завершена: ${result.count} bindings` + : 'Синхронизация завершена', + ) + }, + onError: (e: unknown) => { + toast.error(e instanceof ApiError ? e.message : 'Не удалось синхронизировать с CFDM') + }, + }) + function handleSubmit(values: FormValues) { const token = values.integrationToken?.trim() onSave({ @@ -59,6 +79,11 @@ export function CfdmIntegrationForm({ }) } + const canSync = + settings?.integrationEnabled === true && + Boolean(settings?.cfdmApiUrl?.trim()) && + settings?.integrationTokenSet === true + return (
@@ -103,7 +128,6 @@ export function CfdmIntegrationForm({ } labelFor="integration-token" stacked - last={!settings?.integrationLastSyncAt} >
- {settings?.integrationLastSyncAt ? ( - + syncMut.mutate()} > - - {new Date(settings.integrationLastSyncAt).toLocaleString('ru-RU')} - - - ) : null} +