Электронные визитки: таблица + API + публичная страница /card/:slug + раздел в админке с загрузкой фото

This commit is contained in:
Nik
2026-06-08 00:05:26 +03:00
parent 3a2404295d
commit 80f0367e72
9 changed files with 594 additions and 1 deletions
+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(
+103
View File
@@ -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
@@ -51,3 +51,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' });
}
});