feat(projects): переработать проекты и отчётность по проектам
Docker / build (push) Failing after 20s

Добавлены карточка проекта, KPI и фильтры на /projects, отчёты с разрезом по проектам, cascade rename и защита удаления на API.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-06-29 19:21:04 +07:00
co-authored by Cursor
parent 0bcea39868
commit 447d499a8d
19 changed files with 1561 additions and 100 deletions
+95
View File
@@ -0,0 +1,95 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { closeDb } from '@cfdm/db'
import { resetTestDb, seedTestProvider, seedTestProviderAccount } from '@cfdm/db/test-setup'
import { projectsRepository } from '@cfdm/db/repositories/projects'
import { getSqlite } from '@cfdm/db'
import { buildApp } from '../index.js'
describe('projects routes', () => {
let app: Awaited<ReturnType<typeof buildApp>>
beforeEach(async () => {
resetTestDb()
seedTestProvider()
seedTestProviderAccount()
app = await buildApp()
})
afterEach(async () => {
await app.close()
closeDb()
})
it('lists projects', async () => {
projectsRepository.create({ name: 'Alpha', color: '#ff0000' })
const res = await app.inject({ method: 'GET', url: '/api/projects' })
expect(res.statusCode).toBe(200)
const body = res.json() as { id: string; name: string; color?: string }[]
expect(body).toHaveLength(1)
expect(body[0]?.name).toBe('Alpha')
expect(body[0]?.color).toBe('#ff0000')
})
it('creates project with color and notes', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/projects',
payload: { name: 'Web', color: '#3b82f6', notes: 'Production sites' },
})
expect(res.statusCode).toBe(201)
const body = res.json() as { id: string; name: string; color?: string; notes?: string }
expect(body.name).toBe('Web')
expect(body.color).toBe('#3b82f6')
expect(body.notes).toBe('Production sites')
})
it('renames project and cascades vps.project', async () => {
const project = projectsRepository.create({ name: 'OldName' })
getSqlite()
.prepare(
`INSERT INTO vps (id, ip, providerId, providerAccountId, status, project, projectId)
VALUES ('vps-p1', '1.1.1.1', 'prov-1', 'acc-1', 'active', 'OldName', ?)`,
)
.run(project.id)
const res = await app.inject({
method: 'PUT',
url: `/api/projects/${project.id}`,
payload: { name: 'NewName' },
})
expect(res.statusCode).toBe(200)
const vps = getSqlite().prepare(`SELECT project FROM vps WHERE id = 'vps-p1'`).get() as {
project: string
}
expect(vps.project).toBe('NewName')
})
it('returns 409 when deleting project with VPS', async () => {
const project = projectsRepository.create({ name: 'Bound' })
getSqlite()
.prepare(
`INSERT INTO vps (id, ip, providerId, providerAccountId, status, project, projectId)
VALUES ('vps-p2', '2.2.2.2', 'prov-1', 'acc-1', 'active', 'Bound', ?)`,
)
.run(project.id)
const res = await app.inject({
method: 'DELETE',
url: `/api/projects/${project.id}`,
})
expect(res.statusCode).toBe(409)
const body = res.json() as { error?: { code?: string; dependencies?: { vps?: number } } }
expect(body.error?.code).toBe('CONFLICT')
expect(body.error?.dependencies?.vps).toBe(1)
})
it('deletes project without dependencies', async () => {
const project = projectsRepository.create({ name: 'Free' })
const res = await app.inject({
method: 'DELETE',
url: `/api/projects/${project.id}`,
})
expect(res.statusCode).toBe(204)
expect(projectsRepository.get(project.id)).toBeUndefined()
})
})
+21 -4
View File
@@ -21,11 +21,17 @@ export const projectsRoutes: FastifyPluginAsync = async (app) => {
})
app.post('/api/projects', async (req, reply) => {
const name = normalizeProjectNameInput((req.body as { name?: unknown })?.name)
const body = req.body as { name?: unknown; color?: string | null; notes?: string | null }
const name = normalizeProjectNameInput(body.name)
if (!name) {
return reply.code(400).send({ error: { code: 'VALIDATION', message: 'name is required' } })
}
return reply.code(201).send(resolveOrCreateProject(name))
const created = projectsRepository.createOrResolve({
name,
color: body.color,
notes: body.notes,
})
return reply.code(201).send(created)
})
app.put<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
@@ -46,10 +52,21 @@ export const projectsRoutes: FastifyPluginAsync = async (app) => {
})
app.delete<{ Params: { id: string } }>('/api/projects/:id', async (req, reply) => {
const ok = projectsRepository.delete(req.params.id)
if (!ok) {
const existing = projectsRepository.get(req.params.id)
if (!existing) {
return reply.code(404).send({ error: { code: 'NOT_FOUND', message: 'Not found' } })
}
const dependencies = projectsRepository.getDependencyCounts(req.params.id)
if (dependencies.vps > 0) {
return reply.code(409).send({
error: {
code: 'CONFLICT',
message: `Нельзя удалить: к проекту привязано ${dependencies.vps} VPS`,
dependencies,
},
})
}
projectsRepository.delete(req.params.id)
return reply.code(204).send()
})
}