Files
iistwin/server/routes/organization-skins.routes.ts
Ильяс Султанов 95279b8fcc feat: skin engine + Twenty CRM theme for iistwin
- Add client/src/themes/ with tokens, built-in skins (iistwin, twenty, omegaprom)
- Add ThemeProvider with data-skin/data-theme and runtime CSS for custom skins
- Add npm run skins:generate script for build-time CSS generation
- Add organization_skins table, migration and CRUD API
- Add activeSkinId to organizations and expose it in /api/auth/me
- Add skin switcher in /profile and admin skin editor in /settings/appearance
- Adapt index.css hardcoded colors to skin tokens
- Update AGENTS.md and Obsidian notes
2026-07-09 20:25:49 +03:00

295 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Router } from 'express';
import { eq, and } from 'drizzle-orm';
import { db } from '../db';
import { organizationSkins, organizations } from '@shared/schema';
import { authenticateToken, requirePermission, type AuthenticatedRequest } from '../middleware/auth.middleware';
import { tenantIsolation } from '../middleware/tenant.middleware';
import { BUILT_IN_SKINS, getBuiltInSkinById } from '../../client/src/themes';
import type { Skin } from '../../client/src/themes/tokens';
const router = Router();
interface SkinListItem {
id: string;
name: string;
description?: string;
isEditable: boolean;
isBuiltIn: boolean;
dbId?: number;
}
function serializeSkinListItem(skin: Skin, isBuiltIn: boolean, dbId?: number): SkinListItem {
return {
id: skin.id,
name: skin.name,
description: skin.description,
isEditable: isBuiltIn ? false : true,
isBuiltIn,
dbId,
};
}
// GET /api/organization/skins — список скинов (встроенные + пользовательские)
router.get('/api/organization/skins', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const customRows = await db
.select()
.from(organizationSkins)
.where(eq(organizationSkins.organizationId, orgId));
const builtInList = BUILT_IN_SKINS.map((skin) => serializeSkinListItem(skin, true));
const customList = customRows.map((row) =>
serializeSkinListItem({
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
tokens: row.tokens as Skin['tokens'],
} as Skin, false, row.id)
);
return res.json({ success: true, skins: [...builtInList, ...customList] });
} catch (error) {
console.error('Get organization skins error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// GET /api/organization/skins/:id — детали скина
router.get('/api/organization/skins/:id', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const skinId = req.params.id;
const builtIn = getBuiltInSkinById(skinId);
if (builtIn) {
return res.json({ success: true, skin: builtIn });
}
const [row] = await db
.select()
.from(organizationSkins)
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)));
if (!row) {
return res.status(404).json({ success: false, error: 'Скин не найден' });
}
const skin: Skin = {
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
isEditable: row.isEditable ?? true,
tokens: row.tokens as Skin['tokens'],
};
return res.json({ success: true, skin });
} catch (error) {
console.error('Get organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// POST /api/organization/skins — создать пользовательский скин
router.post('/api/organization/skins', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const { skinId, name, description, tokens, baseSkinId } = req.body;
if (!skinId || typeof skinId !== 'string') {
return res.status(400).json({ success: false, error: 'skinId обязателен' });
}
if (!name || typeof name !== 'string') {
return res.status(400).json({ success: false, error: 'Название скина обязательно' });
}
if (getBuiltInSkinById(skinId)) {
return res.status(400).json({ success: false, error: 'Нельзя создать скин с id встроенного скина' });
}
let finalTokens = tokens;
if (!finalTokens && baseSkinId) {
const base = getBuiltInSkinById(baseSkinId) || await db.query.organizationSkins.findFirst({
where: and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, baseSkinId)),
});
if (base) {
finalTokens = (base as any).tokens ?? (base as Skin).tokens;
}
}
if (!finalTokens) {
return res.status(400).json({ success: false, error: 'Токены скина обязательны' });
}
const [row] = await db
.insert(organizationSkins)
.values({
organizationId: orgId,
skinId,
name: name.trim(),
description: description ?? null,
tokens: finalTokens,
isEditable: true,
isActive: false,
createdBy: req.user!.id,
})
.returning();
const skin: Skin = {
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
isEditable: true,
tokens: row.tokens as Skin['tokens'],
};
return res.status(201).json({ success: true, skin, dbId: row.id });
} catch (error: any) {
if (error?.code === '23505') {
return res.status(409).json({ success: false, error: 'Скин с таким id уже существует' });
}
console.error('Create organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// PATCH /api/organization/skins/:id — обновить пользовательский скин
router.patch('/api/organization/skins/:id', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const skinId = req.params.id;
const { name, description, tokens } = req.body;
if (getBuiltInSkinById(skinId)) {
return res.status(400).json({ success: false, error: 'Встроенные скины нельзя редактировать' });
}
const updates: Partial<{ name: string; description: string | null; tokens: object }> = {};
if (name !== undefined) updates.name = name.trim();
if (description !== undefined) updates.description = description ?? null;
if (tokens !== undefined) updates.tokens = tokens;
const [row] = await db
.update(organizationSkins)
.set({ ...updates, updatedAt: new Date() })
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)))
.returning();
if (!row) {
return res.status(404).json({ success: false, error: 'Скин не найден' });
}
const skin: Skin = {
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
isEditable: row.isEditable ?? true,
tokens: row.tokens as Skin['tokens'],
};
return res.json({ success: true, skin });
} catch (error) {
console.error('Update organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// DELETE /api/organization/skins/:id — удалить пользовательский скин
router.delete('/api/organization/skins/:id', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const skinId = req.params.id;
if (getBuiltInSkinById(skinId)) {
return res.status(400).json({ success: false, error: 'Встроенные скины нельзя удалять' });
}
const [row] = await db
.delete(organizationSkins)
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)))
.returning();
if (!row) {
return res.status(404).json({ success: false, error: 'Скин не найден' });
}
// Если удалён активный скин — сбрасываем на iistwin
await db
.update(organizations)
.set({ activeSkinId: 'iistwin', updatedAt: new Date() })
.where(and(eq(organizations.id, orgId), eq(organizations.activeSkinId, skinId)));
return res.json({ success: true });
} catch (error) {
console.error('Delete organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// GET /api/organization/current-skin — текущий активный скин организации
router.get('/api/organization/current-skin', authenticateToken, tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const [org] = await db
.select({ activeSkinId: organizations.activeSkinId })
.from(organizations)
.where(eq(organizations.id, orgId));
const activeSkinId = org?.activeSkinId ?? 'iistwin';
const builtIn = getBuiltInSkinById(activeSkinId);
if (builtIn) {
return res.json({ success: true, skinId: activeSkinId, skin: builtIn });
}
const [row] = await db
.select()
.from(organizationSkins)
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, activeSkinId)));
if (!row) {
return res.json({ success: true, skinId: 'iistwin', skin: getBuiltInSkinById('iistwin') });
}
const skin: Skin = {
id: row.skinId,
name: row.name,
description: row.description ?? undefined,
isEditable: row.isEditable ?? true,
tokens: row.tokens as Skin['tokens'],
};
return res.json({ success: true, skinId: activeSkinId, skin });
} catch (error) {
console.error('Get current organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
// PUT /api/organization/current-skin — установить активный скин
router.put('/api/organization/current-skin', authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => {
try {
const orgId = req.organizationId!;
const { skinId } = req.body;
if (!skinId || typeof skinId !== 'string') {
return res.status(400).json({ success: false, error: 'skinId обязателен' });
}
const isBuiltIn = !!getBuiltInSkinById(skinId);
if (!isBuiltIn) {
const [custom] = await db
.select({ id: organizationSkins.id })
.from(organizationSkins)
.where(and(eq(organizationSkins.organizationId, orgId), eq(organizationSkins.skinId, skinId)));
if (!custom) {
return res.status(404).json({ success: false, error: 'Скин не найден' });
}
}
await db
.update(organizations)
.set({ activeSkinId: skinId, updatedAt: new Date() })
.where(eq(organizations.id, orgId));
return res.json({ success: true, skinId });
} catch (error) {
console.error('Set current organization skin error:', error);
return res.status(500).json({ success: false, error: 'Внутренняя ошибка сервера' });
}
});
export default router;