Skip to content

Commit 865c6c5

Browse files
committed
Add runtime modules configuration
1 parent c6cd335 commit 865c6c5

24 files changed

Lines changed: 379 additions & 45 deletions

backend/drizzle/0010_modules.sql

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
ALTER TABLE settings ADD COLUMN modules text;
2+
UPDATE settings
3+
SET modules = '{"ordering":{"enabled":' || CASE selection_enabled WHEN 1 THEN 'true' ELSE 'false' END || ',"mode":"summary"},"ai":{"enabled":' || CASE ai_chat_enabled WHEN 1 THEN 'true' ELSE 'false' END || ',"voiceEnabled":' || CASE WHEN ai_chat_enabled = 1 AND ai_voice_enabled = 1 THEN 'true' ELSE 'false' END || '},"analytics":{"enabled":true}}'
4+
WHERE modules IS NULL;

backend/src/__tests__/admin-crud.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,53 @@ describe('PUT /admin/settings', () => {
6868
});
6969
});
7070

71+
describe('GET /admin/modules', () => {
72+
it('returns normalized module config migrated from legacy settings', async () => {
73+
const { env, headers } = await adminEnv();
74+
const res = await testRequest('/admin/modules', { headers, env });
75+
expect(res.status).toBe(200);
76+
const body = await res.json() as { modules: { ordering: { enabled: boolean; mode: string }; ai: { enabled: boolean; voiceEnabled: boolean }; analytics: { enabled: boolean } } };
77+
expect(body.modules).toEqual({
78+
ordering: { enabled: false, mode: 'summary' },
79+
ai: { enabled: false, voiceEnabled: false },
80+
analytics: { enabled: true },
81+
});
82+
});
83+
});
84+
85+
describe('PUT /admin/modules', () => {
86+
it('updates module config', async () => {
87+
const { db, env, headers } = await adminEnv();
88+
const modules = {
89+
ordering: { enabled: true, mode: 'summary' },
90+
ai: { enabled: true, voiceEnabled: false },
91+
analytics: { enabled: false },
92+
};
93+
const res = await testRequest('/admin/modules', { method: 'PUT', headers, env, body: modules });
94+
expect(res.status).toBe(200);
95+
const row = db.raw.prepare('SELECT modules FROM settings WHERE id = 1').get() as { modules: string };
96+
expect(JSON.parse(row.modules)).toEqual(modules);
97+
});
98+
});
99+
100+
describe('GET /admin/analytics', () => {
101+
it('returns 404 when the analytics module is disabled', async () => {
102+
const { env, headers } = await adminEnv();
103+
const res = await testRequest('/admin/analytics', { headers, env });
104+
expect(res.status).toBe(200);
105+
106+
await testRequest('/admin/modules', {
107+
method: 'PUT',
108+
headers,
109+
env,
110+
body: { analytics: { enabled: false } },
111+
});
112+
113+
const disabled = await testRequest('/admin/analytics', { headers, env });
114+
expect(disabled.status).toBe(404);
115+
});
116+
});
117+
71118
describe('PUT /admin/publication', () => {
72119
it('toggles publication state', async () => {
73120
const { db, env, headers } = await adminEnv();

backend/src/__tests__/catalog.test.ts

Lines changed: 25 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { testRequest } from './helpers';
33
import { createTestDb, makeDbEnv, seedSettings, seedMenu, seedCategory, seedEntry, seedMembership, signTestJwt, installJwksMock } from './helpers/db';
44

55
type CatalogBody = {
6-
restaurant: { name: string; features?: { aiChat?: boolean; aiVoice?: boolean; selection?: boolean } };
6+
restaurant: { name: string; features?: { aiChat?: boolean; aiVoice?: boolean; analytics?: boolean; ordering?: { enabled: boolean; mode: string } } };
77
menus: Array<{ id: string; code: string; title: string; published: boolean }>;
88
categories: Array<{ id: string; entries: Array<{ name: string; price: number; menuIds: string[]; hidden: boolean }> }>;
99
};
@@ -34,7 +34,7 @@ describe('GET /catalog', () => {
3434
expect(res.status).toBe(200);
3535
const body = await res.json() as CatalogBody;
3636
expect(body.restaurant.name).toBe('Trattoria Test');
37-
expect(body.restaurant.features?.selection).toBe(false);
37+
expect(body.restaurant.features?.ordering).toEqual({ enabled: false, mode: 'summary' });
3838
expect(body.menus).toHaveLength(1);
3939
expect(body.menus[0].code).toBe('food');
4040
expect(body.menus[0].published).toBe(true);
@@ -100,25 +100,26 @@ describe('GET /catalog', () => {
100100
});
101101
});
102102

103-
describe('menu selection feature flag', () => {
104-
it('surfaces enabled selection in the catalog response', async () => {
103+
describe('module feature flags', () => {
104+
it('surfaces ordering config in the catalog response', async () => {
105105
const db = createTestDb();
106-
seedSettings(db, { selection_enabled: 1 });
106+
seedSettings(db, { modules: JSON.stringify({ ordering: { enabled: true, mode: 'summary' } }) });
107107
const res = await testRequest('/catalog', { env: makeDbEnv(db) });
108108
expect(res.status).toBe(200);
109109
const body = await res.json() as CatalogBody;
110-
expect(body.restaurant.features?.selection).toBe(true);
110+
expect(body.restaurant.features?.ordering).toEqual({ enabled: true, mode: 'summary' });
111+
expect(body.restaurant.features).not.toHaveProperty('selection');
111112
});
112113

113114
it('surfaces voice dictation only when Tony chat is enabled too', async () => {
114115
const db = createTestDb();
115-
seedSettings(db, { ai_chat_enabled: 0, ai_voice_enabled: 1 });
116+
seedSettings(db, { modules: JSON.stringify({ ai: { enabled: false, voiceEnabled: true } }) });
116117
let res = await testRequest('/catalog', { env: makeDbEnv(db) });
117118
let body = await res.json() as CatalogBody;
118119
expect(body.restaurant.features?.aiChat).toBe(false);
119120
expect(body.restaurant.features?.aiVoice).toBe(false);
120121

121-
seedSettings(db, { ai_chat_enabled: 1, ai_voice_enabled: 1 });
122+
seedSettings(db, { modules: JSON.stringify({ ai: { enabled: true, voiceEnabled: true } }) });
122123
res = await testRequest('/catalog', { env: makeDbEnv(db) });
123124
body = await res.json() as CatalogBody;
124125
expect(body.restaurant.features?.aiChat).toBe(true);
@@ -143,6 +144,22 @@ describe('POST /catalog/view', () => {
143144
expect(count.n).toBe(1);
144145
});
145146

147+
it('returns 404 without recording views when analytics is disabled', async () => {
148+
const db = createTestDb();
149+
seedSettings(db, { modules: JSON.stringify({ analytics: { enabled: false } }) });
150+
seedMenu(db, 'menu-1');
151+
seedCategory(db, 'cat-1');
152+
seedEntry(db, 'entry-1', 'cat-1');
153+
const res = await testRequest('/catalog/view', {
154+
method: 'POST',
155+
body: { entryId: 'entry-1' },
156+
env: makeDbEnv(db),
157+
});
158+
expect(res.status).toBe(404);
159+
const count = db.raw.prepare('SELECT COUNT(*) AS n FROM catalog_views').get() as { n: number };
160+
expect(count.n).toBe(0);
161+
});
162+
146163
it('silently drops views for unknown entries (no row inserted)', async () => {
147164
const db = createTestDb();
148165
seedSettings(db);

backend/src/db/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const settings = sqliteTable('settings', {
5959
aiChatEnabled: integer('ai_chat_enabled', { mode: 'boolean' }).default(false).notNull(),
6060
aiVoiceEnabled: integer('ai_voice_enabled', { mode: 'boolean' }).default(false).notNull(),
6161
selectionEnabled: integer('selection_enabled', { mode: 'boolean' }).default(false).notNull(),
62+
modules: jsonColumn<Record<string, unknown> | null>('modules'),
6263
primaryLocale: text('primary_locale').default('it').notNull(),
6364
enabledLocales: jsonColumn<string[] | null>('enabled_locales'),
6465
disabledLocales: jsonColumn<string[] | null>('disabled_locales'),

backend/src/routes/admin.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ import {
2323
UpdateMenuBodySchema,
2424
CreateLabelBodySchema,
2525
UpdateLabelBodySchema,
26+
ModulesConfigSchema,
27+
normalizeModulesConfig,
2628
} from '@menu/schemas';
2729
import type { AppBindings } from '../types';
2830
import type { DbInstance } from '../db';
@@ -86,6 +88,54 @@ admin.get('/settings', ...base, async (c) => {
8688
});
8789
});
8890

91+
// ── Modules ──────────────────────────────────────────────────────────
92+
93+
admin.get('/modules', ...base, async (c) => {
94+
const [row] = await c.get('db')
95+
.select({
96+
modules: schema.settings.modules,
97+
selectionEnabled: schema.settings.selectionEnabled,
98+
aiChatEnabled: schema.settings.aiChatEnabled,
99+
aiVoiceEnabled: schema.settings.aiVoiceEnabled,
100+
})
101+
.from(schema.settings)
102+
.where(eq(schema.settings.id, 1));
103+
104+
if (!row) return c.json({ error: 'Not found' }, 404);
105+
return c.json({ modules: normalizeModulesConfig(row.modules, row) });
106+
});
107+
108+
admin.put('/modules', ...base, async (c) => {
109+
const body = await parseBody(c, ModulesConfigSchema);
110+
if (body instanceof Response) return body;
111+
112+
const [row] = await c.get('db')
113+
.select({
114+
modules: schema.settings.modules,
115+
selectionEnabled: schema.settings.selectionEnabled,
116+
aiChatEnabled: schema.settings.aiChatEnabled,
117+
aiVoiceEnabled: schema.settings.aiVoiceEnabled,
118+
})
119+
.from(schema.settings)
120+
.where(eq(schema.settings.id, 1));
121+
if (!row) return c.json({ error: 'Not found' }, 404);
122+
123+
const modules = normalizeModulesConfig({ ...normalizeModulesConfig(row.modules, row), ...body });
124+
await c.get('db')
125+
.update(schema.settings)
126+
.set({
127+
modules,
128+
selectionEnabled: modules.ordering.enabled,
129+
aiChatEnabled: modules.ai.enabled,
130+
aiVoiceEnabled: modules.ai.enabled && modules.ai.voiceEnabled,
131+
updatedAt: Date.now(),
132+
})
133+
.where(eq(schema.settings.id, 1));
134+
135+
await refreshPublicCatalog(c);
136+
return c.json({ ok: true, modules });
137+
});
138+
89139
admin.put('/settings', ...base, async (c) => {
90140
const body = await parseBody(c, UpdateSettingsBodySchema);
91141
if (body instanceof Response) return body;
@@ -101,6 +151,20 @@ admin.put('/settings', ...base, async (c) => {
101151
if (body.aiChatEnabled !== undefined) updates.aiChatEnabled = body.aiChatEnabled;
102152
if (body.aiVoiceEnabled !== undefined) updates.aiVoiceEnabled = body.aiChatEnabled === false ? false : body.aiVoiceEnabled;
103153
if (body.selectionEnabled !== undefined) updates.selectionEnabled = body.selectionEnabled;
154+
if (body.aiChatEnabled !== undefined || body.aiVoiceEnabled !== undefined || body.selectionEnabled !== undefined) {
155+
const [row] = await c.get('db')
156+
.select({ modules: schema.settings.modules, selectionEnabled: schema.settings.selectionEnabled, aiChatEnabled: schema.settings.aiChatEnabled, aiVoiceEnabled: schema.settings.aiVoiceEnabled })
157+
.from(schema.settings)
158+
.where(eq(schema.settings.id, 1));
159+
const modules = normalizeModulesConfig(row?.modules, row);
160+
if (body.selectionEnabled !== undefined) modules.ordering.enabled = body.selectionEnabled;
161+
if (body.aiChatEnabled !== undefined) {
162+
modules.ai.enabled = body.aiChatEnabled;
163+
if (!body.aiChatEnabled) modules.ai.voiceEnabled = false;
164+
}
165+
if (body.aiVoiceEnabled !== undefined) modules.ai.voiceEnabled = modules.ai.enabled && body.aiVoiceEnabled;
166+
updates.modules = modules;
167+
}
104168
if (body.primaryLocale !== undefined) updates.primaryLocale = body.primaryLocale;
105169
if (body.enabledLocales !== undefined) updates.enabledLocales = body.enabledLocales;
106170
if (body.disabledLocales !== undefined) updates.disabledLocales = body.disabledLocales;
@@ -753,6 +817,15 @@ admin.delete('/locale-flag/:code', ...base, async (c) => {
753817

754818
admin.get('/analytics', ...base, async (c) => {
755819
const db = c.get('db');
820+
const [settingsRow] = await db
821+
.select({ modules: schema.settings.modules })
822+
.from(schema.settings)
823+
.where(eq(schema.settings.id, 1))
824+
.limit(1);
825+
if (!normalizeModulesConfig(settingsRow?.modules).analytics.enabled) {
826+
return c.json({ error: 'Not Found' }, 404);
827+
}
828+
756829

757830
const periodParam = c.req.query('period') ?? '7d';
758831
const ms = periodToMs(periodParam);

backend/src/routes/catalog.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { eq, asc } from 'drizzle-orm';
33
import { requireDb } from '../middleware/db';
44
import { requireAuth } from '../middleware/auth';
55
import { attachDb, requireAdmin } from '../middleware/admin-guard';
6-
import { RecordViewBodySchema } from '@menu/schemas';
6+
import { RecordViewBodySchema, normalizeModulesConfig } from '@menu/schemas';
77
import type { CatalogResponse } from '@menu/schemas';
88
import * as schema from '../db/schema';
99
import type { AppBindings, Env } from '../types';
@@ -65,6 +65,15 @@ export const catalogRoutes = new Hono<AppBindings>()
6565
}
6666
const body = result.data;
6767

68+
const [settingsRow] = await db
69+
.select({ modules: schema.settings.modules })
70+
.from(schema.settings)
71+
.where(eq(schema.settings.id, 1))
72+
.limit(1);
73+
if (!normalizeModulesConfig(settingsRow?.modules).analytics.enabled) {
74+
return c.json({ error: 'Not Found' }, 404);
75+
}
76+
6877
const ip = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || 'unknown';
6978
const now = new Date();
7079
const dateBucket = now.getUTCFullYear() * 10000 + (now.getUTCMonth() + 1) * 100 + now.getUTCDate();
@@ -135,7 +144,7 @@ export const catalogRoutes = new Hono<AppBindings>()
135144
import type { DbInstance } from '../db';
136145

137146
const CATALOG_CACHE_CONTROL = 'public, max-age=60, stale-while-revalidate=300';
138-
const PUBLIC_CATALOG_VERSION = 'public-v3';
147+
const PUBLIC_CATALOG_VERSION = 'public-v4';
139148

140149
type BuildCatalogOptions = {
141150
/** Public responses must hide drafts/unpublished menus. */
@@ -363,6 +372,7 @@ export async function buildCatalogFromDb(
363372
entriesByCategory.set(entry.categoryId, list);
364373
}
365374

375+
const modules = normalizeModulesConfig(restaurant.modules, restaurant);
366376
return {
367377
restaurant: {
368378
id: 'singleton',
@@ -374,9 +384,10 @@ export async function buildCatalogFromDb(
374384
socials: restaurant.socials,
375385
openingSchedule: restaurant.openingSchedule,
376386
features: {
377-
aiChat: restaurant.aiChatEnabled,
378-
aiVoice: restaurant.aiChatEnabled && restaurant.aiVoiceEnabled,
379-
selection: restaurant.selectionEnabled,
387+
aiChat: modules.ai.enabled,
388+
aiVoice: modules.ai.enabled && modules.ai.voiceEnabled,
389+
analytics: modules.analytics.enabled,
390+
ordering: modules.ordering,
380391
primaryLocale: restaurant.primaryLocale,
381392
enabledLocales: restaurant.enabledLocales,
382393
disabledLocales: restaurant.disabledLocales,

packages/schemas/src/catalog.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { z } from 'zod';
22
import { I18nMapSchema } from './common.js';
33
import { RestaurantThemeSchema, RestaurantInfoSchema, RestaurantSocialsSchema, OpeningScheduleSchema } from './restaurant.js';
44
import { VariantSelectionSchema, ExtraOptionSchema } from './menu.js';
5+
import { OrderingModuleConfigSchema } from './modules.js';
56

67
export const LABEL_COLORS = ['primary', 'green', 'amber', 'red', 'gray'] as const;
78
export const LabelColorSchema = z.enum(LABEL_COLORS);
@@ -122,7 +123,8 @@ export const CatalogRestaurantSchema = z.object({
122123
features: z.object({
123124
aiChat: z.boolean(),
124125
aiVoice: z.boolean(),
125-
selection: z.boolean(),
126+
analytics: z.boolean().optional(),
127+
ordering: OrderingModuleConfigSchema.optional(),
126128
primaryLocale: z.string().optional(),
127129
enabledLocales: z.array(z.string()).nullable().optional(),
128130
disabledLocales: z.array(z.string()).nullable().optional(),

packages/schemas/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './menu.js';
44
export * from './catalog.js';
55
export * from './admin.js';
66
export * from './responses.js';
7+
export * from './modules.js';

packages/schemas/src/modules.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { z } from 'zod';
2+
3+
export const OrderingModeSchema = z.enum(['summary', 'send']);
4+
export type OrderingMode = z.infer<typeof OrderingModeSchema>;
5+
6+
export const OrderingModuleConfigSchema = z.object({
7+
enabled: z.boolean(),
8+
mode: OrderingModeSchema,
9+
});
10+
export type OrderingModuleConfig = z.infer<typeof OrderingModuleConfigSchema>;
11+
12+
export const AiModuleConfigSchema = z.object({
13+
enabled: z.boolean(),
14+
voiceEnabled: z.boolean(),
15+
});
16+
export type AiModuleConfig = z.infer<typeof AiModuleConfigSchema>;
17+
18+
export const AnalyticsModuleConfigSchema = z.object({
19+
enabled: z.boolean(),
20+
});
21+
export type AnalyticsModuleConfig = z.infer<typeof AnalyticsModuleConfigSchema>;
22+
23+
export const ModulesConfigSchema = z.object({
24+
ordering: OrderingModuleConfigSchema.optional(),
25+
ai: AiModuleConfigSchema.optional(),
26+
analytics: AnalyticsModuleConfigSchema.optional(),
27+
}).strict();
28+
export type ModulesConfig = z.infer<typeof ModulesConfigSchema>;
29+
30+
export const NormalizedModulesConfigSchema = z.object({
31+
ordering: OrderingModuleConfigSchema,
32+
ai: AiModuleConfigSchema,
33+
analytics: AnalyticsModuleConfigSchema,
34+
});
35+
export type NormalizedModulesConfig = z.infer<typeof NormalizedModulesConfigSchema>;
36+
37+
export const DEFAULT_MODULES_CONFIG: NormalizedModulesConfig = {
38+
ordering: { enabled: false, mode: 'summary' },
39+
ai: { enabled: false, voiceEnabled: false },
40+
analytics: { enabled: true },
41+
};
42+
43+
export function normalizeModulesConfig(
44+
modules: unknown,
45+
legacy: { selectionEnabled?: boolean | null; aiChatEnabled?: boolean | null; aiVoiceEnabled?: boolean | null } = {},
46+
): NormalizedModulesConfig {
47+
const parsed = ModulesConfigSchema.safeParse(modules ?? {});
48+
const config = parsed.success ? parsed.data : {};
49+
const aiEnabled = legacy.aiChatEnabled ?? DEFAULT_MODULES_CONFIG.ai.enabled;
50+
return {
51+
ordering: config.ordering ?? { enabled: legacy.selectionEnabled ?? DEFAULT_MODULES_CONFIG.ordering.enabled, mode: 'summary' },
52+
ai: config.ai ?? { enabled: aiEnabled, voiceEnabled: aiEnabled && (legacy.aiVoiceEnabled ?? DEFAULT_MODULES_CONFIG.ai.voiceEnabled) },
53+
analytics: config.analytics ?? DEFAULT_MODULES_CONFIG.analytics,
54+
};
55+
}

web/e2e/admin/admin-items-all.spec.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,8 @@ const restaurant = {
7171
],
7272
},
7373
],
74-
features: { primaryLocale: 'it' },
74+
openingSchedule: { open: true, minWaitSlot: 0, slotDuration: 30, maxDaysLookAhead: 0, schedule: [] },
75+
features: { aiChat: false, aiVoice: false, ordering: { enabled: false, mode: 'summary' as const }, primaryLocale: 'it' },
7576
};
7677

7778
test.describe('Admin items page', () => {
@@ -82,9 +83,10 @@ test.describe('Admin items page', () => {
8283
__playwright_restaurant__?: unknown;
8384
};
8485
testWindow.__playwright_admin__ = {
85-
user: { uid: 'admin-user', email: 'admin@example.com', name: 'Admin User' },
86+
user: { uid: 'admin-user', email: 'admin@example.com', displayName: 'Admin User' },
87+
restaurantId: 'admin-items-all',
8688
};
87-
testWindow.__playwright_restaurant__ = mockRestaurant;
89+
testWindow.__playwright_restaurant__ = mockRestaurant as never;
8890
}, restaurant);
8991
});
9092

0 commit comments

Comments
 (0)