feat(projects): переработать проекты и отчётность по проектам
Docker / build (push) Failing after 20s
Docker / build (push) Failing after 20s
Добавлены карточка проекта, KPI и фильтры на /projects, отчёты с разрезом по проектам, cascade rename и защита удаления на API. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const projectSchema = z.object({
|
||||
name: z.string().min(1, 'Укажите название проекта').max(120),
|
||||
export const serverProjectSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
color: z.string().nullable().optional(),
|
||||
sortOrder: z.number().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ProjectFormValues = z.infer<typeof projectSchema>
|
||||
export type ServerProject = z.infer<typeof serverProjectSchema>
|
||||
|
||||
export const projectFormSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().min(1, 'Укажите название проекта').max(120),
|
||||
color: z.string().optional().default(''),
|
||||
notes: z.string().optional().default(''),
|
||||
})
|
||||
|
||||
export type ProjectFormValues = z.infer<typeof projectFormSchema>
|
||||
|
||||
/** @deprecated use projectFormSchema */
|
||||
export const projectSchema = projectFormSchema.pick({ name: true })
|
||||
|
||||
Reference in New Issue
Block a user