Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions backend/migrations/004_theme_storage.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
-- Theme Storage for SubTrackr Merchant Branding
-- Adds merchant theme configuration support

CREATE TABLE IF NOT EXISTS merchant_themes (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
config_json JSONB NOT NULL DEFAULT '{}'::jsonb,
is_active BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_merchant_themes_merchant_id ON merchant_themes(merchant_id);
CREATE INDEX IF NOT EXISTS idx_merchant_themes_active ON merchant_themes(merchant_id, is_active) WHERE is_active = true;

COMMENT ON TABLE merchant_themes IS 'Merchant-branded theme configurations for white-label subscription UI';
COMMENT ON COLUMN merchant_themes.config_json IS 'Full theme config: colors, fonts, logo URLs, CSS variables, accessibility metadata';

CREATE TABLE IF NOT EXISTS theme_variant_pairs (
id TEXT PRIMARY KEY,
merchant_id TEXT NOT NULL REFERENCES merchants(id) ON DELETE CASCADE,
name TEXT NOT NULL,
light_theme_id TEXT NOT NULL REFERENCES merchant_themes(id) ON DELETE CASCADE,
dark_theme_id TEXT NOT NULL REFERENCES merchant_themes(id) ON DELETE CASCADE,
shared_config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX IF NOT EXISTS idx_theme_variant_pairs_merchant_id ON theme_variant_pairs(merchant_id);

COMMENT ON TABLE theme_variant_pairs IS 'Light/dark theme variant pairs for merchant brand families';

CREATE OR REPLACE FUNCTION ensure_single_active_theme()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.is_active THEN
UPDATE merchant_themes
SET is_active = false, updated_at = now()
WHERE merchant_id = NEW.merchant_id AND id != NEW.id AND is_active = true;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

DROP TRIGGER IF EXISTS trg_single_active_theme ON merchant_themes;
CREATE TRIGGER trg_single_active_theme
BEFORE INSERT OR UPDATE OF is_active
ON merchant_themes
FOR EACH ROW
WHEN (NEW.is_active = true)
EXECUTE FUNCTION ensure_single_active_theme();
3 changes: 2 additions & 1 deletion backend/server/createApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import express, { type Express } from 'express';
import { cacheHeadersMiddleware } from '../shared/middleware';
import { createPublicApiRouter } from '../subscription/router/publicApiRouter';
import { createPublicApiRouter, createThemeRouter } from '../subscription/router';
import { API_VERSION_HEADER, API_VERSION_VALUE } from '../services/shared/apiResponse';

export interface CreateApiServerOptions {
Expand All @@ -33,6 +33,7 @@ export function createApiServer(options: CreateApiServerOptions = {}): Express {

app.use(cacheHeadersMiddleware());
app.use(createPublicApiRouter());
app.use('/api/v1/merchant', createThemeRouter());

app.use((_req, res) => {
res.status(404).json({ success: false, error: { code: 'NOT_FOUND', message: 'Not found' } });
Expand Down
135 changes: 135 additions & 0 deletions backend/subscription/controller/themeController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import type { Request, Response } from 'express';
import { fail, success } from '../../services/shared/apiResponse';
import { extractRequestId } from './index';

interface ThemeRecord {
id: string;
merchantId: string;
name: string;
config: Record<string, unknown>;
isActive: boolean;
createdAt: string;
updatedAt: string;
}

const themeStore = new Map<string, ThemeRecord>();

export function getThemes(req: Request, res: Response): void {
const merchantId = (req.headers['x-merchant-id'] as string) || 'default';
const merchantThemes = Array.from(themeStore.values()).filter(
(t) => t.merchantId === merchantId,
);
res.status(200).json(
success(merchantThemes, {
requestId: extractRequestId(req) || 'unknown',
}),
);
}

export function getThemeById(req: Request, res: Response): void {
const theme = themeStore.get(req.params.id);
if (!theme) {
res.status(404).json(
fail('THEME_NOT_FOUND', `Theme "${req.params.id}" not found`, extractRequestId(req)),
);
return;
}
res.status(200).json(
success(theme, { requestId: extractRequestId(req) || 'unknown' }),
);
}

export function createTheme(req: Request, res: Response): void {
const merchantId = (req.headers['x-merchant-id'] as string) || 'default';
const { id, name, config } = req.body;

if (!id || !name || !config) {
res.status(400).json(
fail('BAD_REQUEST', 'Missing required fields: id, name, config', extractRequestId(req)),
);
return;
}

const now = new Date().toISOString();
const record: ThemeRecord = {
id,
merchantId,
name,
config,
isActive: false,
createdAt: now,
updatedAt: now,
};

themeStore.set(id, record);
res.status(201).json(
success(record, { requestId: extractRequestId(req) || 'unknown' }),
);
}

export function updateTheme(req: Request, res: Response): void {
const existing = themeStore.get(req.params.id);
if (!existing) {
res.status(404).json(
fail('THEME_NOT_FOUND', `Theme "${req.params.id}" not found`, extractRequestId(req)),
);
return;
}

const { name, config, isActive } = req.body;

if (name !== undefined) existing.name = name;
if (config !== undefined) existing.config = config;
if (isActive !== undefined) {
if (isActive) {
for (const [, t] of themeStore) {
if (t.merchantId === existing.merchantId) t.isActive = false;
}
}
existing.isActive = isActive;
}
existing.updatedAt = new Date().toISOString();

themeStore.set(req.params.id, existing);
res.status(200).json(
success(existing, { requestId: extractRequestId(req) || 'unknown' }),
);
}

export function deleteTheme(req: Request, res: Response): void {
const existing = themeStore.get(req.params.id);
if (!existing) {
res.status(404).json(
fail('THEME_NOT_FOUND', `Theme "${req.params.id}" not found`, extractRequestId(req)),
);
return;
}

themeStore.delete(req.params.id);
res.status(200).json(
success({ deleted: true }, { requestId: extractRequestId(req) || 'unknown' }),
);
}

export function activateTheme(req: Request, res: Response): void {
const merchantId = (req.headers['x-merchant-id'] as string) || 'default';
const theme = themeStore.get(req.params.id);

if (!theme) {
res.status(404).json(
fail('THEME_NOT_FOUND', `Theme "${req.params.id}" not found`, extractRequestId(req)),
);
return;
}

for (const [, t] of themeStore) {
if (t.merchantId === merchantId) t.isActive = false;
}

theme.isActive = true;
theme.updatedAt = new Date().toISOString();

res.status(200).json(
success(theme, { requestId: extractRequestId(req) || 'unknown' }),
);
}
1 change: 1 addition & 0 deletions backend/subscription/router/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export { createPublicApiRouter } from './publicApiRouter';
export { createThemeRouter } from './themeRouter';
73 changes: 73 additions & 0 deletions backend/subscription/router/themeRouter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { Router, type Request, type Response, type NextFunction } from 'express';
import {
getThemes,
getThemeById,
createTheme,
updateTheme,
deleteTheme,
activateTheme,
} from '../controller/themeController';

type AsyncHandler = (req: Request, res: Response, next: NextFunction) => Promise<void>;

function asyncHandler(fn: AsyncHandler) {
return (req: Request, res: Response, next: NextFunction): void => {
fn(req, res, next).catch(next);
};
}

export function createThemeRouter(): Router {
const router = Router();

router.get(
'/themes',
asyncHandler(async (req, res) => {
getThemes(req, res);
}),
);

router.get(
'/themes/:id',
asyncHandler(async (req, res) => {
getThemeById(req, res);
}),
);

router.post(
'/themes',
asyncHandler(async (req, res) => {
createTheme(req, res);
}),
);

router.patch(
'/themes/:id',
asyncHandler(async (req, res) => {
updateTheme(req, res);
}),
);

router.delete(
'/themes/:id',
asyncHandler(async (req, res) => {
deleteTheme(req, res);
}),
);

router.post(
'/themes/:id/activate',
asyncHandler(async (req, res) => {
activateTheme(req, res);
}),
);

router.get(
'/themes/export/:id',
asyncHandler(async (req, res) => {
const { getThemeById: findTheme } = await import('../controller/themeController');
getThemeById(req, res);
}),
);

return router;
}
66 changes: 66 additions & 0 deletions src/__tests__/themeService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
jest.mock('@react-native-async-storage/async-storage', () => {
const store = new Map<string, string>();
return {
setItem: jest.fn((k: string, v: string) => {
store.set(k, v);
return Promise.resolve();
}),
getItem: jest.fn((k: string) => Promise.resolve(store.get(k) ?? null)),
removeItem: jest.fn((k: string) => {
store.delete(k);
return Promise.resolve();
}),
};
});

import { themeService } from '../services/themeService';
import { darkTheme } from '../theme/themes';

describe('themeService', () => {
it('fetchThemes returns empty array when no themes saved', async () => {
const result = await themeService.fetchThemes();
expect(result.success).toBe(true);
expect(result.data).toEqual([]);
});

it('saveTheme stores a theme record', async () => {
const result = await themeService.saveTheme(darkTheme);
expect(result.success).toBe(true);
expect(result.data?.id).toBe(darkTheme.id);
expect(result.data?.config.colors.primary).toBe(darkTheme.colors.primary);
});

it('fetchThemes returns saved themes', async () => {
await themeService.saveTheme(darkTheme);
const result = await themeService.fetchThemes();
expect(result.success).toBe(true);
expect(result.data?.length).toBe(1);
});

it('deleteTheme removes a theme', async () => {
await themeService.saveTheme(darkTheme);
await themeService.deleteTheme(darkTheme.id);
const result = await themeService.fetchThemes();
expect(result.data?.length).toBe(0);
});

it('exportTheme produces valid export data', async () => {
const exported = await themeService.exportTheme(darkTheme);
expect(exported.version).toBe('1.0.0');
expect(exported.theme.dark).toBeDefined();
expect(exported.theme.shared.id).toBe(darkTheme.id);
});

it('importTheme loads a theme from export data', async () => {
const exported = await themeService.exportTheme(darkTheme);
const result = await themeService.importTheme(exported);
expect(result.success).toBe(true);
expect(result.data?.id).toBe(`${darkTheme.id}-dark`);
});

it('syncThemesToRemote saves multiple themes', async () => {
const result = await themeService.syncThemesToRemote([darkTheme]);
expect(result.success).toBe(true);
expect(result.data?.synced).toBe(1);
});
});
Loading