merge: свести ветку визиток из Gitea с локальной работой по лендингу

Две линии разработки разъехались и месяц жили порознь: в Gitea уехали
электронные визитки с QR-кодом, а в рабочей копии на fortress остались шесть
коммитов, которые до репозитория не дошли и, значит, не попали на сайт —
английская версия с переключателем языков, карточка Бонитировки, логотипы
продуктов, реквизиты компании в подвале, убранная кнопка Telegram, имя
директора латиницей. Плюс незакоммиченное бургер-меню.

Что разрешено вручную:

1. Landing.tsx, блок контактов. Столкнулись наша расстановка (почта главной
   кнопкой с подписью из cta_primary, телефон второй) и правка из Gitea,
   переводящая оба контакта с href на onClick, чтобы адрес и номер не лежали
   в разметке и их не собирали боты. Взято и то, и другое: защита от ботов
   сохранена, порядок и оформление наши, адрес почты в подписи не светится.

2. Коллизия номеров миграций: обе стороны завели номер 003. На живой базе
   применены 001, 002 и 003_business_cards, а 003_product_url_and_requisites
   не выполнялась никогда — она и переименована в 004, чтобы раннер (он
   отслеживает миграции по имени файла) отработал её штатно.

3. package-lock пересобран под слитый манифест, а не выбран со стороны.

