diff --git a/apps/api/src/custom-theme/1710000000000-CreateThemesTable.ts b/apps/api/src/custom-theme/1710000000000-CreateThemesTable.ts new file mode 100644 index 0000000..f287c4f --- /dev/null +++ b/apps/api/src/custom-theme/1710000000000-CreateThemesTable.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class CreateThemesTable1710000000000 implements MigrationInterface { + name = 'CreateThemesTable1710000000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "theme_scope_enum" AS ENUM ('global', 'organization', 'user')`); + + await queryRunner.createTable( + new Table({ + name: 'themes', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'name', type: 'varchar', length: '100', isNullable: false }, + { name: 'description', type: 'varchar', length: '500', isNullable: true }, + { + name: 'scope', + type: 'enum', + enumName: 'theme_scope_enum', + default: "'global'", + }, + { name: 'scope_owner_id', type: 'uuid', isNullable: true }, + { name: 'config', type: 'jsonb', isNullable: false }, + { name: 'is_default', type: 'boolean', default: false }, + { name: 'is_active', type: 'boolean', default: true }, + { name: 'is_read_only', type: 'boolean', default: false }, + { name: 'parent_theme_id', type: 'uuid', isNullable: true }, + { name: 'created_by', type: 'uuid', isNullable: true }, + { name: 'updated_by', type: 'uuid', isNullable: true }, + { + name: 'created_at', + type: 'timestamp with time zone', + default: 'CURRENT_TIMESTAMP', + }, + { + name: 'updated_at', + type: 'timestamp with time zone', + default: 'CURRENT_TIMESTAMP', + }, + { name: 'deleted_at', type: 'timestamp with time zone', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'themes', + new TableIndex({ + name: 'IDX_themes_scope_is_default', + columnNames: ['scope', 'is_default'], + }), + ); + + await queryRunner.createIndex( + 'themes', + new TableIndex({ + name: 'UQ_themes_name_scope', + columnNames: ['name', 'scope'], + isUnique: true, + where: 'deleted_at IS NULL', + }), + ); + + await queryRunner.createIndex( + 'themes', + new TableIndex({ + name: 'IDX_themes_is_active_deleted', + columnNames: ['is_active', 'deleted_at'], + }), + ); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('themes', true, true, true); + await queryRunner.query(`DROP TYPE IF EXISTS "theme_scope_enum"`); + } +} diff --git a/apps/api/src/custom-theme/css-generator.util.spec.ts b/apps/api/src/custom-theme/css-generator.util.spec.ts new file mode 100644 index 0000000..d140444 --- /dev/null +++ b/apps/api/src/custom-theme/css-generator.util.spec.ts @@ -0,0 +1,176 @@ +import { + generateCssVariables, + generateDarkModeVariables, + generateFullCssBundle, + variablesToCssString, +} from '../utils/css-generator.util'; +import { DEFAULT_THEME_CONFIG, ThemeConfig } from '../types/theme-config.types'; + +describe('CssGeneratorUtil', () => { + describe('generateCssVariables', () => { + it('should generate CSS variables from the default config', () => { + const vars = generateCssVariables(DEFAULT_THEME_CONFIG); + + expect(vars).toBeDefined(); + expect(typeof vars).toBe('object'); + }); + + it('should map primary color to --color-primary', () => { + const vars = generateCssVariables(DEFAULT_THEME_CONFIG); + expect(vars['--color-primary']).toBe('#6366F1'); + }); + + it('should map font family to --font-font-family-heading', () => { + const vars = generateCssVariables(DEFAULT_THEME_CONFIG); + expect(vars['--font-font-family-heading']).toContain('Inter'); + }); + + it('should map spacing unit to --spacing-unit', () => { + const vars = generateCssVariables(DEFAULT_THEME_CONFIG); + expect(vars['--spacing-unit']).toBe('4'); + }); + + it('should map border radius to --radius-base', () => { + const vars = generateCssVariables(DEFAULT_THEME_CONFIG); + expect(vars['--radius-base']).toBe('4px'); + }); + + it('should map shadow to --shadow-base', () => { + const vars = generateCssVariables(DEFAULT_THEME_CONFIG); + expect(vars['--shadow-base']).toBeDefined(); + }); + + it('should prefix custom CSS variables with --custom-', () => { + const config: ThemeConfig = { + ...DEFAULT_THEME_CONFIG, + customCssVariables: { ribbonColor: '#FF0000' }, + }; + const vars = generateCssVariables(config); + expect(vars['--custom-ribbon-color']).toBe('#FF0000'); + }); + + it('should preserve custom variables that already start with --', () => { + const config: ThemeConfig = { + ...DEFAULT_THEME_CONFIG, + customCssVariables: { '--my-var': 'blue' }, + }; + const vars = generateCssVariables(config); + expect(vars['--my-var']).toBe('blue'); + }); + + it('should produce all keys as strings starting with --', () => { + const vars = generateCssVariables(DEFAULT_THEME_CONFIG); + for (const key of Object.keys(vars)) { + expect(key.startsWith('--')).toBe(true); + } + }); + + it('should not include null/undefined values', () => { + const vars = generateCssVariables(DEFAULT_THEME_CONFIG); + for (const val of Object.values(vars)) { + expect(val).not.toBeNull(); + expect(val).not.toBeUndefined(); + expect(val).not.toBe('null'); + expect(val).not.toBe('undefined'); + } + }); + }); + + describe('generateDarkModeVariables', () => { + it('should return undefined when dark mode is disabled', () => { + const result = generateDarkModeVariables({ + ...DEFAULT_THEME_CONFIG, + darkModeEnabled: false, + }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when no darkModeColors provided', () => { + const result = generateDarkModeVariables({ + ...DEFAULT_THEME_CONFIG, + darkModeEnabled: true, + darkModeColors: undefined, + }); + expect(result).toBeUndefined(); + }); + + it('should generate dark mode variables when enabled and colors provided', () => { + const config: ThemeConfig = { + ...DEFAULT_THEME_CONFIG, + darkModeEnabled: true, + darkModeColors: { + background: '#1A1A2E', + surface: '#16213E', + textPrimary: '#E0E0E0', + }, + }; + const vars = generateDarkModeVariables(config); + + expect(vars).toBeDefined(); + expect(vars!['--color-background']).toBe('#1A1A2E'); + expect(vars!['--color-surface']).toBe('#16213E'); + expect(vars!['--color-text-primary']).toBe('#E0E0E0'); + }); + + it('should skip undefined values in darkModeColors', () => { + const config: ThemeConfig = { + ...DEFAULT_THEME_CONFIG, + darkModeEnabled: true, + darkModeColors: { background: '#000', primary: undefined as any }, + }; + const vars = generateDarkModeVariables(config); + expect(vars!['--color-background']).toBe('#000'); + expect(vars!['--color-primary']).toBeUndefined(); + }); + }); + + describe('variablesToCssString', () => { + it('should wrap variables in :root by default', () => { + const css = variablesToCssString({ '--color-primary': '#6366F1' }); + expect(css).toContain(':root {'); + expect(css).toContain('--color-primary: #6366F1;'); + expect(css).toContain('}'); + }); + + it('should use custom selector when provided', () => { + const css = variablesToCssString({ '--x': 'y' }, '.my-scope'); + expect(css).toContain('.my-scope {'); + }); + + it('should produce one declaration per variable', () => { + const vars = { '--a': '1', '--b': '2', '--c': '3' }; + const css = variablesToCssString(vars); + expect((css.match(/;/g) ?? []).length).toBe(3); + }); + }); + + describe('generateFullCssBundle', () => { + it('should return a non-empty string', () => { + const css = generateFullCssBundle(DEFAULT_THEME_CONFIG); + expect(typeof css).toBe('string'); + expect(css.length).toBeGreaterThan(0); + }); + + it('should contain :root block', () => { + const css = generateFullCssBundle(DEFAULT_THEME_CONFIG); + expect(css).toContain(':root {'); + }); + + it('should not include dark mode block when disabled', () => { + const css = generateFullCssBundle({ + ...DEFAULT_THEME_CONFIG, + darkModeEnabled: false, + }); + expect(css).not.toContain('@media (prefers-color-scheme: dark)'); + }); + + it('should include dark mode media query when enabled', () => { + const css = generateFullCssBundle({ + ...DEFAULT_THEME_CONFIG, + darkModeEnabled: true, + darkModeColors: { background: '#000' }, + }); + expect(css).toContain('@media (prefers-color-scheme: dark)'); + }); + }); +}); diff --git a/apps/api/src/custom-theme/css-generator.util.ts b/apps/api/src/custom-theme/css-generator.util.ts new file mode 100644 index 0000000..481a62b --- /dev/null +++ b/apps/api/src/custom-theme/css-generator.util.ts @@ -0,0 +1,98 @@ +import { ThemeConfig } from '../types/theme-config.types'; + +/** + * Converts a camelCase key to a CSS custom property name. + * e.g. "primaryHover" → "--color-primary-hover" + */ +function camelToKebab(str: string): string { + return str.replace(/([A-Z])/g, (m) => `-${m.toLowerCase()}`); +} + +function sectionPrefix(section: string): string { + const map: Record = { + colors: '--color', + typography: '--font', + spacing: '--spacing', + borderRadius: '--radius', + shadows: '--shadow', + transitions: '--transition', + breakpoints: '--bp', + zIndex: '--z', + }; + return map[section] ?? `--${camelToKebab(section)}`; +} + +export function generateCssVariables( + config: ThemeConfig, +): Record { + const vars: Record = {}; + + const sections: Array = [ + 'colors', + 'typography', + 'spacing', + 'borderRadius', + 'shadows', + 'transitions', + 'breakpoints', + 'zIndex', + ]; + + for (const section of sections) { + const sectionData = config[section] as Record; + if (!sectionData || typeof sectionData !== 'object') continue; + + const prefix = sectionPrefix(section); + + for (const [key, value] of Object.entries(sectionData)) { + if (value === undefined || value === null) continue; + const varName = `${prefix}-${camelToKebab(key)}`; + vars[varName] = String(value); + } + } + + // Custom CSS variables pass-through + if (config.customCssVariables) { + for (const [key, value] of Object.entries(config.customCssVariables)) { + const cssKey = key.startsWith('--') ? key : `--custom-${camelToKebab(key)}`; + vars[cssKey] = value; + } + } + + return vars; +} + +export function generateDarkModeVariables( + config: ThemeConfig, +): Record | undefined { + if (!config.darkModeEnabled || !config.darkModeColors) return undefined; + + const vars: Record = {}; + for (const [key, value] of Object.entries(config.darkModeColors)) { + if (value === undefined || value === null) continue; + vars[`--color-${camelToKebab(key)}`] = String(value); + } + return vars; +} + +export function variablesToCssString( + vars: Record, + selector = ':root', +): string { + const declarations = Object.entries(vars) + .map(([k, v]) => ` ${k}: ${v};`) + .join('\n'); + return `${selector} {\n${declarations}\n}`; +} + +export function generateFullCssBundle(config: ThemeConfig): string { + const rootVars = generateCssVariables(config); + const rootCss = variablesToCssString(rootVars, ':root'); + + const darkVars = generateDarkModeVariables(config); + const darkCss = darkVars + ? `\n\n@media (prefers-color-scheme: dark) {\n${variablesToCssString(darkVars, ' :root')}\n}` + : ''; + + return rootCss + darkCss; +} diff --git a/apps/api/src/custom-theme/theme-config.types.ts b/apps/api/src/custom-theme/theme-config.types.ts new file mode 100644 index 0000000..e8f1be0 --- /dev/null +++ b/apps/api/src/custom-theme/theme-config.types.ts @@ -0,0 +1,240 @@ +export interface ThemeColors { + primary: string; + primaryHover: string; + primaryActive: string; + secondary: string; + secondaryHover: string; + secondaryActive: string; + accent: string; + background: string; + surface: string; + surfaceRaised: string; + onPrimary: string; + onSecondary: string; + error: string; + errorLight: string; + warning: string; + warningLight: string; + success: string; + successLight: string; + info: string; + infoLight: string; + textPrimary: string; + textSecondary: string; + textDisabled: string; + textInverse: string; + border: string; + borderFocus: string; + divider: string; + overlay: string; +} + +export interface ThemeTypography { + fontFamilyHeading: string; + fontFamilyBody: string; + fontFamilyMono: string; + fontSizeXs: string; + fontSizeSm: string; + fontSizeBase: string; + fontSizeLg: string; + fontSizeXl: string; + fontSize2xl: string; + fontSize3xl: string; + fontSize4xl: string; + fontWeightLight: number; + fontWeightRegular: number; + fontWeightMedium: number; + fontWeightSemibold: number; + fontWeightBold: number; + lineHeightTight: number; + lineHeightNormal: number; + lineHeightRelaxed: number; + letterSpacingTight: string; + letterSpacingNormal: string; + letterSpacingWide: string; +} + +export interface ThemeSpacing { + unit: number; + xs: number; + sm: number; + md: number; + lg: number; + xl: number; + xxl: number; +} + +export interface ThemeBorderRadius { + none: string; + sm: string; + base: string; + lg: string; + xl: string; + xxl: string; + full: string; +} + +export interface ThemeShadows { + none: string; + sm: string; + base: string; + lg: string; + xl: string; + inner: string; +} + +export interface ThemeTransitions { + durationFast: string; + durationBase: string; + durationSlow: string; + easingDefault: string; + easingIn: string; + easingOut: string; + easingBounce: string; +} + +export interface ThemeBreakpoints { + xs: number; + sm: number; + md: number; + lg: number; + xl: number; + xxl: number; +} + +export interface ThemeZIndex { + base: number; + dropdown: number; + sticky: number; + fixed: number; + modal: number; + popover: number; + tooltip: number; + toast: number; +} + +export interface ThemeConfig { + colors: ThemeColors; + darkModeColors?: Partial; + typography: ThemeTypography; + spacing: ThemeSpacing; + borderRadius: ThemeBorderRadius; + shadows: ThemeShadows; + transitions: ThemeTransitions; + breakpoints: ThemeBreakpoints; + zIndex: ThemeZIndex; + darkModeEnabled: boolean; + customCssVariables?: Record; +} + +export const DEFAULT_THEME_CONFIG: ThemeConfig = { + colors: { + primary: '#6366F1', + primaryHover: '#4F46E5', + primaryActive: '#4338CA', + secondary: '#8B5CF6', + secondaryHover: '#7C3AED', + secondaryActive: '#6D28D9', + accent: '#F59E0B', + background: '#F9FAFB', + surface: '#FFFFFF', + surfaceRaised: '#F3F4F6', + onPrimary: '#FFFFFF', + onSecondary: '#FFFFFF', + error: '#EF4444', + errorLight: '#FEE2E2', + warning: '#F59E0B', + warningLight: '#FEF3C7', + success: '#10B981', + successLight: '#D1FAE5', + info: '#3B82F6', + infoLight: '#DBEAFE', + textPrimary: '#111827', + textSecondary: '#6B7280', + textDisabled: '#9CA3AF', + textInverse: '#FFFFFF', + border: '#E5E7EB', + borderFocus: '#6366F1', + divider: '#F3F4F6', + overlay: 'rgba(0,0,0,0.5)', + }, + typography: { + fontFamilyHeading: "'Inter', 'Segoe UI', sans-serif", + fontFamilyBody: "'Inter', 'Segoe UI', sans-serif", + fontFamilyMono: "'JetBrains Mono', 'Fira Code', monospace", + fontSizeXs: '0.75rem', + fontSizeSm: '0.875rem', + fontSizeBase: '1rem', + fontSizeLg: '1.125rem', + fontSizeXl: '1.25rem', + fontSize2xl: '1.5rem', + fontSize3xl: '1.875rem', + fontSize4xl: '2.25rem', + fontWeightLight: 300, + fontWeightRegular: 400, + fontWeightMedium: 500, + fontWeightSemibold: 600, + fontWeightBold: 700, + lineHeightTight: 1.25, + lineHeightNormal: 1.5, + lineHeightRelaxed: 1.75, + letterSpacingTight: '-0.025em', + letterSpacingNormal: '0em', + letterSpacingWide: '0.025em', + }, + spacing: { + unit: 4, + xs: 4, + sm: 8, + md: 16, + lg: 24, + xl: 32, + xxl: 48, + }, + borderRadius: { + none: '0px', + sm: '2px', + base: '4px', + lg: '8px', + xl: '12px', + xxl: '16px', + full: '9999px', + }, + shadows: { + none: 'none', + sm: '0 1px 2px 0 rgba(0,0,0,0.05)', + base: '0 1px 3px 0 rgba(0,0,0,0.1), 0 1px 2px -1px rgba(0,0,0,0.1)', + lg: '0 4px 6px -1px rgba(0,0,0,0.1), 0 2px 4px -2px rgba(0,0,0,0.1)', + xl: '0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -4px rgba(0,0,0,0.1)', + inner: 'inset 0 2px 4px 0 rgba(0,0,0,0.05)', + }, + transitions: { + durationFast: '100ms', + durationBase: '200ms', + durationSlow: '300ms', + easingDefault: 'cubic-bezier(0.4, 0, 0.2, 1)', + easingIn: 'cubic-bezier(0.4, 0, 1, 1)', + easingOut: 'cubic-bezier(0, 0, 0.2, 1)', + easingBounce: 'cubic-bezier(0.34, 1.56, 0.64, 1)', + }, + breakpoints: { + xs: 320, + sm: 640, + md: 768, + lg: 1024, + xl: 1280, + xxl: 1536, + }, + zIndex: { + base: 0, + dropdown: 100, + sticky: 200, + fixed: 300, + modal: 400, + popover: 500, + tooltip: 600, + toast: 700, + }, + darkModeEnabled: false, + customCssVariables: {}, +}; diff --git a/apps/api/src/custom-theme/theme.controller.spec.ts b/apps/api/src/custom-theme/theme.controller.spec.ts new file mode 100644 index 0000000..2779145 --- /dev/null +++ b/apps/api/src/custom-theme/theme.controller.spec.ts @@ -0,0 +1,238 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ThemeController } from '../theme.controller'; +import { ThemeService } from '../theme.service'; +import { ThemeScope } from '../entities/theme.entity'; +import { DEFAULT_THEME_CONFIG } from '../types/theme-config.types'; +import { CreateThemeDto, ThemeOverrideDto, UpdateThemeDto } from '../dto/theme.dto'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const makeResponse = (overrides: Partial = {}) => ({ + id: 'theme-uuid-1', + name: 'Test Theme', + description: null, + scope: ThemeScope.GLOBAL, + scopeOwnerId: null, + parentThemeId: null, + config: DEFAULT_THEME_CONFIG, + isDefault: false, + isActive: true, + isReadOnly: false, + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, +}); + +const mockRequest = (userId = 'user-123') => ({ user: { id: userId } } as any); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('ThemeController', () => { + let controller: ThemeController; + let service: jest.Mocked; + + beforeEach(async () => { + const mockService: Partial> = { + create: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + applyOverride: jest.fn(), + resetToDefault: jest.fn(), + clone: jest.fn(), + getCssVariables: jest.fn(), + getPreview: jest.fn(), + getDefaultTheme: jest.fn(), + }; + + const module: TestingModule = await Test.createTestingModule({ + controllers: [ThemeController], + providers: [{ provide: ThemeService, useValue: mockService }], + }).compile(); + + controller = module.get(ThemeController); + service = module.get(ThemeService); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── POST /themes ───────────────────────────────────────────────────────── + + describe('create()', () => { + it('should call service.create with dto and actorId', async () => { + const dto: CreateThemeDto = { name: 'New Theme' }; + const expected = makeResponse({ name: 'New Theme' }); + service.create.mockResolvedValue(expected); + + const result = await controller.create(dto, mockRequest()); + + expect(service.create).toHaveBeenCalledWith(dto, 'user-123'); + expect(result).toEqual(expected); + }); + + it('should handle request without user gracefully', async () => { + const dto: CreateThemeDto = { name: 'Anon Theme' }; + service.create.mockResolvedValue(makeResponse()); + + await controller.create(dto, {} as any); + expect(service.create).toHaveBeenCalledWith(dto, undefined); + }); + }); + + // ─── GET /themes ────────────────────────────────────────────────────────── + + describe('findAll()', () => { + it('should return array of themes', async () => { + const themes = [makeResponse(), makeResponse({ id: 'theme-2' })]; + service.findAll.mockResolvedValue(themes); + + const result = await controller.findAll({ scope: ThemeScope.GLOBAL }); + expect(result).toHaveLength(2); + expect(service.findAll).toHaveBeenCalledWith({ scope: ThemeScope.GLOBAL }); + }); + + it('should return empty array when no themes exist', async () => { + service.findAll.mockResolvedValue([]); + const result = await controller.findAll({}); + expect(result).toEqual([]); + }); + }); + + // ─── GET /themes/default ────────────────────────────────────────────────── + + describe('getDefault()', () => { + it('should call getDefaultTheme with global scope by default', async () => { + const theme = makeResponse({ isDefault: true }); + service.getDefaultTheme.mockResolvedValue(theme); + + const result = await controller.getDefault(); + expect(service.getDefaultTheme).toHaveBeenCalledWith(ThemeScope.GLOBAL, undefined); + expect(result.isDefault).toBe(true); + }); + + it('should pass scope and scopeOwnerId to service', async () => { + service.getDefaultTheme.mockResolvedValue(makeResponse()); + + await controller.getDefault(ThemeScope.ORGANIZATION, 'org-id'); + expect(service.getDefaultTheme).toHaveBeenCalledWith(ThemeScope.ORGANIZATION, 'org-id'); + }); + }); + + // ─── GET /themes/:id ────────────────────────────────────────────────────── + + describe('findOne()', () => { + it('should return a single theme', async () => { + const theme = makeResponse(); + service.findOne.mockResolvedValue(theme); + + const result = await controller.findOne('theme-uuid-1'); + expect(result).toEqual(theme); + expect(service.findOne).toHaveBeenCalledWith('theme-uuid-1'); + }); + }); + + // ─── PATCH /themes/:id ──────────────────────────────────────────────────── + + describe('update()', () => { + it('should call service.update with correct params', async () => { + const dto: UpdateThemeDto = { name: 'Updated' }; + const updated = makeResponse({ name: 'Updated' }); + service.update.mockResolvedValue(updated); + + const result = await controller.update('theme-uuid-1', dto, mockRequest()); + + expect(service.update).toHaveBeenCalledWith('theme-uuid-1', dto, 'user-123'); + expect(result.name).toBe('Updated'); + }); + }); + + // ─── DELETE /themes/:id ─────────────────────────────────────────────────── + + describe('remove()', () => { + it('should call service.remove and return void', async () => { + service.remove.mockResolvedValue(undefined); + + const result = await controller.remove('theme-uuid-1', mockRequest()); + + expect(service.remove).toHaveBeenCalledWith('theme-uuid-1', 'user-123'); + expect(result).toBeUndefined(); + }); + }); + + // ─── POST /themes/:id/override ──────────────────────────────────────────── + + describe('applyOverride()', () => { + it('should call service.applyOverride with overrides dto', async () => { + const dto: ThemeOverrideDto = { + overrides: { colors: { primary: '#FF0000' } } as any, + }; + const result = makeResponse(); + service.applyOverride.mockResolvedValue(result); + + await controller.applyOverride('theme-uuid-1', dto, mockRequest()); + expect(service.applyOverride).toHaveBeenCalledWith('theme-uuid-1', dto, 'user-123'); + }); + }); + + // ─── POST /themes/:id/reset ─────────────────────────────────────────────── + + describe('resetToDefault()', () => { + it('should call service.resetToDefault', async () => { + service.resetToDefault.mockResolvedValue(makeResponse()); + + await controller.resetToDefault('theme-uuid-1', mockRequest()); + expect(service.resetToDefault).toHaveBeenCalledWith('theme-uuid-1', 'user-123'); + }); + }); + + // ─── POST /themes/:id/clone ─────────────────────────────────────────────── + + describe('clone()', () => { + it('should call service.clone with name and actorId', async () => { + service.clone.mockResolvedValue(makeResponse({ name: 'Cloned' })); + + const result = await controller.clone('theme-uuid-1', 'Cloned', mockRequest()); + + expect(service.clone).toHaveBeenCalledWith('theme-uuid-1', 'Cloned', 'user-123'); + expect(result.name).toBe('Cloned'); + }); + }); + + // ─── GET /themes/:id/css ───────────────────────────────────────────────── + + describe('getCssVariables()', () => { + it('should return css variables bundle', async () => { + const cssResponse = { + cssVariables: ':root { --color-primary: #6366F1; }', + variables: { '--color-primary': '#6366F1' }, + darkVariables: undefined, + }; + service.getCssVariables.mockResolvedValue(cssResponse); + + const result = await controller.getCssVariables('theme-uuid-1'); + + expect(result.cssVariables).toContain(':root'); + expect(result.variables['--color-primary']).toBe('#6366F1'); + }); + }); + + // ─── GET /themes/:id/preview ────────────────────────────────────────────── + + describe('getPreview()', () => { + it('should return theme with css preview', async () => { + const previewResponse = { + theme: makeResponse(), + css: { + cssVariables: ':root { }', + variables: {}, + }, + }; + service.getPreview.mockResolvedValue(previewResponse); + + const result = await controller.getPreview('theme-uuid-1'); + expect(result.theme).toBeDefined(); + expect(result.css).toBeDefined(); + }); + }); +}); diff --git a/apps/api/src/custom-theme/theme.controller.ts b/apps/api/src/custom-theme/theme.controller.ts new file mode 100644 index 0000000..4691b87 --- /dev/null +++ b/apps/api/src/custom-theme/theme.controller.ts @@ -0,0 +1,180 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + Req, +} from '@nestjs/common'; +import { + ApiBearerAuth, + ApiBody, + ApiCreatedResponse, + ApiNoContentResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from '@nestjs/swagger'; +import { Request } from 'express'; +import { + CreateThemeDto, + ThemeCssVariablesResponseDto, + ThemeOverrideDto, + ThemePreviewResponseDto, + ThemeQueryDto, + ThemeResponseDto, + UpdateThemeDto, +} from './dto/theme.dto'; +import { ThemeScope } from './entities/theme.entity'; +import { ThemeService } from './theme.service'; + +@ApiTags('Themes') +@ApiBearerAuth() +@Controller('themes') +export class ThemeController { + constructor(private readonly themeService: ThemeService) {} + + // ─── CRUD ───────────────────────────────────────────────────────────────── + + @Post() + @ApiOperation({ summary: 'Create a new theme' }) + @ApiCreatedResponse({ type: ThemeResponseDto }) + async create( + @Body() dto: CreateThemeDto, + @Req() req: Request, + ): Promise { + const actorId = (req as any).user?.id; + return this.themeService.create(dto, actorId); + } + + @Get() + @ApiOperation({ summary: 'List themes with optional scope filter' }) + @ApiOkResponse({ type: [ThemeResponseDto] }) + @ApiQuery({ name: 'scope', enum: ThemeScope, required: false }) + @ApiQuery({ name: 'scopeOwnerId', required: false }) + @ApiQuery({ name: 'isActive', required: false, type: Boolean }) + @ApiQuery({ name: 'isDefault', required: false, type: Boolean }) + async findAll(@Query() query: ThemeQueryDto): Promise { + return this.themeService.findAll(query); + } + + @Get('default') + @ApiOperation({ summary: 'Get the default theme for a scope' }) + @ApiOkResponse({ type: ThemeResponseDto }) + @ApiQuery({ name: 'scope', enum: ThemeScope, required: false }) + @ApiQuery({ name: 'scopeOwnerId', required: false }) + async getDefault( + @Query('scope') scope?: ThemeScope, + @Query('scopeOwnerId') scopeOwnerId?: string, + ): Promise { + return this.themeService.getDefaultTheme(scope ?? ThemeScope.GLOBAL, scopeOwnerId); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a theme by ID' }) + @ApiOkResponse({ type: ThemeResponseDto }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async findOne(@Param('id', ParseUUIDPipe) id: string): Promise { + return this.themeService.findOne(id); + } + + @Patch(':id') + @ApiOperation({ summary: 'Update a theme' }) + @ApiOkResponse({ type: ThemeResponseDto }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateThemeDto, + @Req() req: Request, + ): Promise { + const actorId = (req as any).user?.id; + return this.themeService.update(id, dto, actorId); + } + + @Delete(':id') + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Soft-delete a theme' }) + @ApiNoContentResponse() + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async remove( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ): Promise { + const actorId = (req as any).user?.id; + return this.themeService.remove(id, actorId); + } + + // ─── Overrides ──────────────────────────────────────────────────────────── + + @Post(':id/override') + @ApiOperation({ summary: 'Apply partial config overrides to a theme' }) + @ApiOkResponse({ type: ThemeResponseDto }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + @ApiBody({ type: ThemeOverrideDto }) + async applyOverride( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ThemeOverrideDto, + @Req() req: Request, + ): Promise { + const actorId = (req as any).user?.id; + return this.themeService.applyOverride(id, dto, actorId); + } + + @Post(':id/reset') + @ApiOperation({ summary: 'Reset theme to its default/parent config' }) + @ApiOkResponse({ type: ThemeResponseDto }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async resetToDefault( + @Param('id', ParseUUIDPipe) id: string, + @Req() req: Request, + ): Promise { + const actorId = (req as any).user?.id; + return this.themeService.resetToDefault(id, actorId); + } + + // ─── Clone ──────────────────────────────────────────────────────────────── + + @Post(':id/clone') + @ApiOperation({ summary: 'Clone a theme under a new name' }) + @ApiCreatedResponse({ type: ThemeResponseDto }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + @ApiBody({ schema: { properties: { name: { type: 'string' } }, required: ['name'] } }) + async clone( + @Param('id', ParseUUIDPipe) id: string, + @Body('name') name: string, + @Req() req: Request, + ): Promise { + const actorId = (req as any).user?.id; + return this.themeService.clone(id, name, actorId); + } + + // ─── CSS ────────────────────────────────────────────────────────────────── + + @Get(':id/css') + @ApiOperation({ summary: 'Get CSS custom property variables for a theme' }) + @ApiOkResponse({ type: ThemeCssVariablesResponseDto }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async getCssVariables( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.themeService.getCssVariables(id); + } + + @Get(':id/preview') + @ApiOperation({ summary: 'Preview a theme — returns theme data + CSS variables bundle' }) + @ApiOkResponse({ type: ThemePreviewResponseDto }) + @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) + async getPreview( + @Param('id', ParseUUIDPipe) id: string, + ): Promise { + return this.themeService.getPreview(id); + } +} diff --git a/apps/api/src/custom-theme/theme.dto.ts b/apps/api/src/custom-theme/theme.dto.ts new file mode 100644 index 0000000..5a60e26 --- /dev/null +++ b/apps/api/src/custom-theme/theme.dto.ts @@ -0,0 +1,503 @@ +import { ApiProperty, ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsBoolean, + IsEnum, + IsHexColor, + IsNotEmpty, + IsNumber, + IsObject, + IsOptional, + IsString, + IsUUID, + MaxLength, + Min, + ValidateIf, + ValidateNested, +} from 'class-validator'; +import { ThemeScope } from '../entities/theme.entity'; +import { + ThemeBorderRadius, + ThemeBreakpoints, + ThemeColors, + ThemeConfig, + ThemeShadows, + ThemeSpacing, + ThemeTransitions, + ThemeTypography, + ThemeZIndex, +} from '../types/theme-config.types'; + +// ─── Nested config DTOs ────────────────────────────────────────────────────── + +export class ThemeColorsDto implements Partial { + @ApiPropertyOptional({ example: '#6366F1' }) + @IsOptional() + @IsHexColor() + primary?: string; + + @ApiPropertyOptional({ example: '#4F46E5' }) + @IsOptional() + @IsHexColor() + primaryHover?: string; + + @ApiPropertyOptional({ example: '#4338CA' }) + @IsOptional() + @IsHexColor() + primaryActive?: string; + + @ApiPropertyOptional({ example: '#8B5CF6' }) + @IsOptional() + @IsHexColor() + secondary?: string; + + @ApiPropertyOptional({ example: '#7C3AED' }) + @IsOptional() + @IsHexColor() + secondaryHover?: string; + + @ApiPropertyOptional({ example: '#6D28D9' }) + @IsOptional() + @IsHexColor() + secondaryActive?: string; + + @ApiPropertyOptional({ example: '#F59E0B' }) + @IsOptional() + @IsHexColor() + accent?: string; + + @ApiPropertyOptional({ example: '#F9FAFB' }) + @IsOptional() + @IsHexColor() + background?: string; + + @ApiPropertyOptional({ example: '#FFFFFF' }) + @IsOptional() + @IsHexColor() + surface?: string; + + @ApiPropertyOptional({ example: '#F3F4F6' }) + @IsOptional() + @IsHexColor() + surfaceRaised?: string; + + @ApiPropertyOptional({ example: '#FFFFFF' }) + @IsOptional() + @IsHexColor() + onPrimary?: string; + + @ApiPropertyOptional({ example: '#FFFFFF' }) + @IsOptional() + @IsHexColor() + onSecondary?: string; + + @ApiPropertyOptional({ example: '#EF4444' }) + @IsOptional() + @IsHexColor() + error?: string; + + @ApiPropertyOptional({ example: '#FEE2E2' }) + @IsOptional() + @IsHexColor() + errorLight?: string; + + @ApiPropertyOptional({ example: '#F59E0B' }) + @IsOptional() + @IsHexColor() + warning?: string; + + @ApiPropertyOptional({ example: '#FEF3C7' }) + @IsOptional() + @IsHexColor() + warningLight?: string; + + @ApiPropertyOptional({ example: '#10B981' }) + @IsOptional() + @IsHexColor() + success?: string; + + @ApiPropertyOptional({ example: '#D1FAE5' }) + @IsOptional() + @IsHexColor() + successLight?: string; + + @ApiPropertyOptional({ example: '#3B82F6' }) + @IsOptional() + @IsHexColor() + info?: string; + + @ApiPropertyOptional({ example: '#DBEAFE' }) + @IsOptional() + @IsHexColor() + infoLight?: string; + + @ApiPropertyOptional({ example: '#111827' }) + @IsOptional() + @IsHexColor() + textPrimary?: string; + + @ApiPropertyOptional({ example: '#6B7280' }) + @IsOptional() + @IsHexColor() + textSecondary?: string; + + @ApiPropertyOptional({ example: '#9CA3AF' }) + @IsOptional() + @IsHexColor() + textDisabled?: string; + + @ApiPropertyOptional({ example: '#FFFFFF' }) + @IsOptional() + @IsHexColor() + textInverse?: string; + + @ApiPropertyOptional({ example: '#E5E7EB' }) + @IsOptional() + @IsHexColor() + border?: string; + + @ApiPropertyOptional({ example: '#6366F1' }) + @IsOptional() + @IsHexColor() + borderFocus?: string; + + @ApiPropertyOptional({ example: '#F3F4F6' }) + @IsOptional() + @IsHexColor() + divider?: string; +} + +export class ThemeTypographyDto implements Partial { + @ApiPropertyOptional({ example: "'Inter', sans-serif" }) + @IsOptional() + @IsString() + fontFamilyHeading?: string; + + @ApiPropertyOptional({ example: "'Inter', sans-serif" }) + @IsOptional() + @IsString() + fontFamilyBody?: string; + + @ApiPropertyOptional({ example: "'JetBrains Mono', monospace" }) + @IsOptional() + @IsString() + fontFamilyMono?: string; + + @ApiPropertyOptional({ example: '0.75rem' }) + @IsOptional() + @IsString() + fontSizeXs?: string; + + @ApiPropertyOptional({ example: '0.875rem' }) + @IsOptional() + @IsString() + fontSizeSm?: string; + + @ApiPropertyOptional({ example: '1rem' }) + @IsOptional() + @IsString() + fontSizeBase?: string; + + @ApiPropertyOptional({ example: '1.125rem' }) + @IsOptional() + @IsString() + fontSizeLg?: string; + + @ApiPropertyOptional({ example: '1.25rem' }) + @IsOptional() + @IsString() + fontSizeXl?: string; + + @ApiPropertyOptional({ example: '1.5rem' }) + @IsOptional() + @IsString() + fontSize2xl?: string; + + @ApiPropertyOptional({ example: '1.875rem' }) + @IsOptional() + @IsString() + fontSize3xl?: string; + + @ApiPropertyOptional({ example: '2.25rem' }) + @IsOptional() + @IsString() + fontSize4xl?: string; + + @ApiPropertyOptional({ example: 300 }) + @IsOptional() + @IsNumber() + fontWeightLight?: number; + + @ApiPropertyOptional({ example: 400 }) + @IsOptional() + @IsNumber() + fontWeightRegular?: number; + + @ApiPropertyOptional({ example: 500 }) + @IsOptional() + @IsNumber() + fontWeightMedium?: number; + + @ApiPropertyOptional({ example: 600 }) + @IsOptional() + @IsNumber() + fontWeightSemibold?: number; + + @ApiPropertyOptional({ example: 700 }) + @IsOptional() + @IsNumber() + fontWeightBold?: number; + + @ApiPropertyOptional({ example: 1.25 }) + @IsOptional() + @IsNumber() + lineHeightTight?: number; + + @ApiPropertyOptional({ example: 1.5 }) + @IsOptional() + @IsNumber() + lineHeightNormal?: number; + + @ApiPropertyOptional({ example: 1.75 }) + @IsOptional() + @IsNumber() + lineHeightRelaxed?: number; +} + +export class ThemeSpacingDto implements Partial { + @ApiPropertyOptional({ example: 4 }) + @IsOptional() + @IsNumber() + @Min(1) + unit?: number; + + @ApiPropertyOptional({ example: 4 }) + @IsOptional() + @IsNumber() + xs?: number; + + @ApiPropertyOptional({ example: 8 }) + @IsOptional() + @IsNumber() + sm?: number; + + @ApiPropertyOptional({ example: 16 }) + @IsOptional() + @IsNumber() + md?: number; + + @ApiPropertyOptional({ example: 24 }) + @IsOptional() + @IsNumber() + lg?: number; + + @ApiPropertyOptional({ example: 32 }) + @IsOptional() + @IsNumber() + xl?: number; + + @ApiPropertyOptional({ example: 48 }) + @IsOptional() + @IsNumber() + xxl?: number; +} + +export class ThemeBorderRadiusDto implements Partial { + @ApiPropertyOptional({ example: '0px' }) + @IsOptional() + @IsString() + none?: string; + + @ApiPropertyOptional({ example: '2px' }) + @IsOptional() + @IsString() + sm?: string; + + @ApiPropertyOptional({ example: '4px' }) + @IsOptional() + @IsString() + base?: string; + + @ApiPropertyOptional({ example: '8px' }) + @IsOptional() + @IsString() + lg?: string; + + @ApiPropertyOptional({ example: '12px' }) + @IsOptional() + @IsString() + xl?: string; + + @ApiPropertyOptional({ example: '9999px' }) + @IsOptional() + @IsString() + full?: string; +} + +export class ThemeConfigDto { + @ApiPropertyOptional({ type: ThemeColorsDto }) + @IsOptional() + @ValidateNested() + @Type(() => ThemeColorsDto) + colors?: ThemeColorsDto; + + @ApiPropertyOptional({ type: ThemeColorsDto, description: 'Dark mode color overrides' }) + @IsOptional() + @ValidateNested() + @Type(() => ThemeColorsDto) + darkModeColors?: ThemeColorsDto; + + @ApiPropertyOptional({ type: ThemeTypographyDto }) + @IsOptional() + @ValidateNested() + @Type(() => ThemeTypographyDto) + typography?: ThemeTypographyDto; + + @ApiPropertyOptional({ type: ThemeSpacingDto }) + @IsOptional() + @ValidateNested() + @Type(() => ThemeSpacingDto) + spacing?: ThemeSpacingDto; + + @ApiPropertyOptional({ type: ThemeBorderRadiusDto }) + @IsOptional() + @ValidateNested() + @Type(() => ThemeBorderRadiusDto) + borderRadius?: ThemeBorderRadiusDto; + + @ApiPropertyOptional({ description: 'Enable dark mode support', example: false }) + @IsOptional() + @IsBoolean() + darkModeEnabled?: boolean; + + @ApiPropertyOptional({ + description: 'Custom CSS variables map', + example: { '--custom-ribbon': '#FF0000' }, + }) + @IsOptional() + @IsObject() + customCssVariables?: Record; +} + +// ─── Primary DTOs ──────────────────────────────────────────────────────────── + +export class CreateThemeDto { + @ApiProperty({ example: 'BridgeWise Dark', maxLength: 100 }) + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string; + + @ApiPropertyOptional({ example: 'Dark theme for BridgeWise dashboard', maxLength: 500 }) + @IsOptional() + @IsString() + @MaxLength(500) + description?: string; + + @ApiPropertyOptional({ enum: ThemeScope, default: ThemeScope.GLOBAL }) + @IsOptional() + @IsEnum(ThemeScope) + scope?: ThemeScope; + + @ApiPropertyOptional({ description: 'Owner ID for organization/user-scoped themes' }) + @IsOptional() + @IsUUID() + scopeOwnerId?: string; + + @ApiPropertyOptional({ description: 'Inherit from this theme ID and override' }) + @IsOptional() + @IsUUID() + parentThemeId?: string; + + @ApiPropertyOptional({ type: ThemeConfigDto }) + @IsOptional() + @ValidateNested() + @Type(() => ThemeConfigDto) + config?: ThemeConfigDto; + + @ApiPropertyOptional({ default: false }) + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} + +export class UpdateThemeDto extends PartialType(CreateThemeDto) {} + +export class ThemeOverrideDto { + @ApiProperty({ type: ThemeConfigDto, description: 'Partial config overrides to apply' }) + @IsNotEmpty() + @ValidateNested() + @Type(() => ThemeConfigDto) + overrides: ThemeConfigDto; +} + +export class ApplyThemeDto { + @ApiProperty({ description: 'Theme ID to apply' }) + @IsUUID() + themeId: string; + + @ApiPropertyOptional({ enum: ThemeScope }) + @IsOptional() + @IsEnum(ThemeScope) + scope?: ThemeScope; + + @ApiPropertyOptional({ description: 'Scope owner ID (org or user)' }) + @IsOptional() + @IsUUID() + scopeOwnerId?: string; +} + +export class ThemeQueryDto { + @ApiPropertyOptional({ enum: ThemeScope }) + @IsOptional() + @IsEnum(ThemeScope) + scope?: ThemeScope; + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + scopeOwnerId?: string; + + @ApiPropertyOptional({ default: true }) + @IsOptional() + @IsBoolean() + isActive?: boolean; + + @ApiPropertyOptional({ description: 'Filter by default theme only' }) + @IsOptional() + @IsBoolean() + isDefault?: boolean; +} + +// ─── Response DTOs ─────────────────────────────────────────────────────────── + +export class ThemeResponseDto { + @ApiProperty() id: string; + @ApiProperty() name: string; + @ApiPropertyOptional() description: string | null; + @ApiProperty({ enum: ThemeScope }) scope: ThemeScope; + @ApiPropertyOptional() scopeOwnerId: string | null; + @ApiProperty() config: ThemeConfig; + @ApiProperty() isDefault: boolean; + @ApiProperty() isActive: boolean; + @ApiProperty() isReadOnly: boolean; + @ApiPropertyOptional() parentThemeId: string | null; + @ApiProperty() createdAt: Date; + @ApiProperty() updatedAt: Date; +} + +export class ThemeCssVariablesResponseDto { + @ApiProperty({ description: 'Generated CSS variables string' }) + cssVariables: string; + + @ApiProperty({ description: 'CSS variables as key-value map' }) + variables: Record; + + @ApiProperty({ description: 'Dark mode CSS variables map (if enabled)' }) + darkVariables?: Record; +} + +export class ThemePreviewResponseDto { + @ApiProperty({ type: ThemeResponseDto }) theme: ThemeResponseDto; + @ApiProperty({ type: ThemeCssVariablesResponseDto }) css: ThemeCssVariablesResponseDto; +} diff --git a/apps/api/src/custom-theme/theme.e2e.spec.ts b/apps/api/src/custom-theme/theme.e2e.spec.ts new file mode 100644 index 0000000..39c6e4a --- /dev/null +++ b/apps/api/src/custom-theme/theme.e2e.spec.ts @@ -0,0 +1,308 @@ +import { INestApplication, ValidationPipe } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import * as request from 'supertest'; +import { ThemeController } from '../theme.controller'; +import { ThemeService } from '../theme.service'; +import { ThemeScope } from '../entities/theme.entity'; +import { DEFAULT_THEME_CONFIG } from '../types/theme-config.types'; + +// ─── Mock service setup ─────────────────────────────────────────────────────── + +const makeThemeResponse = (overrides: Partial = {}) => ({ + id: 'e2e-theme-1', + name: 'E2E Theme', + description: 'E2E test theme', + scope: ThemeScope.GLOBAL, + scopeOwnerId: null, + parentThemeId: null, + config: DEFAULT_THEME_CONFIG, + isDefault: false, + isActive: true, + isReadOnly: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + ...overrides, +}); + +const mockThemeService = { + create: jest.fn(), + findAll: jest.fn(), + findOne: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + applyOverride: jest.fn(), + resetToDefault: jest.fn(), + clone: jest.fn(), + getCssVariables: jest.fn(), + getPreview: jest.fn(), + getDefaultTheme: jest.fn(), +}; + +// ─── E2E Suite ─────────────────────────────────────────────────────────────── + +describe('Theme API (e2e)', () => { + let app: INestApplication; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [ThemeController], + providers: [{ provide: ThemeService, useValue: mockThemeService }], + }).compile(); + + app = module.createNestApplication(); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); + await app.init(); + }); + + afterAll(() => app.close()); + afterEach(() => jest.clearAllMocks()); + + // ─── POST /themes ────────────────────────────────────────────────────────── + + describe('POST /themes', () => { + it('should create a theme and return 201', async () => { + const payload = { name: 'BridgeWise Dark' }; + mockThemeService.create.mockResolvedValue(makeThemeResponse({ name: 'BridgeWise Dark' })); + + const { status, body } = await request(app.getHttpServer()) + .post('/themes') + .send(payload); + + expect(status).toBe(201); + expect(body.name).toBe('BridgeWise Dark'); + expect(body.id).toBeDefined(); + }); + + it('should return 400 when name is missing', async () => { + const { status } = await request(app.getHttpServer()) + .post('/themes') + .send({}); + + expect(status).toBe(400); + }); + + it('should return 400 when name exceeds max length', async () => { + const { status } = await request(app.getHttpServer()) + .post('/themes') + .send({ name: 'a'.repeat(101) }); + + expect(status).toBe(400); + }); + + it('should create theme with config overrides', async () => { + const payload = { + name: 'Custom Red Theme', + config: { + colors: { primary: '#FF0000' }, + }, + }; + mockThemeService.create.mockResolvedValue(makeThemeResponse({ name: 'Custom Red Theme' })); + + const { status, body } = await request(app.getHttpServer()) + .post('/themes') + .send(payload); + + expect(status).toBe(201); + expect(body.name).toBe('Custom Red Theme'); + }); + + it('should return 400 for invalid hex color', async () => { + const { status } = await request(app.getHttpServer()) + .post('/themes') + .send({ + name: 'Bad Color Theme', + config: { colors: { primary: 'not-a-color' } }, + }); + + expect(status).toBe(400); + }); + }); + + // ─── GET /themes ────────────────────────────────────────────────────────── + + describe('GET /themes', () => { + it('should return theme list', async () => { + mockThemeService.findAll.mockResolvedValue([ + makeThemeResponse(), + makeThemeResponse({ id: 'e2e-theme-2', name: 'Second Theme' }), + ]); + + const { status, body } = await request(app.getHttpServer()).get('/themes'); + + expect(status).toBe(200); + expect(Array.isArray(body)).toBe(true); + expect(body).toHaveLength(2); + }); + + it('should accept scope query param', async () => { + mockThemeService.findAll.mockResolvedValue([]); + + const { status } = await request(app.getHttpServer()) + .get('/themes') + .query({ scope: 'global' }); + + expect(status).toBe(200); + expect(mockThemeService.findAll).toHaveBeenCalledWith( + expect.objectContaining({ scope: 'global' }), + ); + }); + }); + + // ─── GET /themes/default ────────────────────────────────────────────────── + + describe('GET /themes/default', () => { + it('should return the default theme', async () => { + mockThemeService.getDefaultTheme.mockResolvedValue( + makeThemeResponse({ isDefault: true }), + ); + + const { status, body } = await request(app.getHttpServer()).get('/themes/default'); + + expect(status).toBe(200); + expect(body.isDefault).toBe(true); + }); + }); + + // ─── GET /themes/:id ────────────────────────────────────────────────────── + + describe('GET /themes/:id', () => { + it('should return a single theme', async () => { + mockThemeService.findOne.mockResolvedValue(makeThemeResponse()); + + const { status, body } = await request(app.getHttpServer()) + .get('/themes/e2e-theme-1'); + + expect(status).toBe(200); + expect(body.id).toBe('e2e-theme-1'); + }); + }); + + // ─── PATCH /themes/:id ──────────────────────────────────────────────────── + + describe('PATCH /themes/:id', () => { + it('should update theme name', async () => { + mockThemeService.update.mockResolvedValue( + makeThemeResponse({ name: 'Updated Name' }), + ); + + const { status, body } = await request(app.getHttpServer()) + .patch('/themes/e2e-theme-1') + .send({ name: 'Updated Name' }); + + expect(status).toBe(200); + expect(body.name).toBe('Updated Name'); + }); + }); + + // ─── DELETE /themes/:id ─────────────────────────────────────────────────── + + describe('DELETE /themes/:id', () => { + it('should soft-delete a theme and return 204', async () => { + mockThemeService.remove.mockResolvedValue(undefined); + + const { status } = await request(app.getHttpServer()) + .delete('/themes/e2e-theme-1'); + + expect(status).toBe(204); + }); + }); + + // ─── POST /themes/:id/override ──────────────────────────────────────────── + + describe('POST /themes/:id/override', () => { + it('should apply color overrides and return updated theme', async () => { + const overridePayload = { overrides: { colors: { primary: '#00FF00' } } }; + mockThemeService.applyOverride.mockResolvedValue(makeThemeResponse()); + + const { status, body } = await request(app.getHttpServer()) + .post('/themes/e2e-theme-1/override') + .send(overridePayload); + + expect(status).toBe(201); + }); + + it('should reject override with invalid color', async () => { + const { status } = await request(app.getHttpServer()) + .post('/themes/e2e-theme-1/override') + .send({ overrides: { colors: { primary: 'invalid' } } }); + + expect(status).toBe(400); + }); + }); + + // ─── POST /themes/:id/reset ─────────────────────────────────────────────── + + describe('POST /themes/:id/reset', () => { + it('should reset theme and return 201', async () => { + mockThemeService.resetToDefault.mockResolvedValue(makeThemeResponse()); + + const { status } = await request(app.getHttpServer()) + .post('/themes/e2e-theme-1/reset'); + + expect(status).toBe(201); + }); + }); + + // ─── POST /themes/:id/clone ─────────────────────────────────────────────── + + describe('POST /themes/:id/clone', () => { + it('should clone a theme with a new name', async () => { + mockThemeService.clone.mockResolvedValue( + makeThemeResponse({ id: 'cloned-id', name: 'My Clone' }), + ); + + const { status, body } = await request(app.getHttpServer()) + .post('/themes/e2e-theme-1/clone') + .send({ name: 'My Clone' }); + + expect(status).toBe(201); + expect(body.name).toBe('My Clone'); + }); + }); + + // ─── GET /themes/:id/css ────────────────────────────────────────────────── + + describe('GET /themes/:id/css', () => { + it('should return CSS variables string and map', async () => { + mockThemeService.getCssVariables.mockResolvedValue({ + cssVariables: ':root { --color-primary: #6366F1; }', + variables: { '--color-primary': '#6366F1' }, + darkVariables: undefined, + }); + + const { status, body } = await request(app.getHttpServer()) + .get('/themes/e2e-theme-1/css'); + + expect(status).toBe(200); + expect(body.cssVariables).toContain(':root'); + expect(body.variables['--color-primary']).toBe('#6366F1'); + }); + }); + + // ─── GET /themes/:id/preview ────────────────────────────────────────────── + + describe('GET /themes/:id/preview', () => { + it('should return theme + css bundle', async () => { + mockThemeService.getPreview.mockResolvedValue({ + theme: makeThemeResponse(), + css: { + cssVariables: ':root { }', + variables: {}, + }, + }); + + const { status, body } = await request(app.getHttpServer()) + .get('/themes/e2e-theme-1/preview'); + + expect(status).toBe(200); + expect(body.theme).toBeDefined(); + expect(body.css).toBeDefined(); + }); + }); +}); diff --git a/apps/api/src/custom-theme/theme.entity.ts b/apps/api/src/custom-theme/theme.entity.ts new file mode 100644 index 0000000..b413c26 --- /dev/null +++ b/apps/api/src/custom-theme/theme.entity.ts @@ -0,0 +1,69 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { ThemeConfig } from '../types/theme-config.types'; + +export enum ThemeScope { + GLOBAL = 'global', + ORGANIZATION = 'organization', + USER = 'user', +} + +@Entity('themes') +@Index(['scope', 'isDefault']) +@Index(['name', 'scope'], { unique: true }) +export class Theme { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column({ length: 500, nullable: true }) + description: string | null; + + @Column({ + type: 'enum', + enum: ThemeScope, + default: ThemeScope.GLOBAL, + }) + scope: ThemeScope; + + @Column({ nullable: true }) + scopeOwnerId: string | null; + + @Column({ type: 'jsonb' }) + config: ThemeConfig; + + @Column({ default: false }) + isDefault: boolean; + + @Column({ default: true }) + isActive: boolean; + + @Column({ default: false }) + isReadOnly: boolean; + + @Column({ nullable: true }) + parentThemeId: string | null; + + @Column({ nullable: true }) + createdBy: string | null; + + @Column({ nullable: true }) + updatedBy: string | null; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; + + @Column({ nullable: true }) + deletedAt: Date | null; +} diff --git a/apps/api/src/custom-theme/theme.module.ts b/apps/api/src/custom-theme/theme.module.ts new file mode 100644 index 0000000..9a07324 --- /dev/null +++ b/apps/api/src/custom-theme/theme.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { Theme } from './entities/theme.entity'; +import { ThemeRepository } from './repositories/theme.repository'; +import { ThemeController } from './theme.controller'; +import { ThemeService } from './theme.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([Theme])], + controllers: [ThemeController], + providers: [ThemeService, ThemeRepository], + exports: [ThemeService], +}) +export class ThemeModule {} diff --git a/apps/api/src/custom-theme/theme.repository.spec.ts b/apps/api/src/custom-theme/theme.repository.spec.ts new file mode 100644 index 0000000..633bcb2 --- /dev/null +++ b/apps/api/src/custom-theme/theme.repository.spec.ts @@ -0,0 +1,183 @@ +import { DataSource, SelectQueryBuilder, UpdateQueryBuilder } from 'typeorm'; +import { ThemeRepository } from '../repositories/theme.repository'; +import { Theme, ThemeScope } from '../entities/theme.entity'; +import { DEFAULT_THEME_CONFIG } from '../types/theme-config.types'; + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const makeTheme = (overrides: Partial = {}): Theme => + ({ + id: 'theme-1', + name: 'Test', + description: null, + scope: ThemeScope.GLOBAL, + scopeOwnerId: null, + parentThemeId: null, + config: DEFAULT_THEME_CONFIG, + isDefault: false, + isActive: true, + isReadOnly: false, + createdBy: null, + updatedBy: null, + createdAt: new Date(), + updatedAt: new Date(), + deletedAt: null, + ...overrides, + } as Theme); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('ThemeRepository', () => { + let repository: ThemeRepository; + let dataSource: jest.Mocked; + + const mockQb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + leftJoinAndMapOne: jest.fn().mockReturnThis(), + update: jest.fn().mockReturnThis(), + set: jest.fn().mockReturnThis(), + execute: jest.fn().mockResolvedValue(undefined), + getMany: jest.fn(), + getOne: jest.fn(), + } as unknown as jest.Mocked>; + + beforeEach(() => { + const mockManager = { + find: jest.fn(), + findOne: jest.fn(), + save: jest.fn(), + update: jest.fn(), + count: jest.fn(), + } as any; + + dataSource = { + createEntityManager: jest.fn().mockReturnValue(mockManager), + } as unknown as jest.Mocked; + + repository = new ThemeRepository(dataSource); + + // Stub QueryBuilder methods + jest.spyOn(repository, 'createQueryBuilder').mockReturnValue(mockQb as any); + jest.spyOn(repository, 'findOne').mockResolvedValue(null); + jest.spyOn(repository, 'update').mockResolvedValue({ affected: 1 } as any); + jest.spyOn(repository, 'count').mockResolvedValue(0); + }); + + afterEach(() => jest.clearAllMocks()); + + describe('findByScope()', () => { + it('should query with correct scope filter', async () => { + (mockQb.getMany as jest.Mock).mockResolvedValue([makeTheme()]); + + const result = await repository.findByScope(ThemeScope.GLOBAL); + + expect(mockQb.where).toHaveBeenCalledWith('theme.scope = :scope', { + scope: ThemeScope.GLOBAL, + }); + expect(result).toHaveLength(1); + }); + + it('should apply scopeOwnerId filter when provided', async () => { + (mockQb.getMany as jest.Mock).mockResolvedValue([]); + + await repository.findByScope(ThemeScope.ORGANIZATION, 'org-123'); + + expect(mockQb.andWhere).toHaveBeenCalledWith( + 'theme.scopeOwnerId = :scopeOwnerId', + { scopeOwnerId: 'org-123' }, + ); + }); + + it('should order by isDefault DESC then createdAt ASC', async () => { + (mockQb.getMany as jest.Mock).mockResolvedValue([]); + + await repository.findByScope(ThemeScope.GLOBAL); + + expect(mockQb.orderBy).toHaveBeenCalledWith('theme.isDefault', 'DESC'); + expect(mockQb.addOrderBy).toHaveBeenCalledWith('theme.createdAt', 'ASC'); + }); + }); + + describe('findDefaultForScope()', () => { + it('should query for isDefault=true themes', async () => { + (mockQb.getOne as jest.Mock).mockResolvedValue(makeTheme({ isDefault: true })); + + const result = await repository.findDefaultForScope(ThemeScope.GLOBAL); + + expect(mockQb.andWhere).toHaveBeenCalledWith('theme.isDefault = true'); + expect(result?.isDefault).toBe(true); + }); + + it('should return null when no default found', async () => { + (mockQb.getOne as jest.Mock).mockResolvedValue(null); + + const result = await repository.findDefaultForScope(ThemeScope.GLOBAL); + expect(result).toBeNull(); + }); + }); + + describe('findActiveById()', () => { + it('should call findOne with active and non-deleted filter', async () => { + const theme = makeTheme(); + jest.spyOn(repository, 'findOne').mockResolvedValue(theme); + + const result = await repository.findActiveById('theme-1'); + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { id: 'theme-1', isActive: true, deletedAt: null }, + }); + expect(result).toEqual(theme); + }); + + it('should return null when theme not found', async () => { + jest.spyOn(repository, 'findOne').mockResolvedValue(null); + + const result = await repository.findActiveById('missing'); + expect(result).toBeNull(); + }); + }); + + describe('unsetDefaultForScope()', () => { + it('should issue update query to clear isDefault flags', async () => { + await repository.unsetDefaultForScope(ThemeScope.GLOBAL); + + expect(mockQb.set).toHaveBeenCalledWith({ isDefault: false }); + expect(mockQb.execute).toHaveBeenCalled(); + }); + }); + + describe('softDelete()', () => { + it('should set deletedAt, isActive=false and updatedBy', async () => { + jest.spyOn(repository, 'update').mockResolvedValue({ affected: 1 } as any); + + await repository.softDelete('theme-1', 'actor-id'); + + expect(repository.update).toHaveBeenCalledWith( + 'theme-1', + expect.objectContaining({ + isActive: false, + updatedBy: 'actor-id', + }), + ); + + const updateArg = (repository.update as jest.Mock).mock.calls[0][1]; + expect(updateArg.deletedAt).toBeInstanceOf(Date); + }); + }); + + describe('countByScope()', () => { + it('should count active, non-deleted themes in the scope', async () => { + jest.spyOn(repository, 'count').mockResolvedValue(3); + + const result = await repository.countByScope(ThemeScope.GLOBAL); + + expect(repository.count).toHaveBeenCalledWith({ + where: { scope: ThemeScope.GLOBAL, isActive: true, deletedAt: null }, + }); + expect(result).toBe(3); + }); + }); +}); diff --git a/apps/api/src/custom-theme/theme.repository.ts b/apps/api/src/custom-theme/theme.repository.ts new file mode 100644 index 0000000..3aa7733 --- /dev/null +++ b/apps/api/src/custom-theme/theme.repository.ts @@ -0,0 +1,84 @@ +import { Injectable } from '@nestjs/common'; +import { DataSource, Repository } from 'typeorm'; +import { Theme, ThemeScope } from '../entities/theme.entity'; + +@Injectable() +export class ThemeRepository extends Repository { + constructor(private readonly dataSource: DataSource) { + super(Theme, dataSource.createEntityManager()); + } + + async findByScope(scope: ThemeScope, scopeOwnerId?: string): Promise { + const qb = this.createQueryBuilder('theme') + .where('theme.scope = :scope', { scope }) + .andWhere('theme.isActive = true') + .andWhere('theme.deletedAt IS NULL') + .orderBy('theme.isDefault', 'DESC') + .addOrderBy('theme.createdAt', 'ASC'); + + if (scopeOwnerId) { + qb.andWhere('theme.scopeOwnerId = :scopeOwnerId', { scopeOwnerId }); + } + + return qb.getMany(); + } + + async findDefaultForScope(scope: ThemeScope, scopeOwnerId?: string): Promise { + const qb = this.createQueryBuilder('theme') + .where('theme.scope = :scope', { scope }) + .andWhere('theme.isDefault = true') + .andWhere('theme.isActive = true') + .andWhere('theme.deletedAt IS NULL'); + + if (scopeOwnerId) { + qb.andWhere('theme.scopeOwnerId = :scopeOwnerId', { scopeOwnerId }); + } + + return qb.getOne(); + } + + async findActiveById(id: string): Promise { + return this.findOne({ + where: { id, isActive: true, deletedAt: null }, + }); + } + + async unsetDefaultForScope(scope: ThemeScope, scopeOwnerId?: string): Promise { + const qb = this.createQueryBuilder() + .update(Theme) + .set({ isDefault: false }) + .where('scope = :scope', { scope }) + .andWhere('isDefault = true'); + + if (scopeOwnerId) { + qb.andWhere('scopeOwnerId = :scopeOwnerId', { scopeOwnerId }); + } + + await qb.execute(); + } + + async findWithParent(id: string): Promise { + return this.createQueryBuilder('theme') + .leftJoinAndMapOne( + 'theme.parent', + Theme, + 'parent', + 'parent.id = theme.parentThemeId', + ) + .where('theme.id = :id', { id }) + .andWhere('theme.deletedAt IS NULL') + .getOne(); + } + + async softDelete(id: string, deletedBy?: string): Promise { + await this.update(id, { + deletedAt: new Date(), + isActive: false, + updatedBy: deletedBy, + }); + } + + async countByScope(scope: ThemeScope): Promise { + return this.count({ where: { scope, isActive: true, deletedAt: null } }); + } +} diff --git a/apps/api/src/custom-theme/theme.service.spec.ts b/apps/api/src/custom-theme/theme.service.spec.ts new file mode 100644 index 0000000..97d3216 --- /dev/null +++ b/apps/api/src/custom-theme/theme.service.spec.ts @@ -0,0 +1,444 @@ +import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { ThemeScope } from '../entities/theme.entity'; +import { ThemeRepository } from '../repositories/theme.repository'; +import { ThemeService } from '../theme.service'; +import { DEFAULT_THEME_CONFIG } from '../types/theme-config.types'; +import { + CreateThemeDto, + ThemeOverrideDto, + UpdateThemeDto, +} from '../dto/theme.dto'; + +// ─── Mock factory ───────────────────────────────────────────────────────────── + +const makeTheme = (overrides: Partial = {}) => ({ + id: 'theme-uuid-1', + name: 'Test Theme', + description: 'A test theme', + scope: ThemeScope.GLOBAL, + scopeOwnerId: null, + parentThemeId: null, + config: { ...DEFAULT_THEME_CONFIG }, + isDefault: false, + isActive: true, + isReadOnly: false, + createdBy: null, + updatedBy: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + deletedAt: null, + ...overrides, +}); + +const mockRepository = () => ({ + create: jest.fn(), + save: jest.fn(), + findActiveById: jest.fn(), + findByScope: jest.fn(), + findDefaultForScope: jest.fn(), + unsetDefaultForScope: jest.fn(), + softDelete: jest.fn(), + findWithParent: jest.fn(), + countByScope: jest.fn(), +}); + +// ─── Tests ─────────────────────────────────────────────────────────────────── + +describe('ThemeService', () => { + let service: ThemeService; + let repository: jest.Mocked>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ThemeService, + { + provide: ThemeRepository, + useFactory: mockRepository, + }, + ], + }).compile(); + + service = module.get(ThemeService); + repository = module.get(ThemeRepository); + }); + + afterEach(() => jest.clearAllMocks()); + + // ─── create ─────────────────────────────────────────────────────────────── + + describe('create()', () => { + it('should create a theme with default config when no config provided', async () => { + const dto: CreateThemeDto = { name: 'My Theme' }; + const saved = makeTheme({ name: 'My Theme' }); + + repository.create.mockReturnValue(saved); + repository.save.mockResolvedValue(saved); + + const result = await service.create(dto, 'actor-id'); + + expect(repository.create).toHaveBeenCalledWith( + expect.objectContaining({ name: 'My Theme', scope: ThemeScope.GLOBAL }), + ); + expect(result.name).toBe('My Theme'); + expect(result.id).toBe('theme-uuid-1'); + }); + + it('should deep-merge supplied config onto defaults', async () => { + const dto: CreateThemeDto = { + name: 'Custom', + config: { colors: { primary: '#FF0000' } } as any, + }; + const saved = makeTheme({ name: 'Custom' }); + repository.create.mockReturnValue(saved); + repository.save.mockResolvedValue(saved); + + await service.create(dto); + + const createCall = repository.create.mock.calls[0][0]; + expect(createCall.config.colors.primary).toBe('#FF0000'); + // Other defaults preserved + expect(createCall.config.colors.secondary).toBe(DEFAULT_THEME_CONFIG.colors.secondary); + }); + + it('should call unsetDefaultForScope when isDefault=true', async () => { + const dto: CreateThemeDto = { name: 'Default Theme', isDefault: true }; + const saved = makeTheme({ isDefault: true }); + repository.create.mockReturnValue(saved); + repository.save.mockResolvedValue(saved); + + await service.create(dto); + + expect(repository.unsetDefaultForScope).toHaveBeenCalledWith( + ThemeScope.GLOBAL, + undefined, + ); + }); + + it('should inherit parent config when parentThemeId provided', async () => { + const parentTheme = makeTheme({ + id: 'parent-id', + config: { + ...DEFAULT_THEME_CONFIG, + colors: { ...DEFAULT_THEME_CONFIG.colors, primary: '#PARENT' }, + }, + }); + + repository.findActiveById.mockResolvedValue(parentTheme); + repository.create.mockReturnValue(makeTheme()); + repository.save.mockResolvedValue(makeTheme()); + + await service.create({ name: 'Child', parentThemeId: 'parent-id' }); + + const createCall = repository.create.mock.calls[0][0]; + expect(createCall.config.colors.primary).toBe('#PARENT'); + }); + + it('should throw NotFoundException if parent theme not found', async () => { + repository.findActiveById.mockResolvedValue(null); + + await expect( + service.create({ name: 'Child', parentThemeId: 'missing-id' }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for org-scope without scopeOwnerId', async () => { + await expect( + service.create({ name: 'Org Theme', scope: ThemeScope.ORGANIZATION }), + ).rejects.toThrow(BadRequestException); + }); + + it('should throw ConflictException on unique name violation (pg code 23505)', async () => { + repository.create.mockReturnValue(makeTheme()); + repository.save.mockRejectedValue({ code: '23505' }); + + await expect(service.create({ name: 'Duplicate' })).rejects.toThrow(ConflictException); + }); + }); + + // ─── findAll ────────────────────────────────────────────────────────────── + + describe('findAll()', () => { + it('should return mapped theme DTOs', async () => { + const themes = [makeTheme(), makeTheme({ id: 'theme-2', name: 'Second' })]; + repository.findByScope.mockResolvedValue(themes); + + const result = await service.findAll({ scope: ThemeScope.GLOBAL }); + expect(result).toHaveLength(2); + expect(result[0].id).toBe('theme-uuid-1'); + expect(result[1].id).toBe('theme-2'); + }); + + it('should filter by isDefault when specified', async () => { + const themes = [ + makeTheme({ isDefault: true }), + makeTheme({ id: 'theme-2', isDefault: false }), + ]; + repository.findByScope.mockResolvedValue(themes); + + const result = await service.findAll({ isDefault: true }); + expect(result).toHaveLength(1); + expect(result[0].isDefault).toBe(true); + }); + + it('should return empty array when no themes found', async () => { + repository.findByScope.mockResolvedValue([]); + const result = await service.findAll({}); + expect(result).toHaveLength(0); + }); + }); + + // ─── findOne ────────────────────────────────────────────────────────────── + + describe('findOne()', () => { + it('should return a theme DTO by id', async () => { + repository.findActiveById.mockResolvedValue(makeTheme()); + const result = await service.findOne('theme-uuid-1'); + expect(result.id).toBe('theme-uuid-1'); + }); + + it('should throw NotFoundException when theme not found', async () => { + repository.findActiveById.mockResolvedValue(null); + await expect(service.findOne('missing')).rejects.toThrow(NotFoundException); + }); + }); + + // ─── update ─────────────────────────────────────────────────────────────── + + describe('update()', () => { + it('should update name and description', async () => { + const existing = makeTheme(); + const updated = makeTheme({ name: 'Updated Name', description: 'New desc' }); + repository.findActiveById.mockResolvedValue(existing); + repository.save.mockResolvedValue(updated); + + const result = await service.update('theme-uuid-1', { + name: 'Updated Name', + description: 'New desc', + }); + + expect(result.name).toBe('Updated Name'); + }); + + it('should deep-merge config patch onto existing config', async () => { + const existing = makeTheme(); + repository.findActiveById.mockResolvedValue(existing); + repository.save.mockImplementation(async (t) => t); + + await service.update('theme-uuid-1', { + config: { colors: { primary: '#PATCHED' } } as any, + }); + + expect(repository.save).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + colors: expect.objectContaining({ primary: '#PATCHED' }), + }), + }), + ); + }); + + it('should call unsetDefaultForScope when promoting to default', async () => { + const existing = makeTheme({ isDefault: false }); + repository.findActiveById.mockResolvedValue(existing); + repository.save.mockResolvedValue(makeTheme({ isDefault: true })); + + await service.update('theme-uuid-1', { isDefault: true }); + + expect(repository.unsetDefaultForScope).toHaveBeenCalled(); + }); + + it('should throw NotFoundException when theme not found', async () => { + repository.findActiveById.mockResolvedValue(null); + await expect(service.update('missing', {})).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for read-only themes', async () => { + repository.findActiveById.mockResolvedValue(makeTheme({ isReadOnly: true })); + await expect(service.update('theme-uuid-1', { name: 'Hack' })).rejects.toThrow( + BadRequestException, + ); + }); + }); + + // ─── remove ─────────────────────────────────────────────────────────────── + + describe('remove()', () => { + it('should soft-delete a non-default theme', async () => { + repository.findActiveById.mockResolvedValue(makeTheme({ isDefault: false })); + + await service.remove('theme-uuid-1', 'actor'); + expect(repository.softDelete).toHaveBeenCalledWith('theme-uuid-1', 'actor'); + }); + + it('should throw NotFoundException when theme not found', async () => { + repository.findActiveById.mockResolvedValue(null); + await expect(service.remove('missing')).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException when deleting read-only theme', async () => { + repository.findActiveById.mockResolvedValue(makeTheme({ isReadOnly: true })); + await expect(service.remove('theme-uuid-1')).rejects.toThrow(BadRequestException); + }); + + it('should throw BadRequestException when deleting the default theme', async () => { + repository.findActiveById.mockResolvedValue(makeTheme({ isDefault: true })); + await expect(service.remove('theme-uuid-1')).rejects.toThrow(BadRequestException); + }); + }); + + // ─── applyOverride ──────────────────────────────────────────────────────── + + describe('applyOverride()', () => { + it('should merge override into existing config', async () => { + const existing = makeTheme(); + repository.findActiveById.mockResolvedValue(existing); + repository.save.mockImplementation(async (t) => t); + + const dto: ThemeOverrideDto = { + overrides: { colors: { primary: '#OVERRIDE' } } as any, + }; + const result = await service.applyOverride('theme-uuid-1', dto); + + expect(result.config.colors.primary).toBe('#OVERRIDE'); + // Other values intact + expect(result.config.colors.secondary).toBe(DEFAULT_THEME_CONFIG.colors.secondary); + }); + + it('should throw NotFoundException when theme not found', async () => { + repository.findActiveById.mockResolvedValue(null); + await expect( + service.applyOverride('missing', { overrides: {} as any }), + ).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException for read-only themes', async () => { + repository.findActiveById.mockResolvedValue(makeTheme({ isReadOnly: true })); + await expect( + service.applyOverride('theme-uuid-1', { overrides: {} as any }), + ).rejects.toThrow(BadRequestException); + }); + }); + + // ─── resetToDefault ─────────────────────────────────────────────────────── + + describe('resetToDefault()', () => { + it('should reset config to DEFAULT_THEME_CONFIG when no parent', async () => { + const existing = makeTheme({ + config: { + ...DEFAULT_THEME_CONFIG, + colors: { ...DEFAULT_THEME_CONFIG.colors, primary: '#CUSTOM' }, + }, + }); + repository.findActiveById.mockResolvedValue(existing); + repository.save.mockImplementation(async (t) => t); + + const result = await service.resetToDefault('theme-uuid-1'); + expect(result.config.colors.primary).toBe(DEFAULT_THEME_CONFIG.colors.primary); + }); + + it('should reset to parent config when parentThemeId set', async () => { + const parentConfig = { + ...DEFAULT_THEME_CONFIG, + colors: { ...DEFAULT_THEME_CONFIG.colors, primary: '#PARENT' }, + }; + const existing = makeTheme({ parentThemeId: 'parent-id' }); + const parent = makeTheme({ id: 'parent-id', config: parentConfig }); + + repository.findActiveById + .mockResolvedValueOnce(existing) + .mockResolvedValueOnce(parent); + repository.save.mockImplementation(async (t) => t); + + const result = await service.resetToDefault('theme-uuid-1'); + expect(result.config.colors.primary).toBe('#PARENT'); + }); + }); + + // ─── getCssVariables ────────────────────────────────────────────────────── + + describe('getCssVariables()', () => { + it('should return CSS variables map and string', async () => { + repository.findActiveById.mockResolvedValue(makeTheme()); + + const result = await service.getCssVariables('theme-uuid-1'); + + expect(result.cssVariables).toContain(':root {'); + expect(result.variables).toBeDefined(); + expect(typeof result.variables).toBe('object'); + }); + + it('should include darkVariables when dark mode enabled', async () => { + const theme = makeTheme({ + config: { + ...DEFAULT_THEME_CONFIG, + darkModeEnabled: true, + darkModeColors: { background: '#000' }, + }, + }); + repository.findActiveById.mockResolvedValue(theme); + + const result = await service.getCssVariables('theme-uuid-1'); + expect(result.darkVariables).toBeDefined(); + expect(result.darkVariables!['--color-background']).toBe('#000'); + }); + + it('should throw NotFoundException when theme not found', async () => { + repository.findActiveById.mockResolvedValue(null); + await expect(service.getCssVariables('missing')).rejects.toThrow(NotFoundException); + }); + }); + + // ─── getDefaultTheme ────────────────────────────────────────────────────── + + describe('getDefaultTheme()', () => { + it('should return default theme when found', async () => { + const defaultTheme = makeTheme({ isDefault: true }); + repository.findDefaultForScope.mockResolvedValue(defaultTheme); + + const result = await service.getDefaultTheme(ThemeScope.GLOBAL); + expect(result.isDefault).toBe(true); + }); + + it('should fallback to first active theme when no default set', async () => { + const fallback = makeTheme({ isDefault: false }); + repository.findDefaultForScope.mockResolvedValue(null); + repository.findByScope.mockResolvedValue([fallback]); + + const result = await service.getDefaultTheme(ThemeScope.GLOBAL); + expect(result.id).toBe(fallback.id); + }); + + it('should return virtual default when no themes exist', async () => { + repository.findDefaultForScope.mockResolvedValue(null); + repository.findByScope.mockResolvedValue([]); + + const result = await service.getDefaultTheme(ThemeScope.GLOBAL); + expect(result.id).toBe('default'); + expect(result.isReadOnly).toBe(true); + }); + }); + + // ─── clone ──────────────────────────────────────────────────────────────── + + describe('clone()', () => { + it('should create a new theme based on source config', async () => { + const source = makeTheme({ name: 'Original' }); + const cloned = makeTheme({ id: 'clone-id', name: 'Clone' }); + + repository.findActiveById.mockResolvedValueOnce(source); + repository.create.mockReturnValue(cloned); + repository.save.mockResolvedValue(cloned); + + const result = await service.clone('theme-uuid-1', 'Clone'); + expect(result.id).toBe('clone-id'); + }); + + it('should throw NotFoundException when source theme not found', async () => { + repository.findActiveById.mockResolvedValue(null); + await expect(service.clone('missing', 'Clone')).rejects.toThrow(NotFoundException); + }); + }); +}); diff --git a/apps/api/src/custom-theme/theme.service.ts b/apps/api/src/custom-theme/theme.service.ts new file mode 100644 index 0000000..c1c31c7 --- /dev/null +++ b/apps/api/src/custom-theme/theme.service.ts @@ -0,0 +1,312 @@ +import { + BadRequestException, + ConflictException, + Injectable, + Logger, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import * as deepmerge from 'deepmerge'; +import { Theme, ThemeScope } from '../entities/theme.entity'; +import { + CreateThemeDto, + ThemeCssVariablesResponseDto, + ThemeOverrideDto, + ThemePreviewResponseDto, + ThemeQueryDto, + ThemeResponseDto, + UpdateThemeDto, +} from '../dto/theme.dto'; +import { ThemeRepository } from '../repositories/theme.repository'; +import { DEFAULT_THEME_CONFIG, ThemeConfig } from '../types/theme-config.types'; +import { + generateCssVariables, + generateDarkModeVariables, + generateFullCssBundle, +} from '../utils/css-generator.util'; + +@Injectable() +export class ThemeService { + private readonly logger = new Logger(ThemeService.name); + + constructor( + @InjectRepository(ThemeRepository) + private readonly themeRepository: ThemeRepository, + ) {} + + // ─── CRUD ──────────────────────────────────────────────────────────────── + + async create(dto: CreateThemeDto, actorId?: string): Promise { + const scope = dto.scope ?? ThemeScope.GLOBAL; + + // Validate owner required for non-global scopes + if (scope !== ThemeScope.GLOBAL && !dto.scopeOwnerId) { + throw new BadRequestException( + `scopeOwnerId is required for scope "${scope}"`, + ); + } + + // Resolve parent config if inheritance is requested + let resolvedConfig = DEFAULT_THEME_CONFIG; + if (dto.parentThemeId) { + const parent = await this.themeRepository.findActiveById(dto.parentThemeId); + if (!parent) { + throw new NotFoundException(`Parent theme "${dto.parentThemeId}" not found`); + } + resolvedConfig = parent.config; + } + + // Deep-merge supplied config overrides onto the resolved base + const finalConfig: ThemeConfig = dto.config + ? deepmerge(resolvedConfig, dto.config as Partial, { + arrayMerge: (_, src) => src, + }) + : resolvedConfig; + + // If this theme is being set as default, clear existing defaults first + if (dto.isDefault) { + await this.themeRepository.unsetDefaultForScope(scope, dto.scopeOwnerId); + } + + const theme = this.themeRepository.create({ + name: dto.name, + description: dto.description ?? null, + scope, + scopeOwnerId: dto.scopeOwnerId ?? null, + parentThemeId: dto.parentThemeId ?? null, + config: finalConfig, + isDefault: dto.isDefault ?? false, + isActive: true, + isReadOnly: false, + createdBy: actorId ?? null, + updatedBy: actorId ?? null, + }); + + try { + const saved = await this.themeRepository.save(theme); + this.logger.log(`Theme created: ${saved.id} (${saved.name})`); + return this.toResponseDto(saved); + } catch (err: any) { + if (err?.code === '23505') { + throw new ConflictException( + `A theme named "${dto.name}" already exists for scope "${scope}"`, + ); + } + throw err; + } + } + + async findAll(query: ThemeQueryDto): Promise { + const themes = await this.themeRepository.findByScope( + query.scope ?? ThemeScope.GLOBAL, + query.scopeOwnerId, + ); + + return themes + .filter((t) => { + if (query.isActive !== undefined && t.isActive !== query.isActive) return false; + if (query.isDefault !== undefined && t.isDefault !== query.isDefault) return false; + return true; + }) + .map(this.toResponseDto); + } + + async findOne(id: string): Promise { + const theme = await this.themeRepository.findActiveById(id); + if (!theme) throw new NotFoundException(`Theme "${id}" not found`); + return this.toResponseDto(theme); + } + + async update(id: string, dto: UpdateThemeDto, actorId?: string): Promise { + const theme = await this.themeRepository.findActiveById(id); + if (!theme) throw new NotFoundException(`Theme "${id}" not found`); + + if (theme.isReadOnly) { + throw new BadRequestException(`Theme "${id}" is read-only and cannot be modified`); + } + + if (dto.isDefault && !theme.isDefault) { + await this.themeRepository.unsetDefaultForScope( + theme.scope, + theme.scopeOwnerId ?? undefined, + ); + } + + if (dto.config) { + theme.config = deepmerge(theme.config, dto.config as Partial, { + arrayMerge: (_, src) => src, + }); + } + + if (dto.name !== undefined) theme.name = dto.name; + if (dto.description !== undefined) theme.description = dto.description ?? null; + if (dto.isDefault !== undefined) theme.isDefault = dto.isDefault; + theme.updatedBy = actorId ?? null; + + const saved = await this.themeRepository.save(theme); + this.logger.log(`Theme updated: ${saved.id}`); + return this.toResponseDto(saved); + } + + async remove(id: string, actorId?: string): Promise { + const theme = await this.themeRepository.findActiveById(id); + if (!theme) throw new NotFoundException(`Theme "${id}" not found`); + + if (theme.isReadOnly) { + throw new BadRequestException(`Theme "${id}" is read-only and cannot be deleted`); + } + + if (theme.isDefault) { + throw new BadRequestException( + 'Cannot delete the default theme. Set another theme as default first.', + ); + } + + await this.themeRepository.softDelete(id, actorId); + this.logger.log(`Theme soft-deleted: ${id}`); + } + + // ─── Override / Merge ───────────────────────────────────────────────────── + + async applyOverride( + id: string, + dto: ThemeOverrideDto, + actorId?: string, + ): Promise { + const theme = await this.themeRepository.findActiveById(id); + if (!theme) throw new NotFoundException(`Theme "${id}" not found`); + + if (theme.isReadOnly) { + throw new BadRequestException(`Theme "${id}" is read-only`); + } + + theme.config = deepmerge( + theme.config, + dto.overrides as Partial, + { arrayMerge: (_, src) => src }, + ); + theme.updatedBy = actorId ?? null; + + const saved = await this.themeRepository.save(theme); + this.logger.log(`Theme override applied: ${id}`); + return this.toResponseDto(saved); + } + + async resetToDefault(id: string, actorId?: string): Promise { + const theme = await this.themeRepository.findActiveById(id); + if (!theme) throw new NotFoundException(`Theme "${id}" not found`); + + if (theme.isReadOnly) { + throw new BadRequestException(`Theme "${id}" is read-only`); + } + + let baseConfig = DEFAULT_THEME_CONFIG; + if (theme.parentThemeId) { + const parent = await this.themeRepository.findActiveById(theme.parentThemeId); + if (parent) baseConfig = parent.config; + } + + theme.config = baseConfig; + theme.updatedBy = actorId ?? null; + const saved = await this.themeRepository.save(theme); + return this.toResponseDto(saved); + } + + // ─── CSS Generation ─────────────────────────────────────────────────────── + + async getCssVariables(id: string): Promise { + const theme = await this.themeRepository.findActiveById(id); + if (!theme) throw new NotFoundException(`Theme "${id}" not found`); + + const variables = generateCssVariables(theme.config); + const darkVariables = generateDarkModeVariables(theme.config); + const cssVariables = generateFullCssBundle(theme.config); + + return { cssVariables, variables, darkVariables }; + } + + async getPreview(id: string): Promise { + const themeDto = await this.findOne(id); + const theme = await this.themeRepository.findActiveById(id); + + const variables = generateCssVariables(theme!.config); + const darkVariables = generateDarkModeVariables(theme!.config); + const cssVariables = generateFullCssBundle(theme!.config); + + return { + theme: themeDto, + css: { cssVariables, variables, darkVariables }, + }; + } + + async getDefaultTheme(scope: ThemeScope, scopeOwnerId?: string): Promise { + const theme = await this.themeRepository.findDefaultForScope(scope, scopeOwnerId); + + // Fallback: return any active theme for the scope + if (!theme) { + const [fallback] = await this.themeRepository.findByScope(scope, scopeOwnerId); + if (!fallback) { + // Last resort: synthesise a virtual default + return { + id: 'default', + name: 'Default Theme', + description: 'System default theme', + scope, + scopeOwnerId: scopeOwnerId ?? null, + config: DEFAULT_THEME_CONFIG, + isDefault: true, + isActive: true, + isReadOnly: true, + parentThemeId: null, + createdAt: new Date(), + updatedAt: new Date(), + }; + } + return this.toResponseDto(fallback); + } + + return this.toResponseDto(theme); + } + + // ─── Clone ──────────────────────────────────────────────────────────────── + + async clone( + id: string, + newName: string, + actorId?: string, + ): Promise { + const source = await this.themeRepository.findActiveById(id); + if (!source) throw new NotFoundException(`Theme "${id}" not found`); + + return this.create( + { + name: newName, + description: `Cloned from: ${source.name}`, + scope: source.scope, + scopeOwnerId: source.scopeOwnerId ?? undefined, + config: source.config as any, + isDefault: false, + }, + actorId, + ); + } + + // ─── Helpers ────────────────────────────────────────────────────────────── + + private toResponseDto(theme: Theme): ThemeResponseDto { + return { + id: theme.id, + name: theme.name, + description: theme.description, + scope: theme.scope, + scopeOwnerId: theme.scopeOwnerId, + config: theme.config, + isDefault: theme.isDefault, + isActive: theme.isActive, + isReadOnly: theme.isReadOnly, + parentThemeId: theme.parentThemeId, + createdAt: theme.createdAt, + updatedAt: theme.updatedAt, + }; + } +}