Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
3c9789a
feat: add NutritionalInfo model, Unit measureType, and register Graph…
m-lyon Mar 29, 2026
f410204
fix: address roborev findings on NutritionalInfo schema
m-lyon Mar 29, 2026
4cebe43
feat: add USDA FoodData Central proxy GraphQL resolvers
m-lyon Mar 29, 2026
cd5da18
fix: address roborev findings on USDA proxy resolvers
m-lyon Mar 29, 2026
f1fb591
feat: add nutritional notification emails on recipe save
m-lyon Mar 29, 2026
ada3184
fix: address roborev findings on nutritional notifications
m-lyon Mar 29, 2026
298aa38
feat: add client GraphQL fragments, queries, and mutations for nutrit…
m-lyon Mar 29, 2026
1edccd0
fix: address roborev findings on client GraphQL and API resolvers
m-lyon Mar 29, 2026
a496873
feat: add nutrition calculation utility and fix mock measureType fields
m-lyon Mar 29, 2026
9483838
fix: address roborev findings on nutrition utility - spread ZERO_MACR…
m-lyon Mar 29, 2026
2aefd87
feat: add nutritional info panel and ingredient highlighting to recip…
m-lyon Mar 29, 2026
60583a6
fix: address roborev findings on nutrition utility - NaN guard, volum…
m-lyon Mar 30, 2026
b2ef4fe
fix: address roborev findings on nutritional info panel and ingredien…
m-lyon Mar 30, 2026
16deb73
fix: address roborev findings on NutritionalInfoPanel props and add i…
m-lyon Mar 30, 2026
ec78be4
fix: address roborev findings - guard allUncounted with loading, use …
m-lyon Mar 30, 2026
5df9ca7
feat: add UsdaLinkSection, measureType Select for units, mount in ing…
m-lyon Mar 30, 2026
7492862
fix: address roborev findings on UsdaLinkSection and unit form measur…
m-lyon Mar 30, 2026
e2a1268
formatting
m-lyon Mar 30, 2026
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
2 changes: 2 additions & 0 deletions api/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ export const IMAGE_DIR = process.env.IMAGE_DIR ? process.env.IMAGE_DIR : '/data/
export const SMTP_FROM_DOMAIN = process.env.SMTP_FROM_DOMAIN;
export const SMTP_ADMIN_EMAIL = process.env.SMTP_ADMIN_EMAIL;
export const SENDGRID_API_KEY = process.env.SENDGRID_API_KEY;
export const USDA_API_KEY = process.env.USDA_API_KEY ?? '';
export const EMAIL_FROM = process.env.EMAIL_FROM ?? '';