Проверено: tsc чист, vite build проходит, 118 модулей, 241 КБ.
This commit is contained in:
zeroday
2026-08-25 22:27:25 +03:00
15 changed files with 1209 additions and 204 deletions
+22
View File
@@ -16,18 +16,40 @@ export async function runMigrations(): Promise<void> {
return;
}
// Создаём таблицу учёта миграций если её нет
await pool.query(`
CREATE TABLE IF NOT EXISTS _migrations (
filename TEXT PRIMARY KEY,
applied_at TIMESTAMPTZ DEFAULT now()
)
`);
const files = fs
.readdirSync(MIGRATIONS_DIR)
.filter((f) => f.endsWith('.sql'))
.sort();
for (const file of files) {
// Проверяем — уже применялась?
const { rowCount } = await pool.query(
'SELECT 1 FROM _migrations WHERE filename = $1',
[file]
);
if (rowCount && rowCount > 0) {
console.log(`[migrate] ✓ ${file} (уже применена)`);
continue;
}
const fullPath = path.join(MIGRATIONS_DIR, file);
const sql = fs.readFileSync(fullPath, 'utf-8');
console.log(`[migrate] → ${file}`);
try {
await pool.query('BEGIN');
await pool.query(sql);
await pool.query('INSERT INTO _migrations (filename) VALUES ($1)', [file]);
await pool.query('COMMIT');
} catch (err) {
await pool.query('ROLLBACK');
console.error(`[migrate] Ошибка в ${file}:`, err);
throw err;
}
+1 -1
View File
@@ -12,7 +12,7 @@ dotenv.config();
const app = express();
const PORT = parseInt(process.env.PORT || '3000', 10);
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '6mb' }));
app.use(cookieParser());
app.use(
+106 -3
View File
@@ -111,7 +111,7 @@ adminRouter.post('/products', requireAdmin, async (req, res) => {
});
adminRouter.put('/products/:id', requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10);
const id = parseInt(String(req.params.id), 10);
if (Number.isNaN(id)) {
res.status(400).json({ error: 'invalid_id' });
return;
@@ -152,7 +152,7 @@ adminRouter.put('/products/:id', requireAdmin, async (req, res) => {
});
adminRouter.delete('/products/:id', requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10);
const id = parseInt(String(req.params.id), 10);
await query(`DELETE FROM products WHERE id = $1`, [id]);
res.json({ ok: true });
});
@@ -192,7 +192,7 @@ adminRouter.get('/approach', requireAdmin, async (_req, res) => {
});
adminRouter.put('/approach/:id', requireAdmin, async (req, res) => {
const id = parseInt(req.params.id, 10);
const id = parseInt(String(req.params.id), 10);
const { title, description, icon_key, sort_order, is_active } = req.body;
const updated = await queryOne(
`UPDATE approach_items SET
@@ -231,3 +231,106 @@ adminRouter.put('/settings/:key', requireAdmin, async (req, res) => {
);
res.json({ ok: true });
});
// ============ Электронные визитки ============
const cardSchema = z.object({
slug: z.string().min(1).max(64).regex(/^[a-z0-9-]+$/, 'slug_format'),
name: z.string().min(1).max(128),
role: z.string().max(128).nullable().optional(),
company: z.string().max(128).nullable().optional(),
phone: z.string().max(64).nullable().optional(),
email: z.string().max(128).nullable().optional(),
telegram: z.string().max(128).nullable().optional(),
photo: z.string().max(5_000_000).nullable().optional(),
product_title: z.string().max(128).nullable().optional(),
product_subtitle: z.string().max(256).nullable().optional(),
product_url: z.string().max(256).nullable().optional(),
site_url: z.string().max(256).nullable().optional(),
is_published: z.boolean().optional(),
});
adminRouter.get('/cards', requireAdmin, async (_req, res) => {
const cards = await query(`SELECT * FROM business_cards ORDER BY created_at ASC`);
res.json({ cards });
});
adminRouter.post('/cards', requireAdmin, async (req, res) => {
const parsed = cardSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'invalid_payload' });
return;
}
const c = parsed.data;
try {
const created = await queryOne(
`INSERT INTO business_cards
(slug, name, role, company, phone, email, telegram, photo,
product_title, product_subtitle, product_url, site_url, is_published)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13)
RETURNING *`,
[
c.slug, c.name, c.role ?? null, c.company ?? null, c.phone ?? null,
c.email ?? null, c.telegram ?? null, c.photo ?? null,
c.product_title ?? null, c.product_subtitle ?? null,
c.product_url ?? null, c.site_url ?? null, c.is_published ?? true,
]
);
res.status(201).json({ card: created });
} catch (err: any) {
if (err?.code === '23505') {
res.status(409).json({ error: 'slug_exists' });
return;
}
console.error('[POST /cards]', err);
res.status(500).json({ error: 'internal_error' });
}
});
adminRouter.put('/cards/:id', requireAdmin, async (req, res) => {
const id = parseInt(String(req.params.id), 10);
if (Number.isNaN(id)) {
res.status(400).json({ error: 'invalid_id' });
return;
}
const parsed = cardSchema.partial().safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: 'invalid_payload' });
return;
}
const c = parsed.data;
const updated = await queryOne(
`UPDATE business_cards SET
slug = COALESCE($2, slug),
name = COALESCE($3, name),
role = COALESCE($4, role),
company = COALESCE($5, company),
phone = COALESCE($6, phone),
email = COALESCE($7, email),
telegram = COALESCE($8, telegram),
photo = COALESCE($9, photo),
product_title = COALESCE($10, product_title),
product_subtitle = COALESCE($11, product_subtitle),
product_url = COALESCE($12, product_url),
site_url = COALESCE($13, site_url),
is_published = COALESCE($14, is_published),
updated_at = now()
WHERE id = $1
RETURNING *`,
[
id, c.slug, c.name, c.role, c.company, c.phone, c.email, c.telegram,
c.photo, c.product_title, c.product_subtitle, c.product_url,
c.site_url, c.is_published,
]
);
if (!updated) {
res.status(404).json({ error: 'not_found' });
return;
}
res.json({ card: updated });
});
adminRouter.delete('/cards/:id', requireAdmin, async (req, res) => {
const id = parseInt(String(req.params.id), 10);
await query(`DELETE FROM business_cards WHERE id = $1`, [id]);
res.json({ ok: true });
});
+20
View File
@@ -58,3 +58,23 @@ publicRouter.get('/content', async (_req, res) => {
res.status(500).json({ error: 'internal_error' });
}
});
publicRouter.get('/cards/:slug', async (req, res) => {
try {
const card = await query(
`SELECT slug, name, role, company, phone, email, telegram, photo,
product_title, product_subtitle, product_url, site_url
FROM business_cards
WHERE slug = $1 AND is_published = true`,
[req.params.slug]
);
if (!card[0]) {
res.status(404).json({ error: 'not_found' });
return;
}
res.json({ card: card[0] });
} catch (err) {
console.error('[GET /cards/:slug]', err);
res.status(500).json({ error: 'internal_error' });
}
});