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

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

Co-authored-by: Cursor <[email protected]>
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
+54 -16
View File
@@ -70,6 +70,15 @@ export function getProjectNameById(id: string): string {
return row?.name ?? ''
}
function countVpsByProjectId(projectId: string): number {
const row = getDb()
.select({ count: sql<number>`count(*)` })
.from(schema.vps)
.where(eq(schema.vps.projectId, projectId))
.get()
return Number(row?.count ?? 0)
}
export const projectsRepository = {
list(): (typeof schema.serverProjects.$inferSelect)[] {
return getDb()
@@ -78,6 +87,16 @@ export const projectsRepository = {
.orderBy(asc(schema.serverProjects.name))
.all()
},
get(id: string): (typeof schema.serverProjects.$inferSelect) | undefined {
return getDb()
.select()
.from(schema.serverProjects)
.where(eq(schema.serverProjects.id, id))
.get()
},
getDependencyCounts(id: string): { vps: number } {
return { vps: countVpsByProjectId(id) }
},
create(input: { name: string; color?: string | null; notes?: string | null }) {
const id = `proj-${randomUUID()}`
const now = new Date().toISOString()
@@ -92,28 +111,47 @@ export const projectsRepository = {
createdAt: now,
})
.run()
return this.list().find((p) => p.id === id)!
return this.get(id)!
},
createOrResolve(input: { name: string; color?: string | null; notes?: string | null }) {
const existing = findProjectByNameCaseInsensitive(input.name)
if (existing) {
const hasMeta = input.color !== undefined || input.notes !== undefined
if (!hasMeta) return existing
return (
this.update(existing.id, {
...(input.color !== undefined ? { color: input.color } : {}),
...(input.notes !== undefined ? { notes: input.notes } : {}),
}) ?? existing
)
}
return this.create(input)
},
update(
id: string,
input: Partial<{ name: string; color: string | null; notes: string | null }>,
) {
const existing = getDb()
.select()
.from(schema.serverProjects)
.where(eq(schema.serverProjects.id, id))
.get()
const existing = this.get(id)
if (!existing) return undefined
getDb()
.update(schema.serverProjects)
.set({
name: input.name ?? existing.name,
color: input.color ?? existing.color,
notes: input.notes ?? existing.notes,
})
.where(eq(schema.serverProjects.id, id))
.run()
return getDb().select().from(schema.serverProjects).where(eq(schema.serverProjects.id, id)).get()
const nextName = input.name ?? existing.name
const db = getDb()
db.transaction(() => {
db.update(schema.serverProjects)
.set({
name: nextName,
color: input.color !== undefined ? input.color : existing.color,
notes: input.notes !== undefined ? input.notes : existing.notes,
})
.where(eq(schema.serverProjects.id, id))
.run()
if (nextName !== existing.name) {
db.update(schema.vps)
.set({ project: nextName })
.where(eq(schema.vps.projectId, id))
.run()
}
})
return this.get(id)
},
delete(id: string): boolean {
const r = getDb().delete(schema.serverProjects).where(eq(schema.serverProjects.id, id)).run()
+8
View File
@@ -44,6 +44,14 @@ const TABLE_MIGRATIONS: string[] = [
lastSentAt TEXT,
lastStatus TEXT
)`,
`CREATE TABLE IF NOT EXISTS server_projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT,
sortOrder INTEGER DEFAULT 0,
notes TEXT,
createdAt TEXT
)`,
]
let migrated = false
+16
View File
@@ -180,6 +180,15 @@ CREATE TABLE IF NOT EXISTS notification_state (
lastSentAt TEXT,
lastStatus TEXT
);
CREATE TABLE IF NOT EXISTS server_projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
color TEXT,
sortOrder INTEGER DEFAULT 0,
notes TEXT,
createdAt TEXT
);
`
export function resetTestDb(): void {
@@ -198,3 +207,10 @@ export function seedTestProvider(id = 'prov-1'): void {
)
.run(id)
}
export function seedTestProviderAccount(id = 'acc-1', providerId = 'prov-1'): void {
const sqlite = getSqlite()
sqlite
.prepare(`INSERT INTO provider_accounts (id, providerId, name) VALUES (?, ?, 'Test Account')`)
.run(id, providerId)
}