Skip to content

Commit

Permalink
feat(api): food thumbnail images
Browse files Browse the repository at this point in the history
  • Loading branch information
mechkg committed Feb 5, 2025
1 parent a11d3fd commit d98c784
Show file tree
Hide file tree
Showing 18 changed files with 268 additions and 1 deletion.
8 changes: 8 additions & 0 deletions apps/api/src/config/image-processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export type SelectionScreenImageConfig = {
height: number;
};

export type FoodThumbnailImageConfig = {
width: number;
};

export type ImageMapsConfig = {
width: number;
};
Expand All @@ -27,6 +31,7 @@ export type ImageProcessorConfig = {
imageMaps: ImageMapsConfig;
drinkScale: SlidingScaleConfig;
optionSelection: SelectionScreenImageConfig;
foodThumbnailImage: FoodThumbnailImageConfig;
};

const imageProcessorConfig: ImageProcessorConfig = {
Expand All @@ -48,6 +53,9 @@ const imageProcessorConfig: ImageProcessorConfig = {
width: Number.parseInt(process.env.IMAGE_OPTION_SELECTION_WIDTH || '300', 10),
height: Number.parseInt(process.env.IMAGE_OPTION_SELECTION_HEIGHT || '200', 10),
},
foodThumbnailImage: {
width: Number.parseInt(process.env.IMAGE_FOOD_THUMB_WIDTH || '1000', 10),
},
};

export default imageProcessorConfig;
48 changes: 48 additions & 0 deletions apps/api/src/http/routers/admin/food-thumbnail-images.router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { initServer } from '@ts-rest/express';
import multer from 'multer';
import { ValidationError } from '@intake24/api/http/errors';
import { permission } from '@intake24/api/http/middleware';
import ioc from '@intake24/api/ioc';
import { contract } from '@intake24/common/contracts';
import { imageMulterFile } from '@intake24/common/types/http/admin/source-images';
import { FoodLocal } from '@intake24/db';
import { customTypeValidationMessage } from '../../requests/util';