if (!TEST) {
const requiredEnvVars = {
Expand Down
51 changes: 51 additions & 0 deletions api/src/models/NutritionalInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Document, Schema, Types, model } from 'mongoose';
import { composeMongoose } from 'graphql-compose-mongoose';

interface MacroNutrients {
calories: number; // kcal
protein: number; // g
carbs: number; // g
fat: number; // g
}

export interface NutritionalInfo extends Document {
ingredient: Types.ObjectId;
usdaFdcId?: number;
perGram?: MacroNutrients; // mass-based or volume-based (via density)
perUnit?: MacroNutrients; // countable, e.g. 1 egg
}

const macroNutrientsSchema = new Schema<MacroNutrients>(
{
calories: { type: Number, required: true, min: 0 },
protein: { type: Number, required: true, min: 0 },
carbs: { type: Number, required: true, min: 0 },
fat: { type: Number, required: true, min: 0 },
},
{ _id: false }
);

const nutritionalInfoSchema = new Schema<NutritionalInfo>({
ingredient: {
type: Schema.Types.ObjectId,
required: true,
ref: 'Ingredient',
unique: true, // one document per ingredient
},
usdaFdcId: { type: Number },
perGram: { type: macroNutrientsSchema, required: false },
perUnit: { type: macroNutrientsSchema, required: false },
});

// At least one of perGram or perUnit must be present
nutritionalInfoSchema.pre('validate', function () {
if (!this.perGram && !this.perUnit) {
throw new Error('NutritionalInfo must have at least one of perGram or perUnit.');
}
});

export const NutritionalInfo = model<NutritionalInfo>('NutritionalInfo', nutritionalInfoSchema);
export const NutritionalInfoTC = composeMongoose(NutritionalInfo);
export const NutritionalInfoCreateTC = composeMongoose(NutritionalInfo, {
name: 'NutritionalInfoCreate',
});
17 changes: 17 additions & 0 deletions api/src/models/Recipe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { capitalise } from '../utils/string.js';
import { generateRandomString } from '../utils/random.js';
import type { Ingredient as IngredientType } from './Ingredient.js';
import { Ingredient, ReservedIngredientTags } from './Ingredient.js';
import { sendNutritionalNotifications } from '../utils/nutritionalNotifications.js';
import { ownerExists, tagsExist, unique, uniqueInAdminsAndUser } from './validation.js';

const quantityRegex = /^((\d+(\.\d+)?|[1-9]\d*\/[1-9]\d*)(-(\d+(\.\d+)?|[1-9]\d*\/[1-9]\d*))?)$/;
Expand Down Expand Up @@ -283,6 +284,22 @@ recipeSchema.pre('save', async function () {
this.calculatedTags = calculatedTags;
});

recipeSchema.post('save', async function () {
try {
// Clone the doc before populating to avoid mutating the in-memory document
// (which could corrupt ObjectId refs if .save() is called again)
const doc = this.toObject();
await (this.constructor as typeof Recipe).populate(doc, [
{ path: 'ingredientSubsections.ingredients.ingredient' },
{ path: 'ingredientSubsections.ingredients.unit' },
]);
await sendNutritionalNotifications(doc as any);
} catch (err) {
// Do not throw: notification failures must not fail the save response
console.error('Failed to send nutritional notifications:', err);
}
});

export const RecipeIngredient = model<RecipeIngredientType>(
'RecipeIngredient',
recipeIngredientSchema
Expand Down
6 changes: 6 additions & 0 deletions api/src/models/Unit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface Unit extends Document {
owner: Types.ObjectId;
hasSpace: boolean;
unique: boolean;
measureType?: 'mass' | 'volume';
}

const unitSchema = new Schema<Unit>({
Expand Down Expand Up @@ -59,6 +60,11 @@ const unitSchema = new Schema<Unit>({
owner: { type: Schema.Types.ObjectId, required: true, ref: 'User', validate: ownerExists() },
hasSpace: { type: Boolean, required: true },
unique: { type: Boolean, required: true },
measureType: {
type: String,
enum: ['mass', 'volume'],
required: false,
},
});

export const Unit = model<Unit>('Unit', unitSchema);
Expand Down
111 changes: 111 additions & 0 deletions api/src/schema/NutritionalInfo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { GraphQLError } from 'graphql';
import { schemaComposer } from 'graphql-compose';

import { Ingredient } from '../models/Ingredient.js';
import { NutritionalInfoTC } from '../models/NutritionalInfo.js';
import { createOneResolver, updateByIdResolver } from './utils.js';
import { NutritionalInfo, NutritionalInfoCreateTC } from '../models/NutritionalInfo.js';

async function assertIngredientOwnerOrAdmin(
ingredientId: unknown,
userId: unknown,
isUserAdmin: boolean
) {
// Explicit auth check before ownership check
if (!userId) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
if (isUserAdmin) return;
const ingr = await Ingredient.findById(ingredientId);
if (!ingr) {
throw new GraphQLError('Ingredient not found', {
extensions: { code: 'NOT_FOUND' },
});
}
if (String(ingr.owner) !== String(userId)) {
throw new GraphQLError('Not authorized', {
extensions: { code: 'FORBIDDEN' },
});
}
}

NutritionalInfoCreateTC.addResolver({
name: 'createOne',
type: NutritionalInfoTC.mongooseResolvers.createOne().getType(),
args: NutritionalInfoCreateTC.mongooseResolvers.createOne().getArgs(),
resolve: createOneResolver(NutritionalInfo, NutritionalInfoCreateTC),
});

NutritionalInfoTC.addResolver({
name: 'updateById',
type: NutritionalInfoTC.mongooseResolvers.updateById().getType(),
args: NutritionalInfoTC.mongooseResolvers.updateById().getArgs(),
resolve: updateByIdResolver(NutritionalInfo, NutritionalInfoTC),
});

export const NutritionalInfoQuery = {
nutritionalInfoByIngredient: NutritionalInfoTC.mongooseResolvers
.findOne()
.wrapResolve((next) => async (rp) => {
if (!rp.context.getUser()) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return next(rp);
}),
nutritionalInfosByIngredientIds: schemaComposer.createResolver({
name: 'nutritionalInfosByIngredientIds',
type: [NutritionalInfoTC],
args: { ingredientIds: '[MongoID!]!' },
resolve: async ({ args, context }) => {
if (!context.getUser()) {
throw new GraphQLError('Not authenticated', {
extensions: { code: 'UNAUTHENTICATED' },
});
}
return NutritionalInfo.find({ ingredient: { $in: args.ingredientIds } });
},
}),
};

export const NutritionalInfoMutation = {
nutritionalInfoCreateOne: NutritionalInfoCreateTC.getResolver('createOne').wrapResolve(
(next) => async (rp) => {
const user = rp.context.getUser();
const isUserAdmin = user?.role === 'admin';
await assertIngredientOwnerOrAdmin(rp.args.record.ingredient, user?._id, isUserAdmin);
return next(rp);
}
),
nutritionalInfoUpdateById: NutritionalInfoTC.getResolver('updateById').wrapResolve(
(next) => async (rp) => {
const existing = await NutritionalInfo.findById(rp.args._id);
if (!existing) {
throw new GraphQLError('NutritionalInfo not found', {
extensions: { code: 'NOT_FOUND' },
});
}
const user = rp.context.getUser();
const isUserAdmin = user?.role === 'admin';
await assertIngredientOwnerOrAdmin(existing.ingredient, user?._id, isUserAdmin);
return next(rp);
}
),
nutritionalInfoRemoveById: NutritionalInfoTC.mongooseResolvers
.removeById()
.wrapResolve((next) => async (rp) => {
const existing = await NutritionalInfo.findById(rp.args._id);
if (!existing) {
throw new GraphQLError('NutritionalInfo not found', {
extensions: { code: 'NOT_FOUND' },
});
}
const user = rp.context.getUser();
const isUserAdmin = user?.role === 'admin';
await assertIngredientOwnerOrAdmin(existing.ingredient, user?._id, isUserAdmin);
return next(rp);
}),
};
102 changes: 102 additions & 0 deletions api/src/schema/Usda.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { GraphQLError } from 'graphql';
import { schemaComposer } from 'graphql-compose';

import { USDA_API_KEY } from '../constants.js';

const USDA_BASE = 'https://api.nal.usda.gov/fdc/v1';
const USDA_REQUEST_TIMEOUT_MS = 10_000;
const USDA_MAX_PAGE_SIZE = 200;

const UsdaFoodItemTC = schemaComposer.createObjectTC({
name: 'UsdaFoodItem',
fields: {
fdcId: 'Int!',
description: 'String!',
brandOwner: 'String',
caloriesPer100g: 'Float',
proteinPer100g: 'Float',
carbsPer100g: 'Float',
fatPer100g: 'Float',
},
});

function extractNutrient(
foodNutrients: Array<Record<string, unknown>>,
nutrientId: number
): number | null {
const entry = foodNutrients?.find(
(n) =>
n['nutrientId'] === nutrientId ||
(n['nutrient'] as Record<string, unknown>)?.['id'] === nutrientId
);
if (!entry) return null;
const value = entry['value'] ?? entry['amount'];
return typeof value === 'number' ? value : null;
}

// USDA nutrient IDs: Energy=1008, Protein=1003, Carbs=1005, Fat=1004
function mapFoodItem(item: Record<string, unknown>) {
const nutrients = (item['foodNutrients'] as Array<Record<string, unknown>>) ?? [];
return {
fdcId: item['fdcId'],
description: item['description'],
brandOwner: item['brandOwner'] ?? null,
caloriesPer100g: extractNutrient(nutrients, 1008),
proteinPer100g: extractNutrient(nutrients, 1003),
carbsPer100g: extractNutrient(nutrients, 1005),
fatPer100g: extractNutrient(nutrients, 1004),
};
}

function usdaFetch(url: string): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), USDA_REQUEST_TIMEOUT_MS);
return fetch(url, {
headers: { 'X-Api-Key': USDA_API_KEY },
signal: controller.signal,
}).finally(() => clearTimeout(timer));
}

export const UsdaQuery = {
usdaSearch: schemaComposer.createResolver({
name: 'usdaSearch',
type: [UsdaFoodItemTC],
args: { query: 'String!', pageSize: { type: 'Int', defaultValue: 20 } },
resolve: async ({ args, context }) => {
if (!context.getUser()) throw new GraphQLError('Not authenticated');
if (typeof args.query !== 'string' || !args.query) {
throw new GraphQLError('Invalid query argument', {
extensions: { code: 'BAD_USER_INPUT' },
});
}
const safePageSize = Math.min((args.pageSize as number) ?? 20, USDA_MAX_PAGE_SIZE);
const url = `${USDA_BASE}/foods/search?query=${encodeURIComponent(args.query)}&pageSize=${safePageSize}`;
const res = await usdaFetch(url);
if (!res.ok) {
throw new GraphQLError(`USDA API error: ${res.status} ${res.statusText}`);
}
const json = (await res.json()) as Record<string, unknown>;
return ((json['foods'] as Array<Record<string, unknown>>) ?? []).map(mapFoodItem);
},
}),
usdaFoodItem: schemaComposer.createResolver({
name: 'usdaFoodItem',
type: UsdaFoodItemTC,
args: { fdcId: 'Int!' },
resolve: async ({ args, context }) => {
if (!context.getUser()) throw new GraphQLError('Not authenticated');
const url = `${USDA_BASE}/food/${args.fdcId}?format=abridged`;
const res = await usdaFetch(url);
if (!res.ok) {
throw new GraphQLError(`USDA API error: ${res.status} ${res.statusText}`);
}
const json = (await res.json()) as Record<string, unknown>;
if (!json['fdcId']) {
throw new GraphQLError('Food item not found', {
extensions: { code: 'NOT_FOUND' },
});
}
return mapFoodItem(json);
},
}),
};
7 changes: 7 additions & 0 deletions api/src/schema/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SchemaComposer } from 'graphql-compose';
import { composeResolvers } from '@graphql-tools/resolvers-composition';

