Skip to content

Commit e4e4347

Browse files
committed
feat(menus): custom image upload per menu (#9)
1 parent c8f0098 commit e4e4347

25 files changed

Lines changed: 408 additions & 6 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
ALTER TABLE menus ADD COLUMN icon_url TEXT;

backend/src/__tests__/admin-routes-extra.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,90 @@ describe('DELETE /admin/entries/:id removes its R2 image', () => {
140140
});
141141
});
142142

143+
describe('POST /admin/menus/:menuId/image', () => {
144+
it('puts to R2 under images/menus/ and stores icon_url', async () => {
145+
const r2 = makeR2();
146+
const { db, env, headers } = await adminEnv(r2);
147+
seedMenu(db, 'm-1', 'food');
148+
149+
const res = await testRequest('/admin/menus/m-1/image', {
150+
method: 'POST', headers, env, body: jpegBody(),
151+
});
152+
expect(res.status).toBe(200);
153+
const body = await res.json() as { ok: boolean; iconUrl: string };
154+
expect(body.ok).toBe(true);
155+
expect(r2.calls.put.some(k => k.startsWith('images/menus/m-1-'))).toBe(true);
156+
expect(body.iconUrl.startsWith(`${R2_BASE}/images/menus/m-1-`)).toBe(true);
157+
158+
const row = db.raw.prepare('SELECT icon_url FROM menus WHERE id = ?').get('m-1') as { icon_url: string };
159+
expect(row.icon_url).toBe(body.iconUrl);
160+
});
161+
162+
it('prunes the previous R2 object on replace', async () => {
163+
const r2 = makeR2();
164+
const { db, env, headers } = await adminEnv(r2);
165+
seedMenu(db, 'm-1', 'food');
166+
const oldKey = 'images/menus/m-1-111.jpg';
167+
db.raw.prepare('UPDATE menus SET icon_url = ? WHERE id = ?').run(`${R2_BASE}/${oldKey}`, 'm-1');
168+
169+
const res = await testRequest('/admin/menus/m-1/image', {
170+
method: 'POST', headers, env, body: jpegBody(),
171+
});
172+
expect(res.status).toBe(200);
173+
expect(r2.calls.delete).toContain(oldKey);
174+
});
175+
176+
it('returns 404 for a missing menu', async () => {
177+
const r2 = makeR2();
178+
const { env, headers } = await adminEnv(r2);
179+
const res = await testRequest('/admin/menus/nope/image', {
180+
method: 'POST', headers, env, body: jpegBody(),
181+
});
182+
expect(res.status).toBe(404);
183+
});
184+
});
185+
186+
describe('DELETE /admin/menus/:menuId/image', () => {
187+
it('prunes the R2 object and nulls icon_url', async () => {
188+
const r2 = makeR2();
189+
const { db, env, headers } = await adminEnv(r2);
190+
seedMenu(db, 'm-1', 'food');
191+
const key = 'images/menus/m-1-123.jpg';
192+
db.raw.prepare('UPDATE menus SET icon_url = ? WHERE id = ?').run(`${R2_BASE}/${key}`, 'm-1');
193+
194+
const res = await testRequest('/admin/menus/m-1/image', { method: 'DELETE', headers, env });
195+
expect(res.status).toBe(200);
196+
expect(r2.calls.delete).toContain(key);
197+
const row = db.raw.prepare('SELECT icon_url FROM menus WHERE id = ?').get('m-1') as { icon_url: string | null };
198+
expect(row.icon_url).toBeNull();
199+
});
200+
201+
it('returns 404 for a missing menu', async () => {
202+
const r2 = makeR2();
203+
const { env, headers } = await adminEnv(r2);
204+
const res = await testRequest('/admin/menus/nope/image', { method: 'DELETE', headers, env });
205+
expect(res.status).toBe(404);
206+
});
207+
});
208+
209+
describe('menu iconUrl exposure', () => {
210+
it('GET /admin/menus and /catalog include iconUrl', async () => {
211+
const { db, env, headers } = await adminEnv();
212+
seedMenu(db, 'm-1', 'food');
213+
db.raw.prepare('UPDATE menus SET icon_url = ? WHERE id = ?').run(`${R2_BASE}/images/menus/m-1-1.jpg`, 'm-1');
214+
215+
const adminRes = await testRequest('/admin/menus', { headers, env });
216+
expect(adminRes.status).toBe(200);
217+
const adminBody = await adminRes.json() as { menus: Array<{ id: string; iconUrl: string | null }> };
218+
expect(adminBody.menus[0].iconUrl).toBe(`${R2_BASE}/images/menus/m-1-1.jpg`);
219+
220+
const catRes = await testRequest('/catalog', { env });
221+
expect(catRes.status).toBe(200);
222+
const catBody = await catRes.json() as { menus: Array<{ id: string; iconUrl: string | null }> };
223+
expect(catBody.menus[0].iconUrl).toBe(`${R2_BASE}/images/menus/m-1-1.jpg`);
224+
});
225+
});
226+
143227
describe('DELETE /admin/menus/:menuId FK cascade', () => {
144228
it('removes the menu and its memberships, keeps the entry', async () => {
145229
const { db, env, headers } = await adminEnv();

backend/src/db/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export const menus = sqliteTable(
7777
published: integer('published', { mode: 'boolean' }).default(true).notNull(),
7878
sortOrder: integer('sort_order').default(0).notNull(),
7979
icon: text('icon').default('utensils').notNull(),
80+
iconUrl: text('icon_url'),
8081
availableFrom: text('available_from'),
8182
availableTo: text('available_to'),
8283
availableDays: jsonColumn<string[] | null>('available_days'),

backend/src/routes/admin.ts

Lines changed: 75 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ admin.get('/menus', ...base, async (c) => {
223223
published: m.published,
224224
sortOrder: m.sortOrder,
225225
icon: m.icon,
226+
iconUrl: m.iconUrl ?? null,
226227
availableFrom: m.availableFrom ?? null,
227228
availableTo: m.availableTo ?? null,
228229
availableDays: m.availableDays ?? null,
@@ -308,14 +309,78 @@ admin.patch('/menus/:menuId', ...base, async (c) => {
308309

309310
admin.delete('/menus/:menuId', ...base, async (c) => {
310311
const menuId = c.req.param('menuId');
312+
const db = c.get('db');
313+
const [menu] = await db
314+
.select({ iconUrl: schema.menus.iconUrl })
315+
.from(schema.menus)
316+
.where(eq(schema.menus.id, menuId))
317+
.limit(1);
318+
if (menu?.iconUrl && c.env.PUBLIC_MENU_BUCKET) {
319+
const key = r2KeyFromUrl(menu.iconUrl);
320+
if (key) await c.env.PUBLIC_MENU_BUCKET.delete(key);
321+
}
311322
// FK cascade on menu_entry_memberships removes membership rows automatically.
312-
await c.get('db')
323+
await db
313324
.delete(schema.menus)
314325
.where(eq(schema.menus.id, menuId));
315326
await refreshPublicCatalog(c);
316327
return c.json({ ok: true });
317328
});
318329

330+
admin.post('/menus/:menuId/image', ...base, async (c) => {
331+
const menuId = c.req.param('menuId');
332+
const db = c.get('db');
333+
334+
const [menu] = await db
335+
.select({ iconUrl: schema.menus.iconUrl })
336+
.from(schema.menus)
337+
.where(eq(schema.menus.id, menuId))
338+
.limit(1);
339+
if (!menu) return c.json({ error: 'Menu not found' }, 404);
340+
341+
const upload = await uploadImage(c, `images/menus/${menuId}`);
342+
if ('response' in upload) return upload.response;
343+
344+
if (menu.iconUrl) {
345+
const oldKey = r2KeyFromUrl(menu.iconUrl);
346+
if (oldKey) await c.env.PUBLIC_MENU_BUCKET!.delete(oldKey);
347+
}
348+
349+
await db
350+
.update(schema.menus)
351+
.set({ iconUrl: upload.imageUrl, updatedAt: Date.now() })
352+
.where(eq(schema.menus.id, menuId));
353+
354+
await refreshPublicCatalog(c);
355+
return c.json({ ok: true, iconUrl: upload.imageUrl });
356+
});
357+
358+
admin.delete('/menus/:menuId/image', ...base, async (c) => {
359+
const menuId = c.req.param('menuId');
360+
const db = c.get('db');
361+
const bucket = c.env.PUBLIC_MENU_BUCKET;
362+
363+
const [menu] = await db
364+
.select({ iconUrl: schema.menus.iconUrl })
365+
.from(schema.menus)
366+
.where(eq(schema.menus.id, menuId))
367+
.limit(1);
368+
if (!menu) return c.json({ error: 'Menu not found' }, 404);
369+
370+
if (menu.iconUrl && bucket) {
371+
const key = r2KeyFromUrl(menu.iconUrl);
372+
if (key) await bucket.delete(key);
373+
}
374+
375+
await db
376+
.update(schema.menus)
377+
.set({ iconUrl: null, updatedAt: Date.now() })
378+
.where(eq(schema.menus.id, menuId));
379+
380+
await refreshPublicCatalog(c);
381+
return c.json({ ok: true });
382+
});
383+
319384
// ── Labels ────────────────────────────────────────────────────────────
320385

321386
admin.get('/labels', ...base, async (c) => {
@@ -1144,9 +1209,16 @@ async function setEntryDestinationAssignments(db: DbInstance, entryId: string, d
11441209
]);
11451210
}
11461211

1147-
async function uploadSettingsImage(
1212+
function uploadSettingsImage(
11481213
c: Context<AppBindings>,
11491214
prefix: string,
1215+
): Promise<{ imageUrl: string } | { response: Response }> {
1216+
return uploadImage(c, `images/settings/${prefix}`);
1217+
}
1218+
1219+
async function uploadImage(
1220+
c: Context<AppBindings>,
1221+
keyPrefix: string,
11501222
): Promise<{ imageUrl: string } | { response: Response }> {
11511223
const bucket = c.env.PUBLIC_MENU_BUCKET;
11521224
if (!bucket) return { response: c.json({ error: 'R2 bucket not configured' }, 503) };
@@ -1157,7 +1229,7 @@ async function uploadSettingsImage(
11571229
return { response: c.json({ error: validation.error }, validation.status) };
11581230
}
11591231

1160-
const key = `images/settings/${prefix}-${Date.now()}${validation.ext}`;
1232+
const key = `${keyPrefix}-${Date.now()}${validation.ext}`;
11611233
await bucket.put(key, body, { httpMetadata: { contentType: validation.type } });
11621234

11631235
const r2Base = (c.env.R2_PUBLIC_URL || '').replace(/\/$/, '');

backend/src/routes/catalog.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,7 @@ export async function buildCatalogFromDb(
404404
published: m.published,
405405
sortOrder: m.sortOrder,
406406
icon: m.icon,
407+
iconUrl: m.iconUrl ?? null,
407408
availableFrom: m.availableFrom ?? null,
408409
availableTo: m.availableTo ?? null,
409410
availableDays: m.availableDays ?? null,

packages/schemas/src/catalog.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export const CatalogMenuSchema = z.object({
8383
published: z.boolean(),
8484
sortOrder: z.number(),
8585
icon: z.string(),
86+
iconUrl: z.string().nullable().optional(),
8687
availableFrom: HHMMSchema.nullable().optional(),
8788
availableTo: HHMMSchema.nullable().optional(),
8889
availableDays: z.array(WeekdaySchema).nullable().optional(),

web/messages/de.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,12 @@
350350
"menus.codeLabel": "Code (URL slug)",
351351
"menus.codeHint": "Wird in /menu?type={code} verwendet",
352352
"menus.iconLabel": "Symbol",
353+
"menus.imageLabel": "Eigenes Bild",
354+
"menus.imageHint": "Optionales Foto, das anstelle des Icons auf den Startseiten-Karten angezeigt wird.",
355+
"menus.imageUpload": "Bild hochladen",
356+
"menus.imageUploading": "Wird hochgeladen…",
357+
"menus.imageRemove": "Bild entfernen (Icon verwenden)",
358+
"menus.imageOverridesIcon": "Das eigene Bild ersetzt das Icon auf der Startseite.",
353359
"menus.icon.utensils": "Speisen",
354360
"menus.icon.lunch": "Mittagessen",
355361
"menus.icon.dinner": "Abendessen",

web/messages/en.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,12 @@
352352
"menus.codeLabel": "Code (URL slug)",
353353
"menus.codeHint": "Used in /menu?type={code}",
354354
"menus.iconLabel": "Icon",
355+
"menus.imageLabel": "Custom image",
356+
"menus.imageHint": "Optional photo shown instead of the icon on the home page cards.",
357+
"menus.imageUpload": "Upload image",
358+
"menus.imageUploading": "Uploading…",
359+
"menus.imageRemove": "Remove image (use icon)",
360+
"menus.imageOverridesIcon": "The custom image overrides the icon on the home page.",
355361
"menus.icon.utensils": "Food",
356362
"menus.icon.lunch": "Lunch",
357363
"menus.icon.dinner": "Dinner",

web/messages/es.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,12 @@
350350
"menus.codeLabel": "Código (URL slug)",
351351
"menus.codeHint": "Se usa en /menu?type={code}",
352352
"menus.iconLabel": "Icono",
353+
"menus.imageLabel": "Imagen personalizada",
354+
"menus.imageHint": "Foto opcional que se muestra en lugar del icono en las tarjetas de la página de inicio.",
355+
"menus.imageUpload": "Subir imagen",
356+
"menus.imageUploading": "Subiendo…",
357+
"menus.imageRemove": "Quitar imagen (usar icono)",
358+
"menus.imageOverridesIcon": "La imagen personalizada reemplaza el icono en la página de inicio.",
353359
"menus.icon.utensils": "Comida",
354360
"menus.icon.lunch": "Almuerzo",
355361
"menus.icon.dinner": "Cena",

web/messages/fr.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,12 @@
350350
"menus.codeLabel": "Code (URL slug)",
351351
"menus.codeHint": "Utilisé dans /menu?type={code}",
352352
"menus.iconLabel": "Icône",
353+
"menus.imageLabel": "Image personnalisée",
354+
"menus.imageHint": "Photo optionnelle affichée à la place de l'icône sur les cartes de la page d'accueil.",
355+
"menus.imageUpload": "Télécharger l'image",
356+
"menus.imageUploading": "Envoi…",
357+
"menus.imageRemove": "Supprimer l'image (utiliser l'icône)",
358+
"menus.imageOverridesIcon": "L'image personnalisée remplace l'icône sur la page d'accueil.",
353359
"menus.icon.utensils": "Repas",
354360
"menus.icon.lunch": "Déjeuner",
355361
"menus.icon.dinner": "Dîner",

0 commit comments

Comments
 (0)