feat(spaces): добавить изолированные пространства и multi-user
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Полная изоляция данных по space, Share (ACL) и Assign, switcher и участники в UI. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -27,6 +27,8 @@ import { integrationsCfdmRoutes } from './routes/integrations-cfdm.js'
|
||||
import { appSwitcherRoutes } from './routes/app-switcher.js'
|
||||
import { startScheduler } from './services/scheduler.js'
|
||||
import { authPlugin } from './plugins/auth.js'
|
||||
import { spacePlugin } from './plugins/space.js'
|
||||
import { spacesRoutes } from './routes/spaces.js'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -46,9 +48,11 @@ export async function buildApp(opts: BuildAppOptions = {}) {
|
||||
await app.register(cors, { origin: true })
|
||||
await app.register(sensible)
|
||||
await app.register(authPlugin)
|
||||
await app.register(spacePlugin)
|
||||
|
||||
app.get('/health', async () => ({ ok: true }))
|
||||
|
||||
await app.register(spacesRoutes)
|
||||
await app.register(dataRoutes)
|
||||
await app.register(vpsRoutes)
|
||||
await app.register(providersRoutes)
|
||||
|
||||
@@ -93,6 +93,11 @@ const RULES: Rule[] = [
|
||||
match: (p) => p.startsWith('/api/sync'),
|
||||
permission: 'vps:sync:write',
|
||||
},
|
||||
{
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) => p.startsWith('/api/spaces'),
|
||||
permission: 'vps:dashboard:read',
|
||||
},
|
||||
{
|
||||
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
match: (p) =>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { timingSafeEqual } from 'node:crypto'
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { runWithSpace } from '@cfdm/db'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
|
||||
function safeEqualToken(expected: string, provided: string): boolean {
|
||||
@@ -20,24 +21,54 @@ export async function requireIntegrationAuth(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
): Promise<void> {
|
||||
const row = settingsRepository.getRow('settings-main')
|
||||
if (!row?.integrationEnabled) {
|
||||
return reply.code(403).send({
|
||||
error: { code: 'INTEGRATION_DISABLED', message: 'Приём интеграции выключен' },
|
||||
})
|
||||
}
|
||||
|
||||
const expected = settingsRepository.getIntegrationToken()
|
||||
if (!expected) {
|
||||
return reply.code(503).send({
|
||||
error: { code: 'INTEGRATION_NOT_CONFIGURED', message: 'Integration token не настроен' },
|
||||
})
|
||||
}
|
||||
|
||||
const provided = extractBearer(request)
|
||||
if (!safeEqualToken(expected, provided)) {
|
||||
const row = settingsRepository.findByIntegrationToken(provided)
|
||||
|
||||
if (!row) {
|
||||
// Distinguish disabled vs bad token: if any space has integration enabled without match → 401
|
||||
const anyEnabled = settingsRepository
|
||||
.listAllSpaces()
|
||||
.some((r) => r.integrationEnabled && r.integrationToken?.trim())
|
||||
if (!anyEnabled) {
|
||||
return reply.code(403).send({
|
||||
error: { code: 'INTEGRATION_DISABLED', message: 'Приём интеграции выключен' },
|
||||
})
|
||||
}
|
||||
if (!provided) {
|
||||
return reply.code(503).send({
|
||||
error: {
|
||||
code: 'INTEGRATION_NOT_CONFIGURED',
|
||||
message: 'Integration token не настроен',
|
||||
},
|
||||
})
|
||||
}
|
||||
return reply.code(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный integration token' },
|
||||
})
|
||||
}
|
||||
|
||||
if (!safeEqualToken(row.integrationToken!.trim(), provided)) {
|
||||
return reply.code(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Неверный integration token' },
|
||||
})
|
||||
}
|
||||
|
||||
request.spaceId = row.spaceId
|
||||
// Enter space context for subsequent handlers in this request
|
||||
// Note: integrations route registers this as onRequest — ALS via runWithSpace won't wrap handler.
|
||||
// Set header for space plugin skip path — integrations are public for portal JWT.
|
||||
// Store on request; integrations-cfdm should call runWithSpace when touching DB.
|
||||
;(request as FastifyRequest & { integrationSpaceId?: string }).integrationSpaceId =
|
||||
row.spaceId
|
||||
}
|
||||
|
||||
export function runInIntegrationSpace<T>(
|
||||
request: FastifyRequest,
|
||||
fn: () => T,
|
||||
): T {
|
||||
const spaceId =
|
||||
(request as FastifyRequest & { integrationSpaceId?: string }).integrationSpaceId ??
|
||||
request.spaceId ??
|
||||
'space-main'
|
||||
return runWithSpace(spaceId, fn)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import fp from 'fastify-plugin'
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||
import { MAIN_SPACE_ID } from '@cfdm/db'
|
||||
import {
|
||||
spacesRepository,
|
||||
roleAtLeast,
|
||||
type SpaceRole,
|
||||
} from '@cfdm/db/repositories/spaces'
|
||||
import { hasPermission } from '../lib/permissions.js'
|
||||
|
||||
/** Request-scoped space ALS — entered in onRequest callback form so handlers inherit it. */
|
||||
const spaceAls = new AsyncLocalStorage<{ spaceId: string }>()
|
||||
|
||||
export function getRequestSpaceId(): string {
|
||||
return spaceAls.getStore()?.spaceId ?? MAIN_SPACE_ID
|
||||
}
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyRequest {
|
||||
spaceId?: string
|
||||
spaceRole?: string
|
||||
}
|
||||
}
|
||||
|
||||
function isSpacePublicPath(url: string): boolean {
|
||||
const path = url.split('?')[0] ?? url
|
||||
if (path === '/health' || path === '/ready') return true
|
||||
if (path === '/api/auth/config') return true
|
||||
if (path.startsWith('/api/integrations/cfdm')) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function headerSpaceId(request: FastifyRequest): string | undefined {
|
||||
const raw = request.headers['x-space-id']
|
||||
if (typeof raw === 'string' && raw.trim()) return raw.trim()
|
||||
if (Array.isArray(raw) && raw[0]?.trim()) return raw[0].trim()
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function ensureUserSpaces(request: FastifyRequest): Promise<void> {
|
||||
const user = request.authUser
|
||||
if (!user) return
|
||||
|
||||
spacesRepository.ensurePersonalSpace(user.id, user.name || user.email)
|
||||
|
||||
if (user.isAdmin) {
|
||||
spacesRepository.claimMainOwnerIfEmpty(user.id)
|
||||
}
|
||||
|
||||
if (hasPermission(user.permissions, 'vps:spaces:admin')) {
|
||||
const mainMember = spacesRepository.getMember(MAIN_SPACE_ID, user.id)
|
||||
if (!mainMember) {
|
||||
spacesRepository.addMember(MAIN_SPACE_ID, user.id, 'admin')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge request ALS into @cfdm/db space-context by syncing store.
|
||||
* Handlers use getCurrentSpaceId from @cfdm/db — we enter BOTH stores.
|
||||
*/
|
||||
import { runWithSpace } from '@cfdm/db'
|
||||
|
||||
export const spacePlugin = fp(async (app) => {
|
||||
app.addHook('onRequest', (request, reply, done) => {
|
||||
void (async () => {
|
||||
try {
|
||||
if (isSpacePublicPath(request.url) || !request.url.startsWith('/api/')) {
|
||||
done()
|
||||
return
|
||||
}
|
||||
|
||||
const authRequired = app.authConfig?.required
|
||||
|
||||
if (authRequired && request.authUser) {
|
||||
await ensureUserSpaces(request)
|
||||
}
|
||||
|
||||
let spaceId = headerSpaceId(request) ?? MAIN_SPACE_ID
|
||||
|
||||
if (authRequired && request.authUser) {
|
||||
const user = request.authUser
|
||||
const canSpacesAdmin =
|
||||
Boolean(user.isAdmin) ||
|
||||
hasPermission(user.permissions, 'vps:spaces:admin')
|
||||
|
||||
if (!spacesRepository.canAccess(spaceId, user.id, canSpacesAdmin)) {
|
||||
const personal = spacesRepository.ensurePersonalSpace(
|
||||
user.id,
|
||||
user.name || user.email,
|
||||
)
|
||||
spaceId = personal.id
|
||||
if (!spacesRepository.canAccess(spaceId, user.id, canSpacesAdmin)) {
|
||||
reply.code(403).send({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Нет доступа к пространству',
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const member = spacesRepository.getMember(spaceId, user.id)
|
||||
request.spaceRole =
|
||||
member?.role ?? (canSpacesAdmin ? 'admin' : 'viewer')
|
||||
} else {
|
||||
spacesRepository.getMain()
|
||||
request.spaceRole = 'owner'
|
||||
}
|
||||
|
||||
request.spaceId = spaceId
|
||||
|
||||
// Enter ALS for the rest of the request (callback-style keeps context)
|
||||
runWithSpace(spaceId, () => {
|
||||
spaceAls.run({ spaceId }, () => {
|
||||
done()
|
||||
})
|
||||
})
|
||||
} catch (err) {
|
||||
done(err as Error)
|
||||
}
|
||||
})()
|
||||
})
|
||||
})
|
||||
|
||||
export function requireSpaceRole(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
min: SpaceRole,
|
||||
): boolean {
|
||||
const user = request.authUser
|
||||
const spaceId = request.spaceId ?? MAIN_SPACE_ID
|
||||
if (!user) {
|
||||
return true
|
||||
}
|
||||
const canSpacesAdmin =
|
||||
Boolean(user.isAdmin) ||
|
||||
hasPermission(user.permissions, 'vps:spaces:admin')
|
||||
const member = spacesRepository.requireRole(
|
||||
spaceId,
|
||||
user.id,
|
||||
min,
|
||||
canSpacesAdmin,
|
||||
)
|
||||
if (!member) {
|
||||
void reply.code(403).send({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: `Недостаточно прав в пространстве (нужно: ${min})`,
|
||||
},
|
||||
})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function canWriteInSpace(request: FastifyRequest): boolean {
|
||||
const role = request.spaceRole ?? 'viewer'
|
||||
return roleAtLeast(role, 'member')
|
||||
}
|
||||
@@ -2,13 +2,17 @@ import type { FastifyPluginAsync } from 'fastify'
|
||||
import { cfdmSyncBindingsBodySchema } from '@cfdm/shared/contracts/integration-cfdm'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
|
||||
import { requireIntegrationAuth } from '../plugins/integration-auth.js'
|
||||
import {
|
||||
requireIntegrationAuth,
|
||||
runInIntegrationSpace,
|
||||
} from '../plugins/integration-auth.js'
|
||||
|
||||
export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post(
|
||||
'/api/integrations/cfdm/ping',
|
||||
{ onRequest: requireIntegrationAuth },
|
||||
async () => ({ ok: true, service: 'vps-tracker' }),
|
||||
async (req) =>
|
||||
runInIntegrationSpace(req, () => ({ ok: true, service: 'vps-tracker' })),
|
||||
)
|
||||
|
||||
app.post(
|
||||
@@ -22,13 +26,14 @@ export const integrationsCfdmRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
}
|
||||
|
||||
const result = vpsDomainsRepository.syncBindings(parsed.data.bindings)
|
||||
settingsRepository.touchIntegrationSync()
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
}
|
||||
return runInIntegrationSpace(req, () => {
|
||||
const result = vpsDomainsRepository.syncBindings(parsed.data.bindings)
|
||||
settingsRepository.touchIntegrationSync()
|
||||
return {
|
||||
ok: true,
|
||||
...result,
|
||||
}
|
||||
})
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +1,44 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { settingsIdForSpace, getCurrentSpaceId } from '@cfdm/db'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
import { settingsSchema, telegramTestBodySchema } from '@cfdm/shared/contracts/settings'
|
||||
|
||||
import { restartScheduler } from '../services/scheduler.js'
|
||||
import { sendTelegramMessage } from '../services/telegram.js'
|
||||
import { deliverWebhook } from '../services/notifications/channels.js'
|
||||
import { requireSpaceRole } from '../plugins/space.js'
|
||||
|
||||
export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/settings', async () => settingsRepository.list())
|
||||
|
||||
app.post('/api/settings', async (req, reply) => {
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const parsed = settingsSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const id = (req.body as { id?: string })?.id ?? 'settings-main'
|
||||
const result = settingsRepository.upsert(id, parsed.data)
|
||||
const spaceId = getCurrentSpaceId()
|
||||
const id =
|
||||
(req.body as { id?: string })?.id ?? settingsIdForSpace(spaceId)
|
||||
const result = settingsRepository.upsertForSpace(spaceId, {
|
||||
...parsed.data,
|
||||
})
|
||||
// Keep id stable
|
||||
if (result.id !== id) {
|
||||
/* upsertForSpace picks correct id */
|
||||
}
|
||||
restartScheduler()
|
||||
return reply.code(201).send(result)
|
||||
})
|
||||
|
||||
app.put<{ Params: { id: string } }>('/api/settings/:id', async (req, reply) => {
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const parsed = settingsSchema.partial().safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const result = settingsRepository.upsert(req.params.id, parsed.data)
|
||||
const spaceId = getCurrentSpaceId()
|
||||
const result = settingsRepository.upsertForSpace(spaceId, parsed.data)
|
||||
restartScheduler()
|
||||
return result
|
||||
})
|
||||
@@ -33,7 +46,7 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.post('/api/settings/telegram/test', async (req) => {
|
||||
const parsed = telegramTestBodySchema.safeParse(req.body ?? {})
|
||||
const body = parsed.success ? parsed.data : {}
|
||||
const settings = settingsRepository.getRow('settings-main')
|
||||
const settings = settingsRepository.getBySpace(getCurrentSpaceId())
|
||||
|
||||
const token = body.telegramBotToken?.trim() || settings?.telegramBotToken?.trim() || ''
|
||||
const chatId = body.telegramChatId?.trim() || settings?.telegramChatId?.trim() || ''
|
||||
@@ -55,7 +68,7 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
|
||||
app.post('/api/settings/webhook/test', async () => {
|
||||
const settings = settingsRepository.getRow('settings-main')
|
||||
const settings = settingsRepository.getBySpace(getCurrentSpaceId())
|
||||
if (!settings?.webhookEnabled) {
|
||||
return { ok: false, error: 'Включите webhook в настройках' }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { closeDb, MAIN_SPACE_ID, runWithSpace } from '@cfdm/db'
|
||||
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
|
||||
import { spacesRepository, vpsGrantsRepository } from '@cfdm/db/repositories/spaces'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
import { buildApp } from '../index.js'
|
||||
|
||||
describe('spaces API', () => {
|
||||
beforeEach(() => {
|
||||
resetTestDb()
|
||||
seedTestProvider()
|
||||
seedTestProviderAccount()
|
||||
spacesRepository.getMain()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
closeDb()
|
||||
})
|
||||
|
||||
it('lists spaces and creates personal space', async () => {
|
||||
const app = await buildApp()
|
||||
const res = await app.inject({ method: 'GET', url: '/api/spaces' })
|
||||
expect(res.statusCode).toBe(200)
|
||||
const list = res.json() as { id: string }[]
|
||||
expect(list.some((s) => s.id === MAIN_SPACE_ID)).toBe(true)
|
||||
await app.close()
|
||||
})
|
||||
|
||||
it('shares and assigns VPS between spaces', async () => {
|
||||
const personal = spacesRepository.create({
|
||||
id: 'space-user-u1',
|
||||
name: 'User 1',
|
||||
slug: 'user-u1',
|
||||
kind: 'personal',
|
||||
ownerUserId: 'u1',
|
||||
})
|
||||
|
||||
const vps = runWithSpace(MAIN_SPACE_ID, () =>
|
||||
vpsRepository.create({
|
||||
ip: '1.2.3.4',
|
||||
providerId: 'prov-1',
|
||||
providerAccountId: 'acc-1',
|
||||
status: 'active',
|
||||
}),
|
||||
)
|
||||
|
||||
const grant = vpsGrantsRepository.create({
|
||||
vpsId: vps.id,
|
||||
fromSpaceId: MAIN_SPACE_ID,
|
||||
toSpaceId: personal.id,
|
||||
permission: 'write',
|
||||
grantedByUserId: 'admin',
|
||||
})
|
||||
expect(grant.permission).toBe('write')
|
||||
|
||||
const sharedList = runWithSpace(personal.id, () => {
|
||||
const grants = vpsGrantsRepository.listToSpace(personal.id)
|
||||
return vpsRepository.listByIds(grants.map((g) => g.vpsId))
|
||||
})
|
||||
expect(sharedList).toHaveLength(1)
|
||||
expect(sharedList[0]?.id).toBe(vps.id)
|
||||
|
||||
vpsGrantsRepository.deleteByVps(vps.id)
|
||||
const moved = vpsRepository.assignToSpace(vps.id, personal.id)
|
||||
expect(moved?.spaceId).toBe(personal.id)
|
||||
expect(moved?.providerAccountId).toBeFalsy()
|
||||
|
||||
const stillInMain = runWithSpace(MAIN_SPACE_ID, () => vpsRepository.get(vps.id))
|
||||
expect(stillInMain).toBeUndefined()
|
||||
|
||||
const inPersonal = runWithSpace(personal.id, () => vpsRepository.get(vps.id))
|
||||
expect(inPersonal?.id).toBe(vps.id)
|
||||
})
|
||||
|
||||
it('share endpoint via inject', async () => {
|
||||
const personal = spacesRepository.create({
|
||||
id: 'space-user-u2',
|
||||
name: 'User 2',
|
||||
slug: 'user-u2',
|
||||
kind: 'personal',
|
||||
ownerUserId: 'u2',
|
||||
})
|
||||
const vps = runWithSpace(MAIN_SPACE_ID, () =>
|
||||
vpsRepository.create({
|
||||
ip: '10.0.0.1',
|
||||
providerId: 'prov-1',
|
||||
providerAccountId: 'acc-1',
|
||||
status: 'active',
|
||||
}),
|
||||
)
|
||||
|
||||
// Same process DB as resetTestDb (:memory: already set)
|
||||
const app = await buildApp()
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/spaces/${MAIN_SPACE_ID}/vps/${vps.id}/share`,
|
||||
headers: { 'x-space-id': MAIN_SPACE_ID },
|
||||
payload: { toSpaceId: personal.id, permission: 'read' },
|
||||
})
|
||||
expect(res.statusCode).toBe(201)
|
||||
const body = res.json() as { toSpaceId: string; permission: string }
|
||||
expect(body.toSpaceId).toBe(personal.id)
|
||||
expect(body.permission).toBe('read')
|
||||
await app.close()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,256 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { MAIN_SPACE_ID } from '@cfdm/db'
|
||||
import {
|
||||
spacesRepository,
|
||||
vpsGrantsRepository,
|
||||
type SpaceRole,
|
||||
type GrantPermission,
|
||||
} from '@cfdm/db/repositories/spaces'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
import { hasPermission } from '../lib/permissions.js'
|
||||
import { requireSpaceRole } from '../plugins/space.js'
|
||||
|
||||
const ROLES: SpaceRole[] = ['owner', 'admin', 'member', 'viewer']
|
||||
|
||||
function isSpacesAdmin(request: { authUser?: { isAdmin?: boolean; permissions: string[] } }) {
|
||||
const u = request.authUser
|
||||
if (!u) return true
|
||||
return Boolean(u.isAdmin) || hasPermission(u.permissions, 'vps:spaces:admin')
|
||||
}
|
||||
|
||||
export const spacesRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/spaces', async (req) => {
|
||||
const user = req.authUser
|
||||
if (!user) {
|
||||
return spacesRepository.listAll().map((s) => ({ ...s, role: 'owner' }))
|
||||
}
|
||||
return spacesRepository.listForUser(user.id, isSpacesAdmin(req))
|
||||
})
|
||||
|
||||
app.post('/api/spaces', async (req, reply) => {
|
||||
const user = req.authUser
|
||||
if (!user) {
|
||||
return reply.code(401).send({
|
||||
error: { code: 'UNAUTHORIZED', message: 'Требуется авторизация' },
|
||||
})
|
||||
}
|
||||
const body = req.body as { name?: string; slug?: string }
|
||||
const name = String(body.name ?? '').trim() || 'Новое пространство'
|
||||
const slug =
|
||||
String(body.slug ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9-]/g, '-') || `space-${Date.now()}`
|
||||
const created = spacesRepository.create({
|
||||
name,
|
||||
slug,
|
||||
kind: 'personal',
|
||||
ownerUserId: user.id,
|
||||
})
|
||||
return reply.code(201).send({ ...created, role: 'owner' })
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
|
||||
const space = spacesRepository.get(req.params.id)
|
||||
if (!space) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
const user = req.authUser
|
||||
if (user && !spacesRepository.canAccess(space.id, user.id, isSpacesAdmin(req))) {
|
||||
return reply.code(403).send({ error: { code: 'FORBIDDEN', message: 'Нет доступа' } })
|
||||
}
|
||||
const member = user
|
||||
? spacesRepository.getMember(space.id, user.id)
|
||||
: { role: 'owner' }
|
||||
return { ...space, role: member?.role ?? 'viewer' }
|
||||
})
|
||||
|
||||
app.patch<{ Params: { id: string } }>('/api/spaces/:id', async (req, reply) => {
|
||||
// Temporarily set space for role check
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const body = req.body as { name?: string; slug?: string }
|
||||
const updated = spacesRepository.update(req.params.id, {
|
||||
...(body.name !== undefined ? { name: String(body.name).trim() } : {}),
|
||||
...(body.slug !== undefined
|
||||
? { slug: String(body.slug).trim().toLowerCase() }
|
||||
: {}),
|
||||
})
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return updated
|
||||
})
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/spaces/:id/members',
|
||||
async (req, reply) => {
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'viewer')) return
|
||||
return spacesRepository.listMembers(req.params.id)
|
||||
},
|
||||
)
|
||||
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/spaces/:id/members',
|
||||
async (req, reply) => {
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const body = req.body as { userId?: string; role?: string }
|
||||
const userId = String(body.userId ?? '').trim()
|
||||
if (!userId) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'userId обязателен' },
|
||||
})
|
||||
}
|
||||
const role = (ROLES.includes(body.role as SpaceRole)
|
||||
? body.role
|
||||
: 'member') as SpaceRole
|
||||
if (role === 'owner') {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'Нельзя назначить owner через invite' },
|
||||
})
|
||||
}
|
||||
const member = spacesRepository.addMember(req.params.id, userId, role)
|
||||
return reply.code(201).send(member)
|
||||
},
|
||||
)
|
||||
|
||||
app.patch<{ Params: { id: string; userId: string } }>(
|
||||
'/api/spaces/:id/members/:userId',
|
||||
async (req, reply) => {
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const body = req.body as { role?: string }
|
||||
const role = body.role as SpaceRole
|
||||
if (!ROLES.includes(role) || role === 'owner') {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'Некорректная роль' },
|
||||
})
|
||||
}
|
||||
const updated = spacesRepository.updateMember(
|
||||
req.params.id,
|
||||
req.params.userId,
|
||||
role,
|
||||
)
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return updated
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string; userId: string } }>(
|
||||
'/api/spaces/:id/members/:userId',
|
||||
async (req, reply) => {
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const member = spacesRepository.getMember(req.params.id, req.params.userId)
|
||||
if (member?.role === 'owner') {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'Нельзя удалить владельца' },
|
||||
})
|
||||
}
|
||||
const ok = spacesRepository.removeMember(req.params.id, req.params.userId)
|
||||
if (!ok) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
/** Share VPS from current ownership space to target space */
|
||||
app.post<{ Params: { id: string; vpsId: string } }>(
|
||||
'/api/spaces/:id/vps/:vpsId/share',
|
||||
async (req, reply) => {
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const body = req.body as { toSpaceId?: string; permission?: string }
|
||||
const toSpaceId = String(body.toSpaceId ?? '').trim()
|
||||
if (!toSpaceId) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'toSpaceId обязателен' },
|
||||
})
|
||||
}
|
||||
if (!spacesRepository.get(toSpaceId)) {
|
||||
return reply.code(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Целевое пространство не найдено' },
|
||||
})
|
||||
}
|
||||
const vps = vpsRepository.getAnySpace(req.params.vpsId)
|
||||
if (!vps || vps.spaceId !== req.params.id) {
|
||||
return reply.code(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'VPS не найден в этом пространстве' },
|
||||
})
|
||||
}
|
||||
const permission: GrantPermission =
|
||||
body.permission === 'write' ? 'write' : 'read'
|
||||
const grant = vpsGrantsRepository.create({
|
||||
vpsId: req.params.vpsId,
|
||||
fromSpaceId: req.params.id,
|
||||
toSpaceId,
|
||||
permission,
|
||||
grantedByUserId: req.authUser?.id ?? null,
|
||||
})
|
||||
return reply.code(201).send(grant)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete<{ Params: { id: string; grantId: string } }>(
|
||||
'/api/spaces/:id/vps-grants/:grantId',
|
||||
async (req, reply) => {
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const grant = vpsGrantsRepository.get(req.params.grantId)
|
||||
if (!grant || (grant.fromSpaceId !== req.params.id && grant.toSpaceId !== req.params.id)) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
vpsGrantsRepository.delete(req.params.grantId)
|
||||
return reply.code(204).send()
|
||||
},
|
||||
)
|
||||
|
||||
/** Assign (move) VPS to target space */
|
||||
app.post<{ Params: { id: string; vpsId: string } }>(
|
||||
'/api/spaces/:id/vps/:vpsId/assign',
|
||||
async (req, reply) => {
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'admin')) return
|
||||
const body = req.body as { toSpaceId?: string }
|
||||
const toSpaceId = String(body.toSpaceId ?? '').trim()
|
||||
if (!toSpaceId) {
|
||||
return reply.code(400).send({
|
||||
error: { code: 'VALIDATION', message: 'toSpaceId обязателен' },
|
||||
})
|
||||
}
|
||||
if (!spacesRepository.get(toSpaceId)) {
|
||||
return reply.code(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'Целевое пространство не найдено' },
|
||||
})
|
||||
}
|
||||
const vps = vpsRepository.getAnySpace(req.params.vpsId)
|
||||
if (!vps || vps.spaceId !== req.params.id) {
|
||||
return reply.code(404).send({
|
||||
error: { code: 'NOT_FOUND', message: 'VPS не найден в этом пространстве' },
|
||||
})
|
||||
}
|
||||
vpsGrantsRepository.deleteByVps(req.params.vpsId)
|
||||
const moved = vpsRepository.assignToSpace(req.params.vpsId, toSpaceId)
|
||||
return moved
|
||||
},
|
||||
)
|
||||
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/spaces/:id/vps-grants',
|
||||
async (req, reply) => {
|
||||
req.spaceId = req.params.id
|
||||
if (!requireSpaceRole(req, reply, 'viewer')) return
|
||||
return {
|
||||
incoming: vpsGrantsRepository.listToSpace(req.params.id),
|
||||
outgoing: vpsGrantsRepository.listFromSpace(req.params.id),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export { MAIN_SPACE_ID }
|
||||
@@ -1,13 +1,41 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
import { vpsDomainsRepository } from '@cfdm/db/repositories/vps-domains'
|
||||
import { vpsGrantsRepository } from '@cfdm/db/repositories/spaces'
|
||||
import { getCurrentSpaceId } from '@cfdm/db'
|
||||
import { vpsSchema } from '@cfdm/shared/contracts/vps'
|
||||
import { auditCreate, auditDelete, auditUpdate } from '../services/audit.js'
|
||||
import { canWriteInSpace, requireSpaceRole } from '../plugins/space.js'
|
||||
|
||||
export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.get('/api/vps', async () => vpsRepository.list())
|
||||
app.get('/api/vps', async () => {
|
||||
const owned = vpsRepository.list()
|
||||
const spaceId = getCurrentSpaceId()
|
||||
const grants = vpsGrantsRepository.listToSpace(spaceId)
|
||||
const ownedIds = new Set(owned.map((v) => v.id))
|
||||
const shared = vpsRepository
|
||||
.listByIds(grants.map((g) => g.vpsId).filter((id) => !ownedIds.has(id)))
|
||||
.map((v) => {
|
||||
const g = grants.find((x) => x.vpsId === v.id)
|
||||
return {
|
||||
...v,
|
||||
access: 'shared' as const,
|
||||
grantPermission: (g?.permission === 'write' ? 'write' : 'read') as
|
||||
| 'read'
|
||||
| 'write',
|
||||
providerAccountId: '',
|
||||
}
|
||||
})
|
||||
return [...owned, ...shared]
|
||||
})
|
||||
|
||||
app.post('/api/vps', async (req, reply) => {
|
||||
if (!requireSpaceRole(req, reply, 'member')) return
|
||||
if (!canWriteInSpace(req)) {
|
||||
return reply.code(403).send({
|
||||
error: { code: 'FORBIDDEN', message: 'Нет прав на запись в пространстве' },
|
||||
})
|
||||
}
|
||||
const parsed = vpsSchema.safeParse(req.body)
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
@@ -24,18 +52,48 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
||||
if (!parsed.success) {
|
||||
return reply.code(400).send({ error: { code: 'VALIDATION', message: parsed.error.message } })
|
||||
}
|
||||
const updated = vpsRepository.update(req.params.id, parsed.data)
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
|
||||
const owned = vpsRepository.get(req.params.id)
|
||||
if (owned) {
|
||||
if (!requireSpaceRole(req, reply, 'member')) return
|
||||
const updated = vpsRepository.update(req.params.id, parsed.data)
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
vpsDomainsRepository.rematchAll()
|
||||
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
|
||||
return updated
|
||||
}
|
||||
vpsDomainsRepository.rematchAll()
|
||||
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
|
||||
return updated
|
||||
|
||||
// Shared write?
|
||||
const grant = vpsGrantsRepository.getGrantInCurrentSpace(req.params.id)
|
||||
if (grant?.permission === 'write') {
|
||||
if (!requireSpaceRole(req, reply, 'member')) return
|
||||
const updated = vpsRepository.updateAnySpace(req.params.id, parsed.data)
|
||||
if (!updated) {
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
auditUpdate('vps', req.params.id, parsed.data as Record<string, unknown>)
|
||||
return { ...updated, access: 'shared', grantPermission: 'write' }
|
||||
}
|
||||
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
})
|
||||
|
||||
app.delete<{ Params: { id: string } }>('/api/vps/:id', async (req, reply) => {
|
||||
if (!requireSpaceRole(req, reply, 'member')) return
|
||||
const ok = vpsRepository.delete(req.params.id)
|
||||
if (!ok) {
|
||||
// Shared VPS cannot be deleted from grantee space
|
||||
const grant = vpsGrantsRepository.getGrantInCurrentSpace(req.params.id)
|
||||
if (grant) {
|
||||
return reply.code(403).send({
|
||||
error: {
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Общий VPS можно только отозвать у владельца, не удалить',
|
||||
},
|
||||
})
|
||||
}
|
||||
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
|
||||
}
|
||||
auditDelete('vps', req.params.id)
|
||||
@@ -43,6 +101,7 @@ export const vpsRoutes: FastifyPluginAsync = async (app) => {
|
||||
})
|
||||
|
||||
app.patch('/api/vps/bulk', async (req, reply) => {
|
||||
if (!requireSpaceRole(req, reply, 'member')) return
|
||||
const body = req.body as { ids?: string[]; action?: string; value?: unknown }
|
||||
const ids = Array.isArray(body.ids) ? body.ids : []
|
||||
if (ids.length === 0) {
|
||||
|
||||
@@ -2,10 +2,8 @@ import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
import { vpsRepository } from '@cfdm/db/repositories/vps'
|
||||
import type { VpsTrackerEvent } from '@cfdm/shared/contracts/integration-cfdm'
|
||||
|
||||
const SETTINGS_ID = 'settings-main'
|
||||
|
||||
function resolveCfdmApiBase(): string | null {
|
||||
const row = settingsRepository.getRow(SETTINGS_ID)
|
||||
const row = settingsRepository.getBySpace()
|
||||
if (!row) return null
|
||||
const explicit = row.cfdmApiUrl?.trim()
|
||||
if (explicit) return explicit.replace(/\/$/, '')
|
||||
@@ -19,7 +17,7 @@ export async function notifyCfdmVpsEvent(
|
||||
): Promise<void> {
|
||||
if (vpsIds.length === 0) return
|
||||
|
||||
const row = settingsRepository.getRow(SETTINGS_ID)
|
||||
const row = settingsRepository.getBySpace()
|
||||
if (!row?.integrationEnabled) return
|
||||
|
||||
const token = settingsRepository.getIntegrationToken()
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
|
||||
const account: FourvpsSyncAccount = {
|
||||
id: 'acc-4vps',
|
||||
spaceId: 'space-main',
|
||||
providerId: 'prov-4vps',
|
||||
name: '4VPS Account',
|
||||
panelUrl: '',
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
/**
|
||||
* Generic account sync with sync_log recording
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { getDb, schema, getCurrentSpaceId } from '@cfdm/db'
|
||||
|
||||
import type { ProviderAdapter, SyncResult } from './types.js'
|
||||
|
||||
@@ -18,12 +14,14 @@ export async function runAccountSync(
|
||||
opts: { skipTariffs?: boolean; skipVpsPayments?: boolean } = {},
|
||||
): Promise<RunAccountSyncResult> {
|
||||
const db = getDb()
|
||||
const accountRow = account as { id: string }
|
||||
const accountRow = account as { id: string; spaceId?: string }
|
||||
const logId = `sync-${accountRow.id}-${Date.now()}`
|
||||
const spaceId = accountRow.spaceId ?? getCurrentSpaceId()
|
||||
|
||||
db.insert(schema.syncLog)
|
||||
.values({
|
||||
id: logId,
|
||||
spaceId,
|
||||
accountId: accountRow.id,
|
||||
startedAt: new Date().toISOString(),
|
||||
status: 'running',
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
function makeAccount(): RuvdsSyncAccount {
|
||||
return {
|
||||
id: 'acc-ruvds',
|
||||
spaceId: 'space-main',
|
||||
providerId: 'prov-ruvds',
|
||||
name: 'RuVDS',
|
||||
panelUrl: '',
|
||||
|
||||
+184
-111
@@ -1,5 +1,5 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { getDb, schema } from '@cfdm/db'
|
||||
import { eq, sql } from 'drizzle-orm'
|
||||
import { getDb, schema, runWithSpaceAsync, MAIN_SPACE_ID } from '@cfdm/db'
|
||||
import { settingsRepository } from '@cfdm/db/repositories/settings'
|
||||
|
||||
import { resolveSyncAccount, getProviderAdapter, type SyncReadyAccount } from './providers/index.js'
|
||||
@@ -20,26 +20,30 @@ let syncTariffsIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let notifyIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
let uptimeIntervalId: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const SETTINGS_ID = 'settings-main'
|
||||
|
||||
type AccountRow = typeof schema.providerAccounts.$inferSelect
|
||||
type SettingsRow = typeof schema.settings.$inferSelect
|
||||
|
||||
interface SyncableAccountEntry {
|
||||
account: SyncReadyAccount
|
||||
apiType: string
|
||||
}
|
||||
|
||||
function getSyncableAccounts(): SyncableAccountEntry[] {
|
||||
function getSyncableAccounts(spaceId: string): SyncableAccountEntry[] {
|
||||
const db = getDb()
|
||||
const rows = db
|
||||
.all<AccountRow>(sql`
|
||||
SELECT pa.* FROM provider_accounts pa
|
||||
INNER JOIN providers p ON p.id = pa.providerId
|
||||
WHERE lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds')
|
||||
WHERE pa.spaceId = ${spaceId}
|
||||
AND lower(trim(COALESCE(p.apiType, ''))) IN ('billmanager', '4vps', 'macloud', 'vdsina', 'veesp', 'ruvds')
|
||||
AND length(trim(COALESCE(p.apiBaseUrl, ''))) > 0
|
||||
AND pa.apiCredentials IS NOT NULL AND length(trim(pa.apiCredentials)) > 0
|
||||
`)
|
||||
const providers = db.select().from(schema.providers).all()
|
||||
const providers = db
|
||||
.select()
|
||||
.from(schema.providers)
|
||||
.where(eq(schema.providers.spaceId, spaceId))
|
||||
.all()
|
||||
const providerById = new Map(providers.map((p) => [p.id, p]))
|
||||
return rows
|
||||
.map((a) => {
|
||||
@@ -49,123 +53,184 @@ function getSyncableAccounts(): SyncableAccountEntry[] {
|
||||
.filter((e): e is SyncableAccountEntry => e != null)
|
||||
}
|
||||
|
||||
function allSettings(): SettingsRow[] {
|
||||
return settingsRepository.listAllSpaces()
|
||||
}
|
||||
|
||||
export async function runNotificationTick(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings) return
|
||||
const payload = buildPaymentExpiryNotification()
|
||||
if (payload) await publishNotification(settings, payload)
|
||||
} catch (err) {
|
||||
console.warn('Notification tick error:', err instanceof Error ? err.message : err)
|
||||
for (const settings of allSettings()) {
|
||||
const spaceId = settings.spaceId || MAIN_SPACE_ID
|
||||
try {
|
||||
await runWithSpaceAsync(spaceId, async () => {
|
||||
const payload = buildPaymentExpiryNotification()
|
||||
if (payload) await publishNotification(settings, payload)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Notification tick error [${spaceId}]:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledSync(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
for (const settings of allSettings()) {
|
||||
if (!settings.syncEnabled) continue
|
||||
const spaceId = settings.spaceId || MAIN_SPACE_ID
|
||||
try {
|
||||
await runWithSpaceAsync(spaceId, async () => {
|
||||
const entries = getSyncableAccounts(spaceId)
|
||||
const digestLines: string[] = []
|
||||
const lowBalanceLines: string[] = []
|
||||
|
||||
const entries = getSyncableAccounts()
|
||||
const digestLines: string[] = []
|
||||
const lowBalanceLines: string[] = []
|
||||
for (const { account, apiType } of entries) {
|
||||
try {
|
||||
const adapter = getProviderAdapter(apiType)
|
||||
const result = await runAccountSync(adapter, account, { skipTariffs: true })
|
||||
const s = result.syncSummary
|
||||
const parts: string[] = []
|
||||
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
||||
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
|
||||
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
|
||||
digestLines.push(
|
||||
`✓ ${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`,
|
||||
)
|
||||
|
||||
for (const { account, apiType } of entries) {
|
||||
try {
|
||||
const adapter = getProviderAdapter(apiType)
|
||||
const result = await runAccountSync(adapter, account, { skipTariffs: true })
|
||||
const s = result.syncSummary
|
||||
const parts: string[] = []
|
||||
if (s.added?.length) parts.push(`+${s.added.length} VPS`)
|
||||
if (s.updated?.length) parts.push(`изм. ${s.updated.length}`)
|
||||
if (result.paymentsCount) parts.push(`платежи +${result.paymentsCount}`)
|
||||
digestLines.push(`✓ ${account.name}: ${parts.length ? parts.join(', ') : 'без изменений'}`)
|
||||
|
||||
const apiBal = result.balance?.balance
|
||||
const threshold = account.balanceAlertBelow
|
||||
if (
|
||||
settings.notifyLowBalanceEnabled &&
|
||||
threshold != null &&
|
||||
Number.isFinite(Number(threshold)) &&
|
||||
apiBal != null &&
|
||||
Number.isFinite(Number(apiBal)) &&
|
||||
Number(apiBal) < Number(threshold)
|
||||
) {
|
||||
const cur = result.balance?.currency || account.balanceCurrency || account.currency || ''
|
||||
lowBalanceLines.push(`• ${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
|
||||
const apiBal = result.balance?.balance
|
||||
const threshold = account.balanceAlertBelow
|
||||
if (
|
||||
settings.notifyLowBalanceEnabled &&
|
||||
threshold != null &&
|
||||
Number.isFinite(Number(threshold)) &&
|
||||
apiBal != null &&
|
||||
Number.isFinite(Number(apiBal)) &&
|
||||
Number(apiBal) < Number(threshold)
|
||||
) {
|
||||
const cur =
|
||||
result.balance?.currency || account.balanceCurrency || account.currency || ''
|
||||
lowBalanceLines.push(`• ${account.name}: ${apiBal} ${cur} (порог ${threshold})`)
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'ошибка'
|
||||
digestLines.push(`✗ ${account.name}: ${message}`)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'ошибка'
|
||||
digestLines.push(`✗ ${account.name}: ${message}`)
|
||||
}
|
||||
}
|
||||
|
||||
await publishMany(settings, [
|
||||
buildSyncDigestNotification(digestLines),
|
||||
buildLowBalanceNotification(lowBalanceLines),
|
||||
])
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync error:', err instanceof Error ? err.message : err)
|
||||
await publishMany(settings, [
|
||||
buildSyncDigestNotification(digestLines),
|
||||
buildLowBalanceNotification(lowBalanceLines),
|
||||
])
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(`Scheduled sync error [${spaceId}]:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledSyncTariffs(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings?.syncEnabled) return
|
||||
for (const settings of allSettings()) {
|
||||
if (!settings.syncEnabled) continue
|
||||
const spaceId = settings.spaceId || MAIN_SPACE_ID
|
||||
try {
|
||||
await runWithSpaceAsync(spaceId, async () => {
|
||||
const entries = getSyncableAccounts(spaceId)
|
||||
const providers = getDb()
|
||||
.select()
|
||||
.from(schema.providers)
|
||||
.where(eq(schema.providers.spaceId, spaceId))
|
||||
.all()
|
||||
|
||||
const entries = getSyncableAccounts()
|
||||
const providers = getDb().select().from(schema.providers).all()
|
||||
|
||||
for (const { account, apiType } of entries) {
|
||||
try {
|
||||
const adapter = getProviderAdapter(apiType)
|
||||
const result = await runAccountSync(adapter, account, { skipVpsPayments: true })
|
||||
const newTariffs = result.newTariffs || []
|
||||
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
const providerName = provider?.name || account.name || '-'
|
||||
const payload = buildNewTariffsNotification(
|
||||
providerName,
|
||||
newTariffs.map((t) => ({ name: t.name, price: t.price })),
|
||||
)
|
||||
if (payload) await publishNotification(settings, payload)
|
||||
for (const { account, apiType } of entries) {
|
||||
try {
|
||||
const adapter = getProviderAdapter(apiType)
|
||||
const result = await runAccountSync(adapter, account, { skipVpsPayments: true })
|
||||
const newTariffs = result.newTariffs || []
|
||||
if (newTariffs.length > 0 && settings.notifyNewTariffsEnabled) {
|
||||
const provider = providers.find((p) => p.id === account.providerId)
|
||||
const providerName = provider?.name || account.name || '-'
|
||||
const payload = buildNewTariffsNotification(
|
||||
providerName,
|
||||
newTariffs.map((t) => ({ name: t.name, price: t.price })),
|
||||
)
|
||||
if (payload) await publishNotification(settings, payload)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Sync tariffs failed for account ${account.id}:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Sync tariffs failed for account ${account.id}:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`Scheduled sync tariffs error [${spaceId}]:`,
|
||||
err instanceof Error ? err.message : err,
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Scheduled sync tariffs error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
export async function runScheduledUptimeChecks(): Promise<void> {
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings) return
|
||||
|
||||
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
|
||||
await publishMany(settings, [
|
||||
buildVpsHealthNotification(
|
||||
'vps_down',
|
||||
newlyDown.map((h) => ({ id: h.id, label: h.label })),
|
||||
),
|
||||
buildVpsHealthNotification(
|
||||
'vps_up',
|
||||
newlyUp.map((h) => ({ id: h.id, label: h.label })),
|
||||
),
|
||||
])
|
||||
if (newlyDown.length > 0) {
|
||||
void notifyCfdmVpsEvent(
|
||||
'vps_down',
|
||||
newlyDown.map((h) => h.id),
|
||||
)
|
||||
for (const settings of allSettings()) {
|
||||
const spaceId = settings.spaceId || MAIN_SPACE_ID
|
||||
try {
|
||||
await runWithSpaceAsync(spaceId, async () => {
|
||||
const { newlyDown, newlyUp } = await runVpsUptimeChecks()
|
||||
await publishMany(settings, [
|
||||
buildVpsHealthNotification(
|
||||
'vps_down',
|
||||
newlyDown.map((h) => ({ id: h.id, label: h.label })),
|
||||
),
|
||||
buildVpsHealthNotification(
|
||||
'vps_up',
|
||||
newlyUp.map((h) => ({ id: h.id, label: h.label })),
|
||||
),
|
||||
])
|
||||
if (newlyDown.length > 0) {
|
||||
void notifyCfdmVpsEvent(
|
||||
'vps_down',
|
||||
newlyDown.map((h) => h.id),
|
||||
)
|
||||
}
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn(`Uptime check error [${spaceId}]:`, err instanceof Error ? err.message : err)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Uptime check error:', err instanceof Error ? err.message : err)
|
||||
}
|
||||
}
|
||||
|
||||
function pickSchedulerIntervals(rows: SettingsRow[]): {
|
||||
notifyInterval: number
|
||||
uptimeInterval: number
|
||||
syncInterval: number | null
|
||||
tariffsInterval: number | null
|
||||
} {
|
||||
let notifyInterval = 60
|
||||
let uptimeInterval = 5
|
||||
let syncInterval: number | null = null
|
||||
let tariffsInterval: number | null = null
|
||||
|
||||
for (const s of rows) {
|
||||
notifyInterval = Math.min(
|
||||
notifyInterval,
|
||||
Math.max(15, Number(s.notifyIntervalMinutes) || 60),
|
||||
)
|
||||
uptimeInterval = Math.min(
|
||||
uptimeInterval,
|
||||
Math.max(1, Number(s.uptimeCheckIntervalMinutes) || 5),
|
||||
)
|
||||
if (s.syncEnabled) {
|
||||
const si = Math.max(15, Number(s.syncIntervalMinutes) || 60)
|
||||
const ti = Math.max(60, Number(s.syncTariffsIntervalMinutes) || 1440)
|
||||
syncInterval = syncInterval == null ? si : Math.min(syncInterval, si)
|
||||
tariffsInterval = tariffsInterval == null ? ti : Math.min(tariffsInterval, ti)
|
||||
}
|
||||
}
|
||||
return { notifyInterval, uptimeInterval, syncInterval, tariffsInterval }
|
||||
}
|
||||
|
||||
export function startScheduler(): void {
|
||||
if (syncIntervalId) clearInterval(syncIntervalId)
|
||||
syncIntervalId = null
|
||||
@@ -177,28 +242,36 @@ export function startScheduler(): void {
|
||||
uptimeIntervalId = null
|
||||
|
||||
try {
|
||||
const settings = settingsRepository.getRow(SETTINGS_ID)
|
||||
if (!settings) return
|
||||
const rows = allSettings()
|
||||
if (rows.length === 0) return
|
||||
|
||||
const notifyInterval = Math.max(15, Number(settings.notifyIntervalMinutes) || 60)
|
||||
const uptimeInterval = Math.max(1, Number(settings.uptimeCheckIntervalMinutes) || 5)
|
||||
const { notifyInterval, uptimeInterval, syncInterval, tariffsInterval } =
|
||||
pickSchedulerIntervals(rows)
|
||||
|
||||
notifyIntervalId = setInterval(() => void runNotificationTick(), notifyInterval * 60 * 1000)
|
||||
uptimeIntervalId = setInterval(() => void runScheduledUptimeChecks(), uptimeInterval * 60 * 1000)
|
||||
uptimeIntervalId = setInterval(
|
||||
() => void runScheduledUptimeChecks(),
|
||||
uptimeInterval * 60 * 1000,
|
||||
)
|
||||
void runNotificationTick()
|
||||
void runScheduledUptimeChecks()
|
||||
|
||||
const parts = [`notify every ${notifyInterval} min`, `uptime every ${uptimeInterval} min`]
|
||||
const parts = [
|
||||
`notify every ${notifyInterval} min`,
|
||||
`uptime every ${uptimeInterval} min`,
|
||||
`spaces=${rows.length}`,
|
||||
]
|
||||
|
||||
if (settings.syncEnabled) {
|
||||
const interval = Math.max(15, Number(settings.syncIntervalMinutes) || 60)
|
||||
const tariffsInterval = Math.max(60, Number(settings.syncTariffsIntervalMinutes) || 1440)
|
||||
syncIntervalId = setInterval(() => void runScheduledSync(), interval * 60 * 1000)
|
||||
if (syncInterval != null) {
|
||||
syncIntervalId = setInterval(() => void runScheduledSync(), syncInterval * 60 * 1000)
|
||||
parts.unshift(`sync every ${syncInterval} min`)
|
||||
}
|
||||
if (tariffsInterval != null) {
|
||||
syncTariffsIntervalId = setInterval(
|
||||
() => void runScheduledSyncTariffs(),
|
||||
tariffsInterval * 60 * 1000,
|
||||
)
|
||||
parts.unshift(`sync every ${interval} min`, `tariffs every ${tariffsInterval} min`)
|
||||
parts.unshift(`tariffs every ${tariffsInterval} min`)
|
||||
}
|
||||
|
||||
console.log(`Scheduler: ${parts.join(', ')}`)
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
function makeAccount(apiType: 'macloud' | 'vdsina'): UserApiSyncAccount {
|
||||
return {
|
||||
id: `acc-${apiType}`,
|
||||
spaceId: 'space-main',
|
||||
providerId: `prov-${apiType}`,
|
||||
name: `${apiType} Account`,
|
||||
panelUrl: '',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { fetchBalance, fetchInvoices, fetchTariffList, fetchVpsRecords } from '.
|
||||
function makeAccount(): VeespSyncAccount {
|
||||
return {
|
||||
id: 'acc-veesp',
|
||||
spaceId: 'space-main',
|
||||
providerId: 'prov-veesp',
|
||||
name: 'Veesp',
|
||||
panelUrl: '',
|
||||
|
||||
Reference in New Issue
Block a user