import { UsdaQuery } from './Usda.js';
import { Unit } from '../models/Unit.js';
import { Size } from '../models/Size.js';
import { Recipe } from '../models/Recipe.js';
Expand All @@ -18,6 +19,7 @@ import { isAdmin, isImageOwnerOrAdmin } from '../middleware/authorisation.js';
import { UnitConversionMutation, UnitConversionQuery } from './UnitConversion.js';
import { ConversionRuleMutation, ConversionRuleQuery } from './UnitConversion.js';
import { isDocumentOwnerOrAdmin, isVerified } from '../middleware/authorisation.js';
import { NutritionalInfoMutation, NutritionalInfoQuery } from './NutritionalInfo.js';
import { PrepMethodMutation, PrepMethodQuery, PrepMethodQueryAdmin } from './PrepMethod.js';

const isAdminMutations = composeResolvers(
Expand Down Expand Up @@ -49,6 +51,9 @@ const isAuthenticatedMutations = composeResolvers(
unitCreateOne: UnitMutation.unitCreateOne,
prepMethodCreateOne: PrepMethodMutation.prepMethodCreateOne,
ingredientCreateOne: IngredientMutation.ingredientCreateOne,
nutritionalInfoCreateOne: NutritionalInfoMutation.nutritionalInfoCreateOne,
nutritionalInfoUpdateById: NutritionalInfoMutation.nutritionalInfoUpdateById,
nutritionalInfoRemoveById: NutritionalInfoMutation.nutritionalInfoRemoveById,
},
},
{ 'Mutation.*': [isVerified()] }
Expand Down Expand Up @@ -122,6 +127,8 @@ schemaComposer.Query.addFields({
...ImageQuery,
...UnitConversionQuery,
...ConversionRuleQuery,
...NutritionalInfoQuery,
...UsdaQuery,
...isAdminQueries.Query,
});
schemaComposer.Mutation.addFields({
Expand Down
2 changes: 2 additions & 0 deletions api/src/types/environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ declare global {
SMTP_FROM_DOMAIN?: string;
SMTP_ADMIN_EMAIL?: string;
SENDGRID_API_KEY?: string;
USDA_API_KEY?: string;
EMAIL_FROM?: string;
}
}
}
Expand Down
Loading