export function foodThumbnailImages() {
const upload = multer({ dest: ioc.cradle.fsConfig.local.uploads });

// FIXME: Empty form produces HTTP 500 with 'Unexpected end of form' message
// instead of 400
return initServer().router(contract.admin.foodThumbnailImages, {
update: {
middleware: [
permission('fdbs:edit'),
upload.single('image'),
],
handler: async ({ file, params: { localeId, foodCode }, req }) => {
const {
user,
foodThumbnailImageService,
} = req.scope.cradle;

const res = imageMulterFile.safeParse(file);

if (!res.success) {
throw new ValidationError(
customTypeValidationMessage('file._', { req, path: 'image' }),
{ path: 'image' },
);
}

const foodLocal = await FoodLocal.findOne({ where: { localeId, foodCode } });

if (foodLocal === null)
return { status: 404, body: undefined };

await foodThumbnailImageService.createImage(user.userId, foodLocal.id, res.data);

return { status: 200, body: undefined };
},
},
});
}
2 changes: 2 additions & 0 deletions apps/api/src/http/routers/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { authentication } from './authentication.router';
import { feedbackScheme } from './feedback-scheme.router';
import { foodDb } from './food-db.router';
import { foodGroup } from './food-group.router';
import { foodThumbnailImages } from './food-thumbnail-images.router';
import images from './images';
import { job } from './job.router';
import { languageTranslation } from './language-translation.router';
Expand All @@ -27,6 +28,7 @@ export default {
authentication,
feedbackScheme,
foodDb,
foodThumbnailImages,
foodGroup,
images,
job,
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/http/routers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ export function registerRouters(express: Router) {
feedbackSchemeSecurable: contract.admin.feedbackSchemeSecurable,
foodDb: contract.admin.foodDb,
foodGroup: contract.admin.foodGroup,
foodThumbnailImages: contract.admin.foodThumbnailImages,
guideImage: contract.admin.images.guideImage,
imageMap: contract.admin.images.imageMap,
job: contract.admin.job,
Expand Down Expand Up @@ -180,6 +181,7 @@ export function registerRouters(express: Router) {
feedbackScheme: admin.feedbackScheme(),
feedbackSchemeSecurable: admin.securable(FeedbackScheme)(),
foodDb: admin.foodDb(),
foodThumbnailImages: admin.foodThumbnailImages(),
foodGroup: admin.foodGroup(),
guideImage: admin.images.guideImage(),
imageMap: admin.images.imageMap(),
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/ioc/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
Filesystem,
FoodDataService,
FoodSearchService,
FoodThumbnailImageService,
GlobalCategoriesService,
GlobalFoodsService,
GuideImageService,
Expand Down Expand Up @@ -189,6 +190,7 @@ export interface IoC extends Jobs {
drinkwareSetService: DrinkwareSetService;
guideImageService: GuideImageService;
imageMapService: ImageMapService;
foodThumbnailImageService: FoodThumbnailImageService;

// Survey / user
adminSignupService: AdminSignupService;
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/ioc/services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
Filesystem,
foodDataService,
foodSearchService,
foodThumbnailImageService,
globalCategoriesService,
globalFoodsService,
guideImageService,
Expand Down Expand Up @@ -81,6 +82,7 @@ export default (container: AwilixContainer): void => {
imageMapService: asFunction(imageMapService).singleton(),
processedImageService: asFunction(processedImageService).singleton(),
sourceImageService: asFunction(sourceImageService).singleton(),
foodThumbnailImageService: asFunction(foodThumbnailImageService).singleton(),

feedbackService: asFunction(feedbackService).singleton(),
foodDataService: asFunction(foodDataService).singleton(),
Expand Down
30 changes: 30 additions & 0 deletions apps/api/src/services/admin/images/food-thumbnail-image.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { IoC } from '@intake24/api/ioc';
import type { SourceFileInput } from '@intake24/common/types/http/admin';
import { FoodThumbnailImage } from '@intake24/db';

function foodThumbnailImageService({
processedImageService,
sourceImageService,
}: Pick<IoC, 'processedImageService' | 'sourceImageService'>) {
const createImage = async (uploaderId: string, foodLocalId: string, sourceImageInput: SourceFileInput): Promise<FoodThumbnailImage> => {
const sourceImage = await sourceImageService.uploadSourceImage({ file: sourceImageInput, uploader: uploaderId, id: foodLocalId }, 'food_thumbnail');
const thumbnailImage = await processedImageService.createFoodThumbnailImage(foodLocalId, sourceImage);

// According to https://sequelize.org/docs/v6/other-topics/upgrade/
// created is always null in Postgres
const [instance, _] = await FoodThumbnailImage.upsert({
foodLocalId,
imageId: thumbnailImage.id,
});

return instance;
};

return {
createImage,
};
}

export default foodThumbnailImageService;

export type FoodThumbnailImageService = ReturnType<typeof foodThumbnailImageService>;
2 changes: 2 additions & 0 deletions apps/api/src/services/admin/images/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export * from './as-served.service';
export { default as asServedService } from './as-served.service';
export * from './drinkware-set.service';
export { default as drinkwareSetService } from './drinkware-set.service';
export * from './food-thumbnail-image.service';
export { default as foodThumbnailImageService } from './food-thumbnail-image.service';
export * from './guide-image.service';
export { default as guideImageService } from './guide-image.service';
export * from './image-map.service';
Expand Down
26 changes: 26 additions & 0 deletions apps/api/src/services/admin/images/processed-image.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,31 @@ function processedImageService({
});
};

const createFoodThumbnailImage = async (
foodLocalId: string,
sourceImage: SourceImage | string,
): Promise<ProcessedImage> => {
const image
= typeof sourceImage === 'string' ? await resolveSourceImage(sourceImage) : sourceImage;

const fileName = `${randomUUID()}${path.extname(image.path)}`;
const fileDir = path.posix.join('food_thumbnails', foodLocalId);
const fullPath = path.posix.join(fileDir, fileName);

await fs.ensureDir(path.join(imagesPath, fileDir));

await sharp(path.join(imagesPath, image.path))
.resize(imageProcessorConfig.foodThumbnailImage.width)
.jpeg({ mozjpeg: true })
.toFile(path.join(imagesPath, fullPath));

return ProcessedImage.create({
path: fullPath,
sourceId: image.id,
purpose: ProcessedImagePurposes.FoodThumbnailImage,
});
};

const destroy = async (imageId: string, options: DestroyOptions = {}): Promise<void> => {
const processedImage = await ProcessedImage.findByPk(imageId, {
attributes: ['id', 'path', 'sourceId'],
Expand Down Expand Up @@ -206,6 +231,7 @@ function processedImageService({
createImageMapBaseImage,
createSelectionImage,
createDrinkScaleBaseImage,
createFoodThumbnailImage,
destroy,
};
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { initContract } from '@ts-rest/core';
import { z } from 'zod';

export const foodThumbnailImages = initContract().router({
update: {
method: 'PUT',
path: '/admin/fdbs/:localeId/:foodCode/thumbnail',
contentType: 'multipart/form-data',
body: z.object({
image: z.custom<File>(),
}),
pathParams: z.object({ localeId: z.string(), foodCode: z.string() }),
responses: {
200: z.undefined(),
404: z.undefined(),
},
summary: 'Update food image thumbnail',
description: 'Update food image thumbnail',
},
});
2 changes: 2 additions & 0 deletions packages/common/src/contracts/admin/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import acl from './acl';
import { authentication } from './authentication.contract';
import { foodThumbnailImages } from './fdbs/food-thumbnail-images.contract';
import { feedbackScheme } from './feedback-scheme.contract';
import { foodDb } from './food-db.contract';
import { foodGroup } from './food-group.contract';
Expand Down Expand Up @@ -29,6 +30,7 @@ export default {
feedbackSchemeSecurable: securable('FeedbackScheme', '/admin/feedback-schemes/:feedbackSchemeId'),
foodDb,
foodGroup,
foodThumbnailImages,
images,
job,
language,
Expand Down
2 changes: 1 addition & 1 deletion packages/common/src/types/http/admin/source-images.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export const imageMulterFile = multerFile.extend({
});
export type ImageMulterFile = z.infer<typeof imageMulterFile>;

export const sourceImageTypes = ['image_maps', 'as_served', 'drink_scale'] as const;
export const sourceImageTypes = ['image_maps', 'as_served', 'drink_scale', 'food_thumbnail'] as const;
export type SourceImageType = typeof sourceImageTypes[number];

export const sourceFileInput = z.object({
Expand Down
1 change: 1 addition & 0 deletions packages/common/src/types/http/foods/user-food-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const userFoodData = z.object({
brandNames: z.array(z.string()),
categories: z.array(z.string()),
tags: z.array(z.string()),
thumbnailImageUrl: z.string().optional(),
});

export type UserFoodData = z.infer<typeof userFoodData>;
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

module.exports = {
up: async (queryInterface, Sequelize) => {
await queryInterface.createTable('food_thumbnail_images', {
id: {
type: Sequelize.BIGINT,
autoIncrement: true,
primaryKey: true,
},
food_local_id: {
type: Sequelize.BIGINT,
allowNull: false,
unique: true,
references: {
model: 'food_locals',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
image_id: {
type: Sequelize.BIGINT,
allowNull: false,
references: {
model: 'processed_images',
key: 'id',
},
onUpdate: 'CASCADE',
onDelete: 'CASCADE',
},
created_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
updated_at: {
type: Sequelize.DATE,
allowNull: false,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});

await queryInterface.addIndex('food_thumbnail_images', ['food_local_id']);
},

down: async (queryInterface) => {
await queryInterface.dropTable('food_thumbnail_images');
},
};
14 changes: 14 additions & 0 deletions packages/db/src/kysely/foods.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
/**
* This file was generated by kysely-codegen.
* Please do not edit it manually.
*/

import type { ColumnType } from 'kysely';

export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
Expand Down Expand Up @@ -200,6 +205,14 @@ export interface FoodsRestrictions {
localeId: string;
}

export interface FoodThumbnailImages {
createdAt: Generated<Timestamp>;
foodLocalId: Int8;
id: Generated<Int8>;
imageId: Int8;
updatedAt: Generated<Timestamp>;
}

export interface GuideImageObjects {
guideImageId: string;
id: Generated<Int8>;
Expand Down Expand Up @@ -432,6 +445,7 @@ export interface DB {
foodsLocalLists: FoodsLocalLists;
foodsNutrients: FoodsNutrients;
foodsRestrictions: FoodsRestrictions;
foodThumbnailImages: FoodThumbnailImages;
guideImageObjects: GuideImageObjects;
guideImages: GuideImages;
imageMapObjects: ImageMapObjects;
Expand Down
Loading

0 comments on commit d98c784

Please sign in to comment.