From d0e262c41d7197f49c92f3c4c1c121a79a2dd572 Mon Sep 17 00:00:00 2001 From: Denozordec Date: Fri, 6 Nov 2026 22:58:30 +0700 Subject: [PATCH] =?UTF-8?q?fix(projects):=20=D0=BD=D0=B0=D1=85=D0=BE=D0=B4?= =?UTF-8?q?=D0=B8=D1=82=D1=8C=20=D0=BF=D1=80=D0=BE=D0=B5=D0=BA=D1=82=D1=8B?= =?UTF-8?q?=20=D1=81=20=D0=BA=D0=B8=D1=80=D0=B8=D0=BB=D0=BB=D0=B8=D1=86?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=B1=D0=B5=D0=B7=20=D1=83=D1=87=D1=91=D1=82?= =?UTF-8?q?=D0=B0=20=D1=80=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SQLite LOWER() и LIKE понижают только ASCII, поэтому «Сайт и API» не находился и каждый новый VPS с таким проектом создавал дубль; подсказки по «сай» тоже были пустыми. Сравнение перенесено в JS. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/projects.test.ts | 12 ++++++++ packages/db/src/repositories/projects.ts | 38 ++++++++++-------------- 2 files changed, 27 insertions(+), 23 deletions(-) diff --git a/apps/api/src/routes/projects.test.ts b/apps/api/src/routes/projects.test.ts index cc7c7d7..2da3722 100644 --- a/apps/api/src/routes/projects.test.ts +++ b/apps/api/src/routes/projects.test.ts @@ -30,6 +30,18 @@ describe('projects routes', () => { expect(body[0]?.color).toBe('#ff0000') }) + it('reuses a Cyrillic project regardless of case and suggests it', async () => { + projectsRepository.create({ name: 'Сайт и API' }) + for (const project of ['Сайт и API', 'сайт и api']) { + const res = await app.inject({ method: 'POST', url: '/api/vps', payload: { ip: '10.0.0.1', project } }) + expect(res.statusCode).toBe(201) + } + const list = (await app.inject({ url: '/api/projects' })).json() as { name: string }[] + expect(list.map((p) => p.name)).toEqual(['Сайт и API']) + const sug = (await app.inject({ url: `/api/projects/suggest?q=${encodeURIComponent('сай')}` })).json() as { name: string }[] + expect(sug.map((p) => p.name)).toEqual(['Сайт и API']) + }) + it('creates project with color and notes', async () => { const res = await app.inject({ method: 'POST', diff --git a/packages/db/src/repositories/projects.ts b/packages/db/src/repositories/projects.ts index 7c3afa3..ec6778f 100644 --- a/packages/db/src/repositories/projects.ts +++ b/packages/db/src/repositories/projects.ts @@ -1,5 +1,5 @@ import { randomUUID } from 'node:crypto' -import { and, eq, like, asc, sql } from 'drizzle-orm' +import { and, eq, asc, sql } from 'drizzle-orm' import { getDb, schema } from '../index.js' import { getCurrentSpaceId } from '../space-context.js' @@ -13,17 +13,18 @@ export function findProjectByNameCaseInsensitive( ): (typeof schema.serverProjects.$inferSelect) | undefined { const n = normalizeProjectNameInput(name) if (!n) return undefined - const spaceId = getCurrentSpaceId() + // SQLite LOWER()/LIKE fold ASCII only — «Сайт» never matched «сайт», so each VPS created a duplicate project. + const key = n.toLowerCase() + return listSpaceProjects().find((p) => p.name.trim().toLowerCase() === key) +} + +function listSpaceProjects() { return getDb() .select() .from(schema.serverProjects) - .where( - and( - eq(schema.serverProjects.spaceId, spaceId), - eq(sql`LOWER(${schema.serverProjects.name})`, n.toLowerCase()), - ), - ) - .get() + .where(eq(schema.serverProjects.spaceId, getCurrentSpaceId())) + .orderBy(asc(schema.serverProjects.name)) + .all() } export function resolveOrCreateProject( @@ -67,20 +68,11 @@ export function projectSuggestions( .limit(lim) .all() } - const esc = term.replace(/\\/g, '\\\\').replace(/%/g, '\\%').replace(/_/g, '\\_') - const pattern = `%${esc.toLowerCase()}%` - return db - .select({ id: schema.serverProjects.id, name: schema.serverProjects.name }) - .from(schema.serverProjects) - .where( - and( - eq(schema.serverProjects.spaceId, spaceId), - like(sql`LOWER(${schema.serverProjects.name})`, pattern), - ), - ) - .orderBy(asc(schema.serverProjects.name)) - .limit(lim) - .all() + const needle = term.toLowerCase() + return listSpaceProjects() + .filter((p) => p.name.toLowerCase().includes(needle)) + .slice(0, lim) + .map(({ id, name }) => ({ id, name })) } export function getProjectNameById(id: string): string {