gymmonk-schema 0.58.0 → 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/file.d.ts +3 -1
- package/dist/file.d.ts.map +1 -1
- package/dist/file.js +9 -0
- package/dist/file.js.map +1 -1
- package/dist/food.d.ts +4 -4
- package/dist/food.d.ts.map +1 -1
- package/dist/food.js +15 -2
- package/dist/food.js.map +1 -1
- package/package.json +1 -1
package/dist/file.d.ts
CHANGED
|
@@ -25,7 +25,7 @@ import { z } from 'zod';
|
|
|
25
25
|
* `<folder>/<userId>/<timestamp>-<random><ext>`. That per-user prefix is what
|
|
26
26
|
* makes ownership checkable on cleanup.
|
|
27
27
|
*/
|
|
28
|
-
export declare const UPLOAD_FOLDERS: readonly ["profile-photos", "center-photos", "logos", "equipment-photos", "announcement-media", "exercise-media", "progress-photos", "payment-proofs", "feedback-media"];
|
|
28
|
+
export declare const UPLOAD_FOLDERS: readonly ["profile-photos", "center-photos", "logos", "equipment-photos", "announcement-media", "exercise-media", "food-media", "progress-photos", "payment-proofs", "feedback-media"];
|
|
29
29
|
export declare const uploadFolderSchema: z.ZodEnum<{
|
|
30
30
|
"profile-photos": "profile-photos";
|
|
31
31
|
"center-photos": "center-photos";
|
|
@@ -33,6 +33,7 @@ export declare const uploadFolderSchema: z.ZodEnum<{
|
|
|
33
33
|
"equipment-photos": "equipment-photos";
|
|
34
34
|
"announcement-media": "announcement-media";
|
|
35
35
|
"exercise-media": "exercise-media";
|
|
36
|
+
"food-media": "food-media";
|
|
36
37
|
"progress-photos": "progress-photos";
|
|
37
38
|
"payment-proofs": "payment-proofs";
|
|
38
39
|
"feedback-media": "feedback-media";
|
|
@@ -89,6 +90,7 @@ export declare const signedUploadUrlBodySchema: z.ZodObject<{
|
|
|
89
90
|
"equipment-photos": "equipment-photos";
|
|
90
91
|
"announcement-media": "announcement-media";
|
|
91
92
|
"exercise-media": "exercise-media";
|
|
93
|
+
"food-media": "food-media";
|
|
92
94
|
"progress-photos": "progress-photos";
|
|
93
95
|
"payment-proofs": "payment-proofs";
|
|
94
96
|
"feedback-media": "feedback-media";
|
package/dist/file.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,
|
|
1
|
+
{"version":3,"file":"file.d.ts","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAIxB;;;;;;;;;GASG;AACH,eAAO,MAAM,cAAc,wLA4BjB,CAAC;AAEX,eAAO,MAAM,kBAAkB;;;;;;;;;;;EAAyB,CAAC;AACzD,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAI9D,wDAAwD;AACxD,eAAO,MAAM,yBAAyB,oFAM5B,CAAC;AAEX,eAAO,MAAM,oBAAoB;;;;;;EAAoC,CAAC;AACtE,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;GAKG;AACH,eAAO,MAAM,kBAAkB,KAAK,CAAC;AAErC;;;;;;;;;GASG;AACH,eAAO,MAAM,kBAAkB,EAAE,OAAO,CAAC,MAAM,CAAC,YAAY,EAAE,MAAM,CAAC,CAAM,CAAC;AAM5E;;;;;;GAMG;AACH,eAAO,MAAM,yBAAyB;;;;;;;;;;;;;;;;;;;;;;iBA0BlC,CAAC;AAEL,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAE5E,mDAAmD;AACnD,eAAO,MAAM,6BAA6B;;;;;iBAUxC,CAAC;AAEH,MAAM,MAAM,uBAAuB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,6BAA6B,CAAC,CAAC;AAIpF;;;;;;;;GAQG;AACH,eAAO,MAAM,wBAAwB;;iBAEnC,CAAC;AAEH,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,eAAO,MAAM,4BAA4B;;;iBAGvC,CAAC;AAEH,MAAM,MAAM,sBAAsB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,4BAA4B,CAAC,CAAC"}
|
package/dist/file.js
CHANGED
|
@@ -39,6 +39,15 @@ export const UPLOAD_FOLDERS = [
|
|
|
39
39
|
'announcement-media',
|
|
40
40
|
/** Custom exercise thumbnails added by a gym. */
|
|
41
41
|
'exercise-media',
|
|
42
|
+
/**
|
|
43
|
+
* Photographs of a gym's own foods and dishes.
|
|
44
|
+
*
|
|
45
|
+
* The food library's card is image-LED — a 68px square down the left of every
|
|
46
|
+
* row — so a gym-authored food with no photograph shows a fallback glyph
|
|
47
|
+
* beside 150 seeded foods that all have one. There was no folder for it, so
|
|
48
|
+
* the authoring form simply had no photo field.
|
|
49
|
+
*/
|
|
50
|
+
'food-media',
|
|
42
51
|
/** Progress photos a member uploads against a measurement entry. */
|
|
43
52
|
'progress-photos',
|
|
44
53
|
/** Screenshot of a transfer, attached to a membership awaiting confirmation. */
|
package/dist/file.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file.js","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — File uploads\n * =============================\n * Contracts for the signed-URL upload flow. GymMonk never streams file bytes\n * through the API: the browser asks the backend for a short-lived v4 signed\n * PUT URL, uploads straight to Google Cloud Storage, and stores the returned\n * PERMANENT public URL.\n *\n * The download URL is deliberately NOT signed. A signed read URL expires,\n * which would rot every image reference already written to Mongo; the bucket\n * instead grants `allUsers` object-viewer and we store the plain public URL.\n *\n * Mirrors reform-backend's `shared/schemas/file.schema.ts`.\n *\n * @module gymmonk-schema/file\n */\n\nimport { z } from 'zod';\n\n// ─── Folders ─────────────────────────────────────────────────────────────────\n\n/**\n * Every folder an upload may target. This is an allow-list, not a hint: the\n * backend rejects anything outside it, so a compromised client cannot scatter\n * objects across the bucket. Adding a new upload surface means adding it here\n * FIRST, then publishing the package.\n *\n * `userId` is appended by the backend, so the effective path is\n * `<folder>/<userId>/<timestamp>-<random><ext>`. That per-user prefix is what\n * makes ownership checkable on cleanup.\n */\nexport const UPLOAD_FOLDERS = [\n /** Member + owner + staff avatars. Square, cropped round in the UI. */\n 'profile-photos',\n /** Gym/center gallery photos. Multi-select, uncropped. */\n 'center-photos',\n /** Organisation and center logos. */\n 'logos',\n /** Equipment photos in the gym inventory. */\n 'equipment-photos',\n /** Images attached to an announcement. */\n 'announcement-media',\n /** Custom exercise thumbnails added by a gym. */\n 'exercise-media',\n /** Progress photos a member uploads against a measurement entry. */\n 'progress-photos',\n /** Screenshot of a transfer, attached to a membership awaiting confirmation. */\n 'payment-proofs',\n /** Screenshots and photos attached to feedback or a complaint. */\n 'feedback-media',\n] as const;\n\nexport const uploadFolderSchema = z.enum(UPLOAD_FOLDERS);\nexport type UploadFolder = z.infer<typeof uploadFolderSchema>;\n\n// ─── Content types ───────────────────────────────────────────────────────────\n\n/** MIME types the signed-URL endpoint will sign for. */\nexport const ALLOWED_UPLOAD_MIME_TYPES = [\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'application/pdf',\n] as const;\n\nexport const uploadMimeTypeSchema = z.enum(ALLOWED_UPLOAD_MIME_TYPES);\nexport type UploadMimeType = z.infer<typeof uploadMimeTypeSchema>;\n\n// ─── Size limits ─────────────────────────────────────────────────────────────\n\n/**\n * Default per-file ceiling, in MB. Validation runs on the RAW pick, before the\n * client-side optimizer shrinks it — a modern phone camera produces 8-12 MB\n * JPEGs that compress to well under 1 MB, so a tighter cap would reject\n * perfectly ordinary photos.\n */\nexport const MAX_UPLOAD_SIZE_MB = 10;\n\n/**\n * Per-folder overrides, for a folder that legitimately needs more room than a\n * photo.\n *\n * Empty today. It held one entry, `member-documents` at 20MB, for the ID scans\n * the front desk used to take; that folder went when the ID-proof upload did,\n * because nothing in the product ever showed a scan back. Kept as the seam\n * rather than deleted, so raising a single folder's ceiling stays a one-line\n * change instead of a refactor.\n */\nexport const FOLDER_MAX_SIZE_MB: Partial<Record<UploadFolder, number>> = {};\n\nconst HARD_CAP_MB = Math.max(MAX_UPLOAD_SIZE_MB, ...Object.values(FOLDER_MAX_SIZE_MB));\n\n// ─── Signed upload URL ───────────────────────────────────────────────────────\n\n/**\n * Request body for `POST /api/v1/files/signed-url`.\n *\n * `fileName` is restricted to a safe charset because it becomes part of the GCS\n * object path — the client sanitises before sending, and this rejects anything\n * that slipped through (path separators, control characters, unicode tricks).\n */\nexport const signedUploadUrlBodySchema = z\n .object({\n fileName: z\n .string()\n .min(1)\n .max(255)\n .regex(/^[a-zA-Z0-9_.-]+$/, 'Invalid file name'),\n contentType: uploadMimeTypeSchema,\n folder: uploadFolderSchema,\n fileSize: z\n .number()\n .int()\n .min(1)\n .max(HARD_CAP_MB * 1024 * 1024, `File must be smaller than ${HARD_CAP_MB}MB`),\n })\n .superRefine((data, ctx) => {\n // Tighten to the per-folder cap. The outer `.max` is only the widest\n // ceiling any folder allows; this is the one that actually applies.\n const capMb = FOLDER_MAX_SIZE_MB[data.folder] ?? MAX_UPLOAD_SIZE_MB;\n if (data.fileSize > capMb * 1024 * 1024) {\n ctx.addIssue({\n code: 'custom',\n path: ['fileSize'],\n message: `File must be smaller than ${capMb}MB for ${data.folder}`,\n });\n }\n });\n\nexport type SignedUploadUrlBody = z.infer<typeof signedUploadUrlBodySchema>;\n\n/** Response of `POST /api/v1/files/signed-url`. */\nexport const signedUploadUrlResponseSchema = z.object({\n /** Short-lived v4 signed URL — PUT the bytes here. */\n uploadUrl: z.string(),\n /** Permanent public URL. This is what gets stored in Mongo. */\n downloadUrl: z.string(),\n /** Object path inside the bucket, without the bucket prefix. Used to\n * delete an object the user abandoned before saving. */\n filePath: z.string(),\n /** When `uploadUrl` stops working. */\n expiresAt: z.iso.datetime(),\n});\n\nexport type SignedUploadUrlResponse = z.infer<typeof signedUploadUrlResponseSchema>;\n\n// ─── Cleanup ─────────────────────────────────────────────────────────────────\n\n/**\n * Body for `POST /api/v1/files/cleanup-uploads`. Called when a user removes a\n * photo they uploaded but never saved, so the orphaned object does not sit in\n * the bucket forever.\n *\n * Ownership is enforced per-path from the `userId` segment the backend embedded\n * at signing time. Paths belonging to someone else are skipped silently rather\n * than 403-ing, so one foreign entry cannot void a legitimate batch.\n */\nexport const cleanupUploadsBodySchema = z.object({\n filePaths: z.array(z.string().min(1)).min(1).max(20),\n});\n\nexport type CleanupUploadsBody = z.infer<typeof cleanupUploadsBodySchema>;\n\nexport const cleanupUploadsResponseSchema = z.object({\n deleted: z.number().int().min(0),\n skipped: z.number().int().min(0),\n});\n\nexport type CleanupUploadsResponse = z.infer<typeof cleanupUploadsResponseSchema>;\n"]}
|
|
1
|
+
{"version":3,"file":"file.js","sourceRoot":"","sources":["../src/file.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — File uploads\n * =============================\n * Contracts for the signed-URL upload flow. GymMonk never streams file bytes\n * through the API: the browser asks the backend for a short-lived v4 signed\n * PUT URL, uploads straight to Google Cloud Storage, and stores the returned\n * PERMANENT public URL.\n *\n * The download URL is deliberately NOT signed. A signed read URL expires,\n * which would rot every image reference already written to Mongo; the bucket\n * instead grants `allUsers` object-viewer and we store the plain public URL.\n *\n * Mirrors reform-backend's `shared/schemas/file.schema.ts`.\n *\n * @module gymmonk-schema/file\n */\n\nimport { z } from 'zod';\n\n// ─── Folders ─────────────────────────────────────────────────────────────────\n\n/**\n * Every folder an upload may target. This is an allow-list, not a hint: the\n * backend rejects anything outside it, so a compromised client cannot scatter\n * objects across the bucket. Adding a new upload surface means adding it here\n * FIRST, then publishing the package.\n *\n * `userId` is appended by the backend, so the effective path is\n * `<folder>/<userId>/<timestamp>-<random><ext>`. That per-user prefix is what\n * makes ownership checkable on cleanup.\n */\nexport const UPLOAD_FOLDERS = [\n /** Member + owner + staff avatars. Square, cropped round in the UI. */\n 'profile-photos',\n /** Gym/center gallery photos. Multi-select, uncropped. */\n 'center-photos',\n /** Organisation and center logos. */\n 'logos',\n /** Equipment photos in the gym inventory. */\n 'equipment-photos',\n /** Images attached to an announcement. */\n 'announcement-media',\n /** Custom exercise thumbnails added by a gym. */\n 'exercise-media',\n /**\n * Photographs of a gym's own foods and dishes.\n *\n * The food library's card is image-LED — a 68px square down the left of every\n * row — so a gym-authored food with no photograph shows a fallback glyph\n * beside 150 seeded foods that all have one. There was no folder for it, so\n * the authoring form simply had no photo field.\n */\n 'food-media',\n /** Progress photos a member uploads against a measurement entry. */\n 'progress-photos',\n /** Screenshot of a transfer, attached to a membership awaiting confirmation. */\n 'payment-proofs',\n /** Screenshots and photos attached to feedback or a complaint. */\n 'feedback-media',\n] as const;\n\nexport const uploadFolderSchema = z.enum(UPLOAD_FOLDERS);\nexport type UploadFolder = z.infer<typeof uploadFolderSchema>;\n\n// ─── Content types ───────────────────────────────────────────────────────────\n\n/** MIME types the signed-URL endpoint will sign for. */\nexport const ALLOWED_UPLOAD_MIME_TYPES = [\n 'image/jpeg',\n 'image/png',\n 'image/webp',\n 'image/gif',\n 'application/pdf',\n] as const;\n\nexport const uploadMimeTypeSchema = z.enum(ALLOWED_UPLOAD_MIME_TYPES);\nexport type UploadMimeType = z.infer<typeof uploadMimeTypeSchema>;\n\n// ─── Size limits ─────────────────────────────────────────────────────────────\n\n/**\n * Default per-file ceiling, in MB. Validation runs on the RAW pick, before the\n * client-side optimizer shrinks it — a modern phone camera produces 8-12 MB\n * JPEGs that compress to well under 1 MB, so a tighter cap would reject\n * perfectly ordinary photos.\n */\nexport const MAX_UPLOAD_SIZE_MB = 10;\n\n/**\n * Per-folder overrides, for a folder that legitimately needs more room than a\n * photo.\n *\n * Empty today. It held one entry, `member-documents` at 20MB, for the ID scans\n * the front desk used to take; that folder went when the ID-proof upload did,\n * because nothing in the product ever showed a scan back. Kept as the seam\n * rather than deleted, so raising a single folder's ceiling stays a one-line\n * change instead of a refactor.\n */\nexport const FOLDER_MAX_SIZE_MB: Partial<Record<UploadFolder, number>> = {};\n\nconst HARD_CAP_MB = Math.max(MAX_UPLOAD_SIZE_MB, ...Object.values(FOLDER_MAX_SIZE_MB));\n\n// ─── Signed upload URL ───────────────────────────────────────────────────────\n\n/**\n * Request body for `POST /api/v1/files/signed-url`.\n *\n * `fileName` is restricted to a safe charset because it becomes part of the GCS\n * object path — the client sanitises before sending, and this rejects anything\n * that slipped through (path separators, control characters, unicode tricks).\n */\nexport const signedUploadUrlBodySchema = z\n .object({\n fileName: z\n .string()\n .min(1)\n .max(255)\n .regex(/^[a-zA-Z0-9_.-]+$/, 'Invalid file name'),\n contentType: uploadMimeTypeSchema,\n folder: uploadFolderSchema,\n fileSize: z\n .number()\n .int()\n .min(1)\n .max(HARD_CAP_MB * 1024 * 1024, `File must be smaller than ${HARD_CAP_MB}MB`),\n })\n .superRefine((data, ctx) => {\n // Tighten to the per-folder cap. The outer `.max` is only the widest\n // ceiling any folder allows; this is the one that actually applies.\n const capMb = FOLDER_MAX_SIZE_MB[data.folder] ?? MAX_UPLOAD_SIZE_MB;\n if (data.fileSize > capMb * 1024 * 1024) {\n ctx.addIssue({\n code: 'custom',\n path: ['fileSize'],\n message: `File must be smaller than ${capMb}MB for ${data.folder}`,\n });\n }\n });\n\nexport type SignedUploadUrlBody = z.infer<typeof signedUploadUrlBodySchema>;\n\n/** Response of `POST /api/v1/files/signed-url`. */\nexport const signedUploadUrlResponseSchema = z.object({\n /** Short-lived v4 signed URL — PUT the bytes here. */\n uploadUrl: z.string(),\n /** Permanent public URL. This is what gets stored in Mongo. */\n downloadUrl: z.string(),\n /** Object path inside the bucket, without the bucket prefix. Used to\n * delete an object the user abandoned before saving. */\n filePath: z.string(),\n /** When `uploadUrl` stops working. */\n expiresAt: z.iso.datetime(),\n});\n\nexport type SignedUploadUrlResponse = z.infer<typeof signedUploadUrlResponseSchema>;\n\n// ─── Cleanup ─────────────────────────────────────────────────────────────────\n\n/**\n * Body for `POST /api/v1/files/cleanup-uploads`. Called when a user removes a\n * photo they uploaded but never saved, so the orphaned object does not sit in\n * the bucket forever.\n *\n * Ownership is enforced per-path from the `userId` segment the backend embedded\n * at signing time. Paths belonging to someone else are skipped silently rather\n * than 403-ing, so one foreign entry cannot void a legitimate batch.\n */\nexport const cleanupUploadsBodySchema = z.object({\n filePaths: z.array(z.string().min(1)).min(1).max(20),\n});\n\nexport type CleanupUploadsBody = z.infer<typeof cleanupUploadsBodySchema>;\n\nexport const cleanupUploadsResponseSchema = z.object({\n deleted: z.number().int().min(0),\n skipped: z.number().int().min(0),\n});\n\nexport type CleanupUploadsResponse = z.infer<typeof cleanupUploadsResponseSchema>;\n"]}
|
package/dist/food.d.ts
CHANGED
|
@@ -492,7 +492,7 @@ export type Food = z.infer<typeof foodSchema>;
|
|
|
492
492
|
export declare const createFoodBodySchema: z.ZodObject<{
|
|
493
493
|
centerId: z.ZodNullable<z.ZodString>;
|
|
494
494
|
name: z.ZodString;
|
|
495
|
-
description: z.ZodPreprocess<z.ZodOptional<z.ZodString
|
|
495
|
+
description: z.ZodNullable<z.ZodPreprocess<z.ZodOptional<z.ZodString>>>;
|
|
496
496
|
category: z.ZodEnum<{
|
|
497
497
|
other: "other";
|
|
498
498
|
protein: "protein";
|
|
@@ -506,7 +506,7 @@ export declare const createFoodBodySchema: z.ZodObject<{
|
|
|
506
506
|
supplements: "supplements";
|
|
507
507
|
prepared: "prepared";
|
|
508
508
|
}>;
|
|
509
|
-
thumbnailUrl: z.ZodPreprocess<z.ZodOptional<z.ZodString
|
|
509
|
+
thumbnailUrl: z.ZodNullable<z.ZodPreprocess<z.ZodOptional<z.ZodString>>>;
|
|
510
510
|
nutrition: z.ZodOptional<z.ZodObject<{
|
|
511
511
|
calories: z.ZodNumber;
|
|
512
512
|
protein: z.ZodNumber;
|
|
@@ -575,7 +575,7 @@ export declare const updateFoodBodySchema: z.ZodObject<{
|
|
|
575
575
|
ref: z.ZodDefault<z.ZodString>;
|
|
576
576
|
note: z.ZodPreprocess<z.ZodOptional<z.ZodString>>;
|
|
577
577
|
}, z.core.$strip>>>;
|
|
578
|
-
description: z.ZodOptional<z.ZodPreprocess<z.ZodOptional<z.ZodString
|
|
578
|
+
description: z.ZodOptional<z.ZodNullable<z.ZodPreprocess<z.ZodOptional<z.ZodString>>>>;
|
|
579
579
|
category: z.ZodOptional<z.ZodEnum<{
|
|
580
580
|
other: "other";
|
|
581
581
|
protein: "protein";
|
|
@@ -589,7 +589,7 @@ export declare const updateFoodBodySchema: z.ZodObject<{
|
|
|
589
589
|
supplements: "supplements";
|
|
590
590
|
prepared: "prepared";
|
|
591
591
|
}>>;
|
|
592
|
-
thumbnailUrl: z.ZodOptional<z.ZodPreprocess<z.ZodOptional<z.ZodString
|
|
592
|
+
thumbnailUrl: z.ZodOptional<z.ZodNullable<z.ZodPreprocess<z.ZodOptional<z.ZodString>>>>;
|
|
593
593
|
dietType: z.ZodOptional<z.ZodOptional<z.ZodEnum<{
|
|
594
594
|
veg: "veg";
|
|
595
595
|
egg: "egg";
|
package/dist/food.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"food.d.ts","sourceRoot":"","sources":["../src/food.ts"],"names":[],"mappings":"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
|
|
1
|
+
{"version":3,"file":"food.d.ts","sourceRoot":"","sources":["../src/food.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAYxB;;;;;;;GAOG;AACH,eAAO,MAAM,kBAAkB;;;;;;;;;;;;EAY7B,CAAC;AACH,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,eAAO,MAAM,kBAAkB;;;;EAAoC,CAAC;AACpE,MAAM,MAAM,YAAY,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,kBAAkB,CAAC,CAAC;AAE9D,8EAA8E;AAC9E,eAAO,MAAM,oBAAoB,EAAE,MAAM,CAAC,YAAY,EAAE,MAAM,CAI7D,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,aAAa,CAAC,UAAU,EAAE,SAAS,OAAO,EAAE,GAAG,OAAO,CAErE;AAKD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,SAAS,YAAY,EAAE,GAAG,YAAY,CAK9E;AAID;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB;;;iBAK5B,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAI5D;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2BG;AACH,eAAO,MAAM,wBAAwB;;;;EAAuC,CAAC;AAC7E,MAAM,MAAM,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,wBAAwB,CAAC,CAAC;AAE1E,eAAO,MAAM,gBAAgB;;;;;;;;iBAM3B,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D,iFAAiF;AACjF,eAAO,MAAM,iBAAiB,EAAE,MAAM,CAAC,kBAAkB,EAAE,MAAM,CAIhE,CAAC;AAEF;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,qBAAqB;;;;iBAOhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAIpE;;;;;GAKG;AACH,eAAO,MAAM,yBAAyB;;;iBAIpC,CAAC;AACH,MAAM,MAAM,mBAAmB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,yBAAyB,CAAC,CAAC;AAE5E;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,gBAAgB;;;;;;iBAI3B,CAAC;AACH,MAAM,MAAM,UAAU,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,gBAAgB,CAAC,CAAC;AAE1D;;;;GAIG;AACH,eAAO,MAAM,8BAA8B;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAmBzC,CAAC;AACH,MAAM,MAAM,wBAAwB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,8BAA8B,CAAC,CAAC;AAEtF,eAAO,MAAM,qBAAqB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAWhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAIpE,eAAO,MAAM,iBAAiB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBA2D5B,CAAC;AACH,MAAM,MAAM,WAAW,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAI5D,eAAO,MAAM,UAAU;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAiDrB,CAAC;AACH,MAAM,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,CAAC;AA+E9C;;;;;;;;;;;;;;GAcG;AAEH;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAsD7B,CAAC;AACL,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE;;;;;GAKG;AACH,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAAwD,CAAC;AAC1F,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAIlE;;;;;;;GAOG;AACH,eAAO,MAAM,cAAc;;;EAAiC,CAAC;AAC7D,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAEtD;;;;;;;;;;GAUG;AACH,eAAO,MAAM,cAAc;;;;;;EAMzB,CAAC;AACH,MAAM,MAAM,QAAQ,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,cAAc,CAAC,CAAC;AAEtD;;;;;;;;;;;;GAYG;AACH,eAAO,MAAM,sBAAsB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;iBAkCjC,CAAC;AACH,MAAM,MAAM,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAC;AAEtE;;;;;;;;GAQG;AACH,eAAO,MAAM,qBAAqB;;;iBAGhC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC;AAQpE,eAAO,MAAM,oBAAoB;;;;;;;;;;;;iBAiB/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE,eAAO,MAAM,oBAAoB;;;;;;;;;;;;;iBAM/B,CAAC;AACH,MAAM,MAAM,cAAc,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,oBAAoB,CAAC,CAAC;AAElE,eAAO,MAAM,qBAAqB;;iBAShC,CAAC;AACH,MAAM,MAAM,eAAe,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,qBAAqB,CAAC,CAAC"}
|
package/dist/food.js
CHANGED
|
@@ -397,9 +397,22 @@ const foodBodyBaseSchema = z.object({
|
|
|
397
397
|
*/
|
|
398
398
|
centerId: objectIdSchema.nullable(),
|
|
399
399
|
name: z.string().trim().min(1, 'Name is required').max(120),
|
|
400
|
-
|
|
400
|
+
/**
|
|
401
|
+
* NULLABLE as well as optional, and the distinction is load-bearing.
|
|
402
|
+
*
|
|
403
|
+
* `optionalTrimmedString` maps an empty string to `undefined`, and `undefined`
|
|
404
|
+
* on the partial update body means "leave this alone". So without `null`
|
|
405
|
+
* there is no way to express REMOVAL: a gym could attach a photograph or a
|
|
406
|
+
* blurb and then never take it off again — the save would return 200 and
|
|
407
|
+
* change nothing, which is the worst kind of failure.
|
|
408
|
+
*
|
|
409
|
+
* undefined don't touch it (every field the request omits)
|
|
410
|
+
* null clear it
|
|
411
|
+
* string set it
|
|
412
|
+
*/
|
|
413
|
+
description: optionalTrimmedString(2000).nullable(),
|
|
401
414
|
category: foodCategorySchema,
|
|
402
|
-
thumbnailUrl: optionalTrimmedString(2000),
|
|
415
|
+
thumbnailUrl: optionalTrimmedString(2000).nullable(),
|
|
403
416
|
serving: foodServingSchema.nullable(),
|
|
404
417
|
/**
|
|
405
418
|
* Per 100g, and REQUIRED FOR A BASE FOOD ONLY.
|
package/dist/food.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"food.js","sourceRoot":"","sources":["../src/food.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Food library\n * ==============================\n * The catalog a diet chart is built from. Global entries (`centerId` null) plus\n * gym-custom ones a trainer authors, exactly as `exercise` works — the two\n * libraries are the same idea applied to the two halves of a member's plan, and\n * they are deliberately shaped alike so the scoping rule only has to be learned\n * once.\n *\n * Nutrition is stored PER 100 GRAMS (see `nutrition`), because that is how\n * packaging and every nutrition database quote it, and because a chart entry\n * stores grams and derives the rest. Correcting a food's calories therefore\n * corrects every chart that uses it, which is the whole reason the library\n * exists rather than each chart carrying typed-in numbers.\n *\n * @module gymmonk-schema/food\n */\n\nimport { z } from 'zod';\nimport {\n isoDateTimeSchema,\n objectIdSchema,\n optionalTrimmedString,\n paginationSchema,\n} from './common.js';\nimport { seedTallySchema } from './exercise.js';\nimport { nutritionSchema, scaledNutritionSchema } from './nutrition.js';\n\n// ─── Vocabulary ──────────────────────────────────────────────────────────────\n\n/**\n * Coarse shelves, not a taxonomy.\n *\n * Enough to filter a library of a few hundred items and no more. A finer\n * classification (cuisine, veg/non-veg, glycaemic index) is a property of the\n * food that can be added when something actually filters on it; guessing at it\n * now would produce categories nobody selects.\n */\nexport const foodCategorySchema = z.enum([\n 'grains',\n 'protein',\n 'dairy',\n 'vegetables',\n 'fruits',\n 'fats-oils',\n 'nuts-seeds',\n 'beverages',\n 'supplements',\n 'prepared',\n 'other',\n]);\nexport type FoodCategory = z.infer<typeof foodCategorySchema>;\n\n/**\n * Veg, egg or non-veg.\n *\n * The comment above says a finer classification should wait until something\n * actually filters on it. This is that something. In India this is not one\n * filter among several, it is THE filter: a pure-vegetarian member scrolling a\n * library that mixes mutton curry into the same list as dal is being shown food\n * they will never eat, and the category chips cannot help them because \"protein\"\n * holds both the dals and the fish.\n *\n * THREE values, not a boolean. Egg is its own answer, not a shade of one:\n * plenty of vegetarians here eat egg and plenty do not, and collapsing it into\n * either neighbour gives one of those two groups the wrong list. The seeded\n * \"Fat loss 1500 (veg + egg)\" chart is named after exactly that distinction.\n *\n * ## Stated for an ingredient, DERIVED for a dish\n * A dish takes the strictest value among its components — any non-veg\n * ingredient makes the dish non-veg, otherwise any egg makes it egg — by the\n * same rule and for the same reason its nutrition is derived: one fact, stored\n * once, corrected once. Marking the chicken correctly fixes every dish\n * containing it. See `strictestDietType`.\n */\nexport const foodDietTypeSchema = z.enum(['veg', 'egg', 'non-veg']);\nexport type FoodDietType = z.infer<typeof foodDietTypeSchema>;\n\n/** How each value is named on screen, so no surface invents its own label. */\nexport const FOOD_DIET_TYPE_LABEL: Record<FoodDietType, string> = {\n veg: 'Veg',\n egg: 'Egg',\n 'non-veg': 'Non-veg',\n};\n\n/**\n * Vegan — a SEPARATE axis, not a fourth `dietType`.\n *\n * It cannot be a fourth value because it does not sit on the same line. Paneer,\n * ghee, curd and honey are all unambiguously VEG under FSSAI's green dot and all\n * of them are excluded by a vegan, so one field would have to say two things\n * about the same food. Modelled as a fourth enum value, filtering \"veg\" would\n * then also have to mean \"and vegan\", and every consumer would need to know the\n * ordering to get a correct list.\n *\n * A boolean beside `dietType` says exactly what it means: this food contains\n * nothing animal-derived. Vegan implies veg, never the reverse, and the filter\n * chips can offer both without either lying.\n *\n * ## Stated for an ingredient, DERIVED for a dish\n * A dish is vegan only if EVERY component is — the same rule as the nutrition\n * and the veg mark, and derived for the same reason. See `isVeganRecipe`.\n */\nexport function isVeganRecipe(components: readonly boolean[]): boolean {\n return components.every(Boolean);\n}\n\n/** Strictness order, used to derive a dish's value from its ingredients'. */\nconst DIET_TYPE_RANK: Record<FoodDietType, number> = { veg: 0, egg: 1, 'non-veg': 2 };\n\n/**\n * The strictest value in a set — what a dish made of these ingredients is.\n *\n * Exported rather than inlined in the service because the seed generator, the\n * API and the recipe builder's preview all have to agree; three copies of a\n * two-line rule is how a dish ends up labelled veg on one screen and egg on\n * another. Empty input is `veg`, which is only reachable for a recipe with no\n * components, and the recipe schema already refuses that.\n */\nexport function strictestDietType(types: readonly FoodDietType[]): FoodDietType {\n return types.reduce<FoodDietType>(\n (worst, t) => (DIET_TYPE_RANK[t] > DIET_TYPE_RANK[worst] ? t : worst),\n 'veg',\n );\n}\n\n// ─── Serving ─────────────────────────────────────────────────────────────────\n\n/**\n * A household measure for a food, and the grams it actually weighs.\n *\n * Nobody at a desk thinks in grams of roti. They think \"2 rotis\", \"1 katori\n * dal\", \"1 scoop whey\" — and a chart written in grams alone is one a member has\n * to translate before every meal. The label is what the builder and the member\n * see; `grams` is what every calculation uses, so the stored quantity stays a\n * single unambiguous number and the display stays human.\n *\n * OPTIONAL, because plenty of foods have no natural unit. A food without one is\n * simply entered and shown in grams.\n */\nexport const foodServingSchema = z.object({\n /** \"1 roti\", \"1 katori\", \"1 scoop\". */\n label: z.string().trim().min(1).max(40),\n /** What one of them weighs. */\n grams: z.number().min(1).max(2000),\n});\nexport type FoodServing = z.infer<typeof foodServingSchema>;\n\n// ─── Provenance ──────────────────────────────────────────────────────────────\n\n/**\n * Where a food's numbers came from.\n *\n * Recorded because a diet chart is a health instruction, and \"300 kcal\" with no\n * provenance is an assertion nobody can check or correct. A trainer who\n * disagrees with a figure needs to know whether they are arguing with a\n * national food composition table or with somebody's best guess.\n *\n * `estimate` is a first-class value on purpose. The alternative to admitting an\n * estimate is not a better number, it is a FABRICATED citation — an FDC id that\n * looks authoritative and points at nothing. One honest label beats that.\n *\n * ## Why only these databases\n * - `usda` — FoodData Central. **Public domain (CC0 1.0)**, commercial use and\n * redistribution explicitly permitted, citation requested as a courtesy. The\n * default for anything it covers. `ref` is the FDC id, e.g. `168877`.\n * - `ifct` — Indian Food Composition Tables 2017, NIN Hyderabad. Used for the\n * Indian foods USDA does not carry, with acknowledgement. `ref` is the IFCT\n * food code, e.g. `A007`. ⚠️ Values are taken as facts and re-entered; the\n * machine-readable npm mirror of IFCT is **AGPL-3.0** and is deliberately not\n * a dependency of this product.\n * - `estimate` — a considered figure with no single citable record behind it,\n * typically a regional dish. `ref` stays empty and `note` says what it was\n * based on.\n *\n * A COMPOSED food needs none of this: its numbers are derived from its\n * ingredients, so its provenance is its recipe, and `source` stays null.\n */\nexport const foodSourceDatabaseSchema = z.enum(['usda', 'ifct', 'estimate']);\nexport type FoodSourceDatabase = z.infer<typeof foodSourceDatabaseSchema>;\n\nexport const foodSourceSchema = z.object({\n database: foodSourceDatabaseSchema,\n /** The record id within that database. Empty for an estimate. */\n ref: z.string().trim().max(120).default(''),\n /** What the figure was based on, when `ref` cannot say it. */\n note: optionalTrimmedString(300),\n});\nexport type FoodSource = z.infer<typeof foodSourceSchema>;\n\n/** How each database is named on screen, so no surface invents its own label. */\nexport const FOOD_SOURCE_LABEL: Record<FoodSourceDatabase, string> = {\n usda: 'USDA FoodData Central',\n ifct: 'IFCT 2017 (NIN India)',\n estimate: 'Reference estimate',\n};\n\n/**\n * Who took the photograph, and under what licence.\n *\n * A SECOND provenance record, separate from `source`, because the numbers and\n * the picture come from different places under different terms. USDA is CC0 and\n * owes nobody anything; the food photography is Wikimedia Commons, which is\n * overwhelmingly CC BY and CC BY-SA, and both of those REQUIRE the author to be\n * credited wherever the image is shown. An uncredited CC BY image is a licence\n * breach, not an untidy screen.\n *\n * Stored on the food rather than derived at render time because the fact\n * belongs to the file: it is settled when the image is chosen, and a page that\n * had to look it up could not render the credit offline.\n *\n * Null for the training-library case where artwork is public domain, and null\n * for a gym's own upload, whose photograph is its own.\n */\nexport const foodImageCreditSchema = z.object({\n /** The photographer, as the source names them. */\n author: z.string().trim().max(200),\n /** \"CC BY-SA 4.0\", \"CC BY 2.0\", \"Public domain\". */\n licence: z.string().trim().max(80),\n /** The file's page on the source site, so the claim can be checked. */\n sourceUrl: z.string().trim().max(500),\n});\nexport type FoodImageCredit = z.infer<typeof foodImageCreditSchema>;\n\n// ─── Recipe (a dish composed from raw ingredients) ───────────────────────────\n\n/**\n * One ingredient in a dish, by weight AS IT GOES IN — raw, uncooked.\n *\n * Referencing another food rather than restating numbers is the entire point:\n * correct the rice once and every dish containing rice corrects with it.\n */\nexport const foodRecipeComponentSchema = z.object({\n foodId: objectIdSchema,\n /** Raw grams of this ingredient in one batch of the dish. */\n grams: z.number().positive().max(20000),\n});\nexport type FoodRecipeComponent = z.infer<typeof foodRecipeComponentSchema>;\n\n/**\n * What a dish is made of, and what one batch of it WEIGHS when cooked.\n *\n * `yieldGrams` is the load-bearing field and the one nobody expects to need.\n * Cooking conserves nutrients and changes mass: 100 g of raw rice leaves the\n * pan as roughly 250 g of cooked rice, having gained only water. Dividing the\n * summed nutrition by the raw input mass would report every figure more than\n * twice too high, and a chart built on it would feed a member double what it\n * claims. See `composeNutrition`.\n *\n * ONE LEVEL DEEP. Components must be base foods, never other recipes. That is\n * enforced rather than merely intended: nesting invites cycles (A contains B\n * contains A), which is an infinite loop in a resolver that runs on every\n * library listing. A dish needing a cooked sub-preparation states its raw\n * ingredients directly instead.\n */\nexport const foodRecipeSchema = z.object({\n /** What one batch weighs when it is ready to eat. */\n yieldGrams: z.number().positive().max(50000),\n components: z.array(foodRecipeComponentSchema).min(1).max(40),\n});\nexport type FoodRecipe = z.infer<typeof foodRecipeSchema>;\n\n/**\n * A recipe as the API returns it: every component carries the ingredient's NAME\n * and its contribution, so a client can render the breakdown without fetching\n * the library and joining it by hand.\n */\nexport const populatedRecipeComponentSchema = foodRecipeComponentSchema.extend({\n slug: z.string(),\n name: z.string(),\n /**\n * Enough to draw the ingredient as a ROW, not a line of text.\n *\n * A recipe is the one place a member is comparing several foods against each\n * other at once — \"which part of this pulao is the fat?\" — and a list of bare\n * names cannot answer that. With the picture and the veg mark the list reads\n * the same way the library does, which is the point: it IS the library,\n * filtered to one dish.\n *\n * Both are already on the ingredient's own document, so this costs nothing\n * beyond the bytes.\n */\n thumbnailUrl: z.string().nullable(),\n dietType: foodDietTypeSchema,\n /** What this ingredient contributes to the batch, in absolute terms. */\n nutrition: scaledNutritionSchema,\n});\nexport type PopulatedRecipeComponent = z.infer<typeof populatedRecipeComponentSchema>;\n\nexport const populatedRecipeSchema = z.object({\n yieldGrams: z.number(),\n components: z.array(populatedRecipeComponentSchema),\n /**\n * Raw grams in, for the editor to show beside the yield.\n *\n * Shown because the RATIO is the sanity check a human can actually make: rice\n * dishes gain, grilled meat loses, and a yield that moves the wrong way is\n * visible at a glance in a way a bare number is not.\n */\n inputGrams: z.number(),\n});\nexport type PopulatedRecipe = z.infer<typeof populatedRecipeSchema>;\n\n// ─── Summary (library card) ──────────────────────────────────────────────────\n\nexport const foodSummarySchema = z.object({\n id: objectIdSchema,\n slug: z.string(),\n name: z.string(),\n category: foodCategorySchema,\n thumbnailUrl: z.string().nullable(),\n /**\n * Who authored this entry: `null` = the PLATFORM library, every gym sees it;\n * a center id = that gym's own, and only its people see it.\n *\n * On the summary and not just the detail, for the same reason `Exercise`\n * carries it there: whoever is scrolling the library needs to know which\n * cards are theirs to edit before tapping one.\n */\n centerId: objectIdSchema.nullable(),\n /** Per 100g. On the CARD because calories are what a library is scanned for. */\n calories: z.number(),\n /**\n * The three macros and fibre, per 100 g, ON THE SUMMARY.\n *\n * Calories alone was the original card and it was not enough. In a GYM's food\n * library the number people scan for is protein, not energy: the whole\n * question a member brings to this list is \"what should I eat to hit my\n * protein\", and a card showing only calories makes them open every row to\n * find out. Two foods at 350 kcal can be 22 g of protein or 2 g.\n *\n * Four extra numbers per row on a list that is now paginated, so this costs\n * about 60 bytes a card and saves a round trip per comparison.\n *\n * Flattened rather than nesting the whole `nutritionSchema`, because the\n * micronutrient array is the large part of that object and no card shows it.\n */\n protein: z.number(),\n carbs: z.number(),\n fat: z.number(),\n fibre: z.number(),\n /** Veg / egg / non-veg. On the card as a dot, and a first-class filter. */\n dietType: foodDietTypeSchema,\n /**\n * Nothing animal-derived. A SEPARATE axis from `dietType` — see\n * `isVeganRecipe`. On the summary because it is a filter, not because any\n * card draws it: a second mark beside the veg dot would be noise on the\n * eighty percent of rows where it says the same thing.\n */\n isVegan: z.boolean(),\n serving: foodServingSchema.nullable(),\n /**\n * Is this a DISH built from other foods, or a measured ingredient?\n *\n * On the summary because two screens need it before they can open anything.\n * The library grid distinguishes \"Roti\" from \"Atta\" — in a list of 150 rows\n * they read alike and behave completely differently. And the recipe builder's\n * ingredient picker must not offer a dish at all: a recipe made of recipes can\n * cycle, the API refuses it, and a picker that lists the option only to have\n * the save rejected is a trap.\n *\n * Derived from `recipe`, never stored twice.\n */\n isRecipe: z.boolean(),\n});\nexport type FoodSummary = z.infer<typeof foodSummarySchema>;\n\n// ─── Detail (full entity) ────────────────────────────────────────────────────\n\nexport const foodSchema = foodSummarySchema.extend({\n description: z.string(),\n /** The whole profile, per 100g. `calories` above is `nutrition.calories`. */\n nutrition: nutritionSchema,\n /**\n * What the food is made of, as free text lines.\n *\n * For a prepared dish — \"poha\", \"protein shake\" — a member and a trainer both\n * want to know what is in it, and a gym is not going to decompose every\n * recipe into library rows with weights. These are descriptive, carry no\n * nutrition of their own, and are never summed: the food's own per-100g\n * profile is the number of record.\n */\n ingredients: z.array(z.string()),\n /**\n * Where the numbers came from. Null for a composed dish, whose provenance is\n * its `recipe` rather than a citation.\n */\n source: foodSourceSchema.nullable(),\n /**\n * What it is made of, when it is made of other foods.\n *\n * Null for a BASE ingredient — raw rice, an egg, mustard oil — which is\n * measured directly and carries a `source` instead. Present for a DISH, whose\n * `nutrition` above is then wholly derived from these components and never\n * typed in by hand. See `composeNutrition`.\n */\n recipe: populatedRecipeSchema.nullable(),\n /**\n * Who to credit for the photograph. Null when there is no image, or when the\n * image is ours or public domain. See `foodImageCreditSchema`.\n */\n imageCredit: foodImageCreditSchema.nullable(),\n /**\n * The dishes built from this ingredient. Empty for a dish.\n *\n * The reverse of `recipe`, and the half of the composition model that had\n * nowhere to show. A member looking at raw toor dal has no idea why it is in\n * the library until they can see that Dal tadka, Sambar and Khichdi are all\n * worked out from it — at which point the ingredient/dish distinction the\n * whole feature rests on explains itself without a paragraph of help text.\n *\n * Computed per request from `recipe.components.foodId`, never stored: a\n * stored back-reference is a second copy of the edge that goes stale the\n * moment a recipe is edited.\n */\n usedIn: z.array(foodSummarySchema),\n createdAt: isoDateTimeSchema,\n updatedAt: isoDateTimeSchema,\n});\nexport type Food = z.infer<typeof foodSchema>;\n\n// ─── Create / update body ────────────────────────────────────────────────────\n\n/**\n * The writable shape, BEFORE the create-only cross-field rule is attached.\n *\n * Split out because `updateFoodBodySchema` derives from it with `.omit().partial()`,\n * and in Zod v4 those throw at MODULE LOAD when applied to a schema carrying a\n * `.superRefine()`. That failure is invisible to `tsc` and to the bundler; it\n * surfaces as a container that will not start. See\n * `feedback_zod4_partial_on_refined` — this file is exactly the shape that trips\n * it, so the refinement goes on the export and never on the base.\n */\nconst foodBodyBaseSchema = z.object({\n /**\n * Which library this entry joins. `null` is the PLATFORM library — content the\n * product publishes to every gym — and only platform staff may send it.\n *\n * Nullable rather than optional, for the reason `createExerciseBodySchema`\n * spells out: an omitted field would default to global, so one forgetful\n * client could publish a gym's own recipes to every competitor on the\n * product. Going global has to be stated.\n */\n centerId: objectIdSchema.nullable(),\n name: z.string().trim().min(1, 'Name is required').max(120),\n description: optionalTrimmedString(2000),\n category: foodCategorySchema,\n thumbnailUrl: optionalTrimmedString(2000),\n serving: foodServingSchema.nullable(),\n /**\n * Per 100g, and REQUIRED FOR A BASE FOOD ONLY.\n *\n * Optional here rather than mandatory because a dish does not have one to\n * send: its profile is computed from `recipe`. The rule that exactly one of\n * the two must be present is enforced below, where it can be stated once and\n * read as a sentence.\n */\n nutrition: nutritionSchema.optional(),\n /**\n * Veg / egg / non-veg, and REQUIRED FOR A BASE FOOD ONLY — exactly like\n * `nutrition` above and enforced by the same refinement.\n *\n * A dish must not send one: it is derived from the ingredients, so a typed\n * value would be a second answer free to disagree the moment a component\n * changes. Marking chicken curry \"veg\" by hand is a mistake nobody would\n * catch, and it would be shown to the member who most needs it right.\n */\n dietType: foodDietTypeSchema.optional(),\n /**\n * Vegan, and REQUIRED FOR A BASE FOOD ONLY, exactly like `dietType`.\n *\n * A dish must not send one: it is derived from the ingredients, so a typed\n * value could claim a paneer dish is vegan and nothing downstream would\n * disagree.\n */\n isVegan: z.boolean().optional(),\n ingredients: z.array(z.string().trim().max(200)).max(40),\n /** Where the numbers came from. A dish leaves this null. */\n source: foodSourceSchema.nullable(),\n /** Who to credit for the photograph. */\n imageCredit: foodImageCreditSchema.nullable(),\n /** What it is made of. A base ingredient leaves this null. */\n recipe: foodRecipeSchema.nullable(),\n});\n\n/**\n * ⚠️ NO `.default()` ANYWHERE IN THE BASE ABOVE. This is not a style choice.\n *\n * `.partial()` makes a field optional; it does NOT switch its default off. So a\n * base carrying `serving: ….default(null)` produces, for the update body\n * `{ name: 'Rice' }`, the parsed object `{ name, serving: null, ingredients: [],\n * source: null, recipe: null }` — and `updateFood` does `$set: body`. Renaming a\n * food would silently erase its serving size, its ingredient list, its citation\n * and, worst of all, the recipe a dish's entire nutrition is derived from.\n *\n * That was already live for `serving` and `ingredients` before this module\n * grew a recipe; it is fixed here for all four at once. The defaults live on\n * the CREATE schema below, which is the only place they are wanted, and the\n * behaviour is pinned by a test.\n */\n\n/**\n * A food is EITHER measured or composed, never both and never neither.\n *\n * Both would mean two answers to \"how many calories\": one typed, one derived,\n * free to disagree the moment an ingredient is corrected — and nothing on\n * screen would say which one a chart used. Neither would mean a library card\n * with no number on it at all, in a library that exists to be scanned for\n * exactly that number.\n */\nexport const createFoodBodySchema = foodBodyBaseSchema\n .extend({\n // Defaults belong to CREATE alone — see the warning above the base.\n serving: foodServingSchema.nullable().default(null),\n ingredients: z.array(z.string().trim().max(200)).max(40).default([]),\n source: foodSourceSchema.nullable().default(null),\n imageCredit: foodImageCreditSchema.nullable().default(null),\n recipe: foodRecipeSchema.nullable().default(null),\n })\n .superRefine((value, ctx) => {\n if (value.recipe && value.nutrition) {\n ctx.addIssue({\n code: 'custom',\n path: ['nutrition'],\n message: 'A food built from a recipe works its own nutrition out. Send one or the other.',\n });\n }\n if (!value.recipe && !value.nutrition) {\n ctx.addIssue({\n code: 'custom',\n path: ['nutrition'],\n message: 'Enter the nutrition per 100 g, or build the food from a recipe.',\n });\n }\n // The same either/or, applied to the veg flag. Stated for an ingredient,\n // derived for a dish — see `foodDietTypeSchema`.\n if (value.recipe && value.dietType) {\n ctx.addIssue({\n code: 'custom',\n path: ['dietType'],\n message: 'A dish takes veg / non-veg from its ingredients. Leave this off.',\n });\n }\n if (!value.recipe && !value.dietType) {\n ctx.addIssue({\n code: 'custom',\n path: ['dietType'],\n message: 'Say whether this is veg, egg or non-veg.',\n });\n }\n if (value.recipe && value.isVegan !== undefined) {\n ctx.addIssue({\n code: 'custom',\n path: ['isVegan'],\n message: 'A dish is vegan only if every ingredient is. Leave this off.',\n });\n }\n if (!value.recipe && value.isVegan === undefined) {\n ctx.addIssue({\n code: 'custom',\n path: ['isVegan'],\n message: 'Say whether this contains anything animal-derived.',\n });\n }\n });\nexport type CreateFoodBody = z.infer<typeof createFoodBodySchema>;\n\n/**\n * Derived from the BASE, not from `createFoodBodySchema` — see the note there.\n * A partial update cannot carry the either/or rule anyway: a request changing\n * only the name says nothing about nutrition, and the service applies the rule\n * against the merged document instead.\n */\nexport const updateFoodBodySchema = foodBodyBaseSchema.omit({ centerId: true }).partial();\nexport type UpdateFoodBody = z.infer<typeof updateFoodBodySchema>;\n\n// ─── Library query ───────────────────────────────────────────────────────────\n\n/**\n * Which shelf of the library to list.\n *\n * The distinction `isRecipe` already draws, promoted to a query parameter\n * because it is the primary way the library is browsed rather than a refinement\n * of it. Nobody eats raw toor dal or maida; putting them in the same scroll as\n * chicken biryani is what made a 154-row library unreadable.\n */\nexport const foodKindSchema = z.enum(['dish', 'ingredient']);\nexport type FoodKind = z.infer<typeof foodKindSchema>;\n\n/**\n * List ordering, applied IN THE DATABASE.\n *\n * It has to be the server's: with a paginated list, a client that sorts what it\n * has sorts one page, so \"highest protein\" would mean \"highest protein among\n * the twenty rows that happened to arrive\".\n *\n * `protein-desc` is here because it is the sort this library exists for. A gym\n * member asking \"what should I eat\" is asking for protein per 100 g, and no\n * other ordering answers it.\n */\nexport const foodSortSchema = z.enum([\n 'name',\n 'name-desc',\n 'calories-desc',\n 'calories-asc',\n 'protein-desc',\n]);\nexport type FoodSort = z.infer<typeof foodSortSchema>;\n\n/**\n * The food-library list query.\n *\n * PAGINATED, filtered and sorted server-side. It used to return every row and\n * let the client narrow them, which is how the training library still works —\n * fine at 136 fixed rows, wrong here, because the gym-authored half of this\n * library is the part that grows and no client-side filter can page it.\n *\n * ⚠️ `limit` now DEFAULTS, so a caller that sends nothing gets page 1 rather\n * than everything. Every consumer of this endpoint had to be converted in the\n * same change: a picker silently showing 30 of 154 foods is worse than a slow\n * one, because it looks like the food is missing rather than late.\n */\nexport const foodLibraryQuerySchema = paginationSchema.extend({\n centerId: objectIdSchema.optional(),\n category: foodCategorySchema.optional(),\n kind: foodKindSchema.optional(),\n dietType: foodDietTypeSchema.optional(),\n /**\n * Vegan only. Absent or `false` means \"do not filter\", never \"not vegan\".\n *\n * ⚠️ `z.coerce.boolean()` is deliberately NOT used: it maps the string\n * \"false\" to `true`, because a non-empty string is truthy. That is the whole\n * trap this field exists to avoid.\n *\n * An ENUM of the two literals rather than `z.literal('true')` alone: with only\n * the one literal, `?isVegan=false` is a validation error and the request\n * 422s. Nothing sends that today — the client omits the param when the toggle\n * is off — but \"off\" 422ing is a trap for the next caller, and a bookmarked\n * URL is a caller. Anything that is neither literal still fails loudly, which\n * is right: `?isVegan=1` is a bug, not an opinion.\n */\n isVegan: z\n .enum(['true', 'false'])\n .optional()\n .transform((v) => (v === 'true' ? true : undefined)),\n q: optionalTrimmedString(120),\n sort: foodSortSchema.default('name'),\n /**\n * Only foods at or above this protein per 100 g.\n *\n * A number rather than a `highProtein: true` flag, so the threshold is the\n * caller's and the API is not the place a fitness opinion gets hard-coded.\n * The UI offers 15 g, which is roughly where a food stops being a carb with\n * protein in it.\n */\n minProtein: z.coerce.number().min(0).max(100).optional(),\n});\nexport type FoodLibraryQuery = z.infer<typeof foodLibraryQuerySchema>;\n\n/**\n * Counts for the shelf switch, returned beside the first page.\n *\n * The tab strip has to say \"Dishes 64 / Ingredients 90\" before either shelf is\n * opened, and a paginated list cannot tell it that: `meta.pagination.total`\n * counts the shelf being viewed. These are counted under the SAME filters as\n * the list except `kind`, so the numbers change with a search the way the user\n * expects and a shelf showing \"12\" never opens onto nothing.\n */\nexport const foodShelfCountsSchema = z.object({\n dish: z.number().int(),\n ingredient: z.number().int(),\n});\nexport type FoodShelfCounts = z.infer<typeof foodShelfCountsSchema>;\n\n// ─── Platform seed ───────────────────────────────────────────────────────────\n//\n// Mirrors the training library's seed contract (`exercise.ts`) so the two\n// consoles are the same screen twice, and `seedTallySchema` is shared rather\n// than redeclared.\n\nexport const foodSeedStatusSchema = z.object({\n /** What the content file ships. */\n available: z.object({\n /** Measured ingredients carrying a citation. */\n baseFoods: z.number().int(),\n /** Dishes composed from those ingredients. */\n recipeFoods: z.number().int(),\n charts: z.number().int(),\n /** Of the base foods, how many cite a real database rather than an estimate. */\n cited: z.number().int(),\n }),\n /** What the PLATFORM shelf currently holds in the database. */\n seeded: z.object({\n foods: z.number().int(),\n recipeFoods: z.number().int(),\n charts: z.number().int(),\n }),\n});\nexport type FoodSeedStatus = z.infer<typeof foodSeedStatusSchema>;\n\nexport const foodSeedReportSchema = z.object({\n foods: seedTallySchema,\n charts: seedTallySchema,\n /** Ingredient or food slugs a recipe or chart named that do not exist. */\n missingSlugs: z.array(z.string()),\n errors: z.array(z.string()),\n});\nexport type FoodSeedReport = z.infer<typeof foodSeedReportSchema>;\n\nexport const runFoodSeedBodySchema = z.object({\n /**\n * Load the library but skip the starter charts.\n *\n * The one case where a full re-seed destroys work: a gym that has edited a\n * starter chart in place would have those edits overwritten, while the foods\n * underneath are safe to refresh at any time.\n */\n foodsOnly: z.boolean().optional(),\n});\nexport type RunFoodSeedBody = z.infer<typeof runFoodSeedBodySchema>;\n"]}
|
|
1
|
+
{"version":3,"file":"food.js","sourceRoot":"","sources":["../src/food.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * gymmonk-schema — Food library\n * ==============================\n * The catalog a diet chart is built from. Global entries (`centerId` null) plus\n * gym-custom ones a trainer authors, exactly as `exercise` works — the two\n * libraries are the same idea applied to the two halves of a member's plan, and\n * they are deliberately shaped alike so the scoping rule only has to be learned\n * once.\n *\n * Nutrition is stored PER 100 GRAMS (see `nutrition`), because that is how\n * packaging and every nutrition database quote it, and because a chart entry\n * stores grams and derives the rest. Correcting a food's calories therefore\n * corrects every chart that uses it, which is the whole reason the library\n * exists rather than each chart carrying typed-in numbers.\n *\n * @module gymmonk-schema/food\n */\n\nimport { z } from 'zod';\nimport {\n isoDateTimeSchema,\n objectIdSchema,\n optionalTrimmedString,\n paginationSchema,\n} from './common.js';\nimport { seedTallySchema } from './exercise.js';\nimport { nutritionSchema, scaledNutritionSchema } from './nutrition.js';\n\n// ─── Vocabulary ──────────────────────────────────────────────────────────────\n\n/**\n * Coarse shelves, not a taxonomy.\n *\n * Enough to filter a library of a few hundred items and no more. A finer\n * classification (cuisine, veg/non-veg, glycaemic index) is a property of the\n * food that can be added when something actually filters on it; guessing at it\n * now would produce categories nobody selects.\n */\nexport const foodCategorySchema = z.enum([\n 'grains',\n 'protein',\n 'dairy',\n 'vegetables',\n 'fruits',\n 'fats-oils',\n 'nuts-seeds',\n 'beverages',\n 'supplements',\n 'prepared',\n 'other',\n]);\nexport type FoodCategory = z.infer<typeof foodCategorySchema>;\n\n/**\n * Veg, egg or non-veg.\n *\n * The comment above says a finer classification should wait until something\n * actually filters on it. This is that something. In India this is not one\n * filter among several, it is THE filter: a pure-vegetarian member scrolling a\n * library that mixes mutton curry into the same list as dal is being shown food\n * they will never eat, and the category chips cannot help them because \"protein\"\n * holds both the dals and the fish.\n *\n * THREE values, not a boolean. Egg is its own answer, not a shade of one:\n * plenty of vegetarians here eat egg and plenty do not, and collapsing it into\n * either neighbour gives one of those two groups the wrong list. The seeded\n * \"Fat loss 1500 (veg + egg)\" chart is named after exactly that distinction.\n *\n * ## Stated for an ingredient, DERIVED for a dish\n * A dish takes the strictest value among its components — any non-veg\n * ingredient makes the dish non-veg, otherwise any egg makes it egg — by the\n * same rule and for the same reason its nutrition is derived: one fact, stored\n * once, corrected once. Marking the chicken correctly fixes every dish\n * containing it. See `strictestDietType`.\n */\nexport const foodDietTypeSchema = z.enum(['veg', 'egg', 'non-veg']);\nexport type FoodDietType = z.infer<typeof foodDietTypeSchema>;\n\n/** How each value is named on screen, so no surface invents its own label. */\nexport const FOOD_DIET_TYPE_LABEL: Record<FoodDietType, string> = {\n veg: 'Veg',\n egg: 'Egg',\n 'non-veg': 'Non-veg',\n};\n\n/**\n * Vegan — a SEPARATE axis, not a fourth `dietType`.\n *\n * It cannot be a fourth value because it does not sit on the same line. Paneer,\n * ghee, curd and honey are all unambiguously VEG under FSSAI's green dot and all\n * of them are excluded by a vegan, so one field would have to say two things\n * about the same food. Modelled as a fourth enum value, filtering \"veg\" would\n * then also have to mean \"and vegan\", and every consumer would need to know the\n * ordering to get a correct list.\n *\n * A boolean beside `dietType` says exactly what it means: this food contains\n * nothing animal-derived. Vegan implies veg, never the reverse, and the filter\n * chips can offer both without either lying.\n *\n * ## Stated for an ingredient, DERIVED for a dish\n * A dish is vegan only if EVERY component is — the same rule as the nutrition\n * and the veg mark, and derived for the same reason. See `isVeganRecipe`.\n */\nexport function isVeganRecipe(components: readonly boolean[]): boolean {\n return components.every(Boolean);\n}\n\n/** Strictness order, used to derive a dish's value from its ingredients'. */\nconst DIET_TYPE_RANK: Record<FoodDietType, number> = { veg: 0, egg: 1, 'non-veg': 2 };\n\n/**\n * The strictest value in a set — what a dish made of these ingredients is.\n *\n * Exported rather than inlined in the service because the seed generator, the\n * API and the recipe builder's preview all have to agree; three copies of a\n * two-line rule is how a dish ends up labelled veg on one screen and egg on\n * another. Empty input is `veg`, which is only reachable for a recipe with no\n * components, and the recipe schema already refuses that.\n */\nexport function strictestDietType(types: readonly FoodDietType[]): FoodDietType {\n return types.reduce<FoodDietType>(\n (worst, t) => (DIET_TYPE_RANK[t] > DIET_TYPE_RANK[worst] ? t : worst),\n 'veg',\n );\n}\n\n// ─── Serving ─────────────────────────────────────────────────────────────────\n\n/**\n * A household measure for a food, and the grams it actually weighs.\n *\n * Nobody at a desk thinks in grams of roti. They think \"2 rotis\", \"1 katori\n * dal\", \"1 scoop whey\" — and a chart written in grams alone is one a member has\n * to translate before every meal. The label is what the builder and the member\n * see; `grams` is what every calculation uses, so the stored quantity stays a\n * single unambiguous number and the display stays human.\n *\n * OPTIONAL, because plenty of foods have no natural unit. A food without one is\n * simply entered and shown in grams.\n */\nexport const foodServingSchema = z.object({\n /** \"1 roti\", \"1 katori\", \"1 scoop\". */\n label: z.string().trim().min(1).max(40),\n /** What one of them weighs. */\n grams: z.number().min(1).max(2000),\n});\nexport type FoodServing = z.infer<typeof foodServingSchema>;\n\n// ─── Provenance ──────────────────────────────────────────────────────────────\n\n/**\n * Where a food's numbers came from.\n *\n * Recorded because a diet chart is a health instruction, and \"300 kcal\" with no\n * provenance is an assertion nobody can check or correct. A trainer who\n * disagrees with a figure needs to know whether they are arguing with a\n * national food composition table or with somebody's best guess.\n *\n * `estimate` is a first-class value on purpose. The alternative to admitting an\n * estimate is not a better number, it is a FABRICATED citation — an FDC id that\n * looks authoritative and points at nothing. One honest label beats that.\n *\n * ## Why only these databases\n * - `usda` — FoodData Central. **Public domain (CC0 1.0)**, commercial use and\n * redistribution explicitly permitted, citation requested as a courtesy. The\n * default for anything it covers. `ref` is the FDC id, e.g. `168877`.\n * - `ifct` — Indian Food Composition Tables 2017, NIN Hyderabad. Used for the\n * Indian foods USDA does not carry, with acknowledgement. `ref` is the IFCT\n * food code, e.g. `A007`. ⚠️ Values are taken as facts and re-entered; the\n * machine-readable npm mirror of IFCT is **AGPL-3.0** and is deliberately not\n * a dependency of this product.\n * - `estimate` — a considered figure with no single citable record behind it,\n * typically a regional dish. `ref` stays empty and `note` says what it was\n * based on.\n *\n * A COMPOSED food needs none of this: its numbers are derived from its\n * ingredients, so its provenance is its recipe, and `source` stays null.\n */\nexport const foodSourceDatabaseSchema = z.enum(['usda', 'ifct', 'estimate']);\nexport type FoodSourceDatabase = z.infer<typeof foodSourceDatabaseSchema>;\n\nexport const foodSourceSchema = z.object({\n database: foodSourceDatabaseSchema,\n /** The record id within that database. Empty for an estimate. */\n ref: z.string().trim().max(120).default(''),\n /** What the figure was based on, when `ref` cannot say it. */\n note: optionalTrimmedString(300),\n});\nexport type FoodSource = z.infer<typeof foodSourceSchema>;\n\n/** How each database is named on screen, so no surface invents its own label. */\nexport const FOOD_SOURCE_LABEL: Record<FoodSourceDatabase, string> = {\n usda: 'USDA FoodData Central',\n ifct: 'IFCT 2017 (NIN India)',\n estimate: 'Reference estimate',\n};\n\n/**\n * Who took the photograph, and under what licence.\n *\n * A SECOND provenance record, separate from `source`, because the numbers and\n * the picture come from different places under different terms. USDA is CC0 and\n * owes nobody anything; the food photography is Wikimedia Commons, which is\n * overwhelmingly CC BY and CC BY-SA, and both of those REQUIRE the author to be\n * credited wherever the image is shown. An uncredited CC BY image is a licence\n * breach, not an untidy screen.\n *\n * Stored on the food rather than derived at render time because the fact\n * belongs to the file: it is settled when the image is chosen, and a page that\n * had to look it up could not render the credit offline.\n *\n * Null for the training-library case where artwork is public domain, and null\n * for a gym's own upload, whose photograph is its own.\n */\nexport const foodImageCreditSchema = z.object({\n /** The photographer, as the source names them. */\n author: z.string().trim().max(200),\n /** \"CC BY-SA 4.0\", \"CC BY 2.0\", \"Public domain\". */\n licence: z.string().trim().max(80),\n /** The file's page on the source site, so the claim can be checked. */\n sourceUrl: z.string().trim().max(500),\n});\nexport type FoodImageCredit = z.infer<typeof foodImageCreditSchema>;\n\n// ─── Recipe (a dish composed from raw ingredients) ───────────────────────────\n\n/**\n * One ingredient in a dish, by weight AS IT GOES IN — raw, uncooked.\n *\n * Referencing another food rather than restating numbers is the entire point:\n * correct the rice once and every dish containing rice corrects with it.\n */\nexport const foodRecipeComponentSchema = z.object({\n foodId: objectIdSchema,\n /** Raw grams of this ingredient in one batch of the dish. */\n grams: z.number().positive().max(20000),\n});\nexport type FoodRecipeComponent = z.infer<typeof foodRecipeComponentSchema>;\n\n/**\n * What a dish is made of, and what one batch of it WEIGHS when cooked.\n *\n * `yieldGrams` is the load-bearing field and the one nobody expects to need.\n * Cooking conserves nutrients and changes mass: 100 g of raw rice leaves the\n * pan as roughly 250 g of cooked rice, having gained only water. Dividing the\n * summed nutrition by the raw input mass would report every figure more than\n * twice too high, and a chart built on it would feed a member double what it\n * claims. See `composeNutrition`.\n *\n * ONE LEVEL DEEP. Components must be base foods, never other recipes. That is\n * enforced rather than merely intended: nesting invites cycles (A contains B\n * contains A), which is an infinite loop in a resolver that runs on every\n * library listing. A dish needing a cooked sub-preparation states its raw\n * ingredients directly instead.\n */\nexport const foodRecipeSchema = z.object({\n /** What one batch weighs when it is ready to eat. */\n yieldGrams: z.number().positive().max(50000),\n components: z.array(foodRecipeComponentSchema).min(1).max(40),\n});\nexport type FoodRecipe = z.infer<typeof foodRecipeSchema>;\n\n/**\n * A recipe as the API returns it: every component carries the ingredient's NAME\n * and its contribution, so a client can render the breakdown without fetching\n * the library and joining it by hand.\n */\nexport const populatedRecipeComponentSchema = foodRecipeComponentSchema.extend({\n slug: z.string(),\n name: z.string(),\n /**\n * Enough to draw the ingredient as a ROW, not a line of text.\n *\n * A recipe is the one place a member is comparing several foods against each\n * other at once — \"which part of this pulao is the fat?\" — and a list of bare\n * names cannot answer that. With the picture and the veg mark the list reads\n * the same way the library does, which is the point: it IS the library,\n * filtered to one dish.\n *\n * Both are already on the ingredient's own document, so this costs nothing\n * beyond the bytes.\n */\n thumbnailUrl: z.string().nullable(),\n dietType: foodDietTypeSchema,\n /** What this ingredient contributes to the batch, in absolute terms. */\n nutrition: scaledNutritionSchema,\n});\nexport type PopulatedRecipeComponent = z.infer<typeof populatedRecipeComponentSchema>;\n\nexport const populatedRecipeSchema = z.object({\n yieldGrams: z.number(),\n components: z.array(populatedRecipeComponentSchema),\n /**\n * Raw grams in, for the editor to show beside the yield.\n *\n * Shown because the RATIO is the sanity check a human can actually make: rice\n * dishes gain, grilled meat loses, and a yield that moves the wrong way is\n * visible at a glance in a way a bare number is not.\n */\n inputGrams: z.number(),\n});\nexport type PopulatedRecipe = z.infer<typeof populatedRecipeSchema>;\n\n// ─── Summary (library card) ──────────────────────────────────────────────────\n\nexport const foodSummarySchema = z.object({\n id: objectIdSchema,\n slug: z.string(),\n name: z.string(),\n category: foodCategorySchema,\n thumbnailUrl: z.string().nullable(),\n /**\n * Who authored this entry: `null` = the PLATFORM library, every gym sees it;\n * a center id = that gym's own, and only its people see it.\n *\n * On the summary and not just the detail, for the same reason `Exercise`\n * carries it there: whoever is scrolling the library needs to know which\n * cards are theirs to edit before tapping one.\n */\n centerId: objectIdSchema.nullable(),\n /** Per 100g. On the CARD because calories are what a library is scanned for. */\n calories: z.number(),\n /**\n * The three macros and fibre, per 100 g, ON THE SUMMARY.\n *\n * Calories alone was the original card and it was not enough. In a GYM's food\n * library the number people scan for is protein, not energy: the whole\n * question a member brings to this list is \"what should I eat to hit my\n * protein\", and a card showing only calories makes them open every row to\n * find out. Two foods at 350 kcal can be 22 g of protein or 2 g.\n *\n * Four extra numbers per row on a list that is now paginated, so this costs\n * about 60 bytes a card and saves a round trip per comparison.\n *\n * Flattened rather than nesting the whole `nutritionSchema`, because the\n * micronutrient array is the large part of that object and no card shows it.\n */\n protein: z.number(),\n carbs: z.number(),\n fat: z.number(),\n fibre: z.number(),\n /** Veg / egg / non-veg. On the card as a dot, and a first-class filter. */\n dietType: foodDietTypeSchema,\n /**\n * Nothing animal-derived. A SEPARATE axis from `dietType` — see\n * `isVeganRecipe`. On the summary because it is a filter, not because any\n * card draws it: a second mark beside the veg dot would be noise on the\n * eighty percent of rows where it says the same thing.\n */\n isVegan: z.boolean(),\n serving: foodServingSchema.nullable(),\n /**\n * Is this a DISH built from other foods, or a measured ingredient?\n *\n * On the summary because two screens need it before they can open anything.\n * The library grid distinguishes \"Roti\" from \"Atta\" — in a list of 150 rows\n * they read alike and behave completely differently. And the recipe builder's\n * ingredient picker must not offer a dish at all: a recipe made of recipes can\n * cycle, the API refuses it, and a picker that lists the option only to have\n * the save rejected is a trap.\n *\n * Derived from `recipe`, never stored twice.\n */\n isRecipe: z.boolean(),\n});\nexport type FoodSummary = z.infer<typeof foodSummarySchema>;\n\n// ─── Detail (full entity) ────────────────────────────────────────────────────\n\nexport const foodSchema = foodSummarySchema.extend({\n description: z.string(),\n /** The whole profile, per 100g. `calories` above is `nutrition.calories`. */\n nutrition: nutritionSchema,\n /**\n * What the food is made of, as free text lines.\n *\n * For a prepared dish — \"poha\", \"protein shake\" — a member and a trainer both\n * want to know what is in it, and a gym is not going to decompose every\n * recipe into library rows with weights. These are descriptive, carry no\n * nutrition of their own, and are never summed: the food's own per-100g\n * profile is the number of record.\n */\n ingredients: z.array(z.string()),\n /**\n * Where the numbers came from. Null for a composed dish, whose provenance is\n * its `recipe` rather than a citation.\n */\n source: foodSourceSchema.nullable(),\n /**\n * What it is made of, when it is made of other foods.\n *\n * Null for a BASE ingredient — raw rice, an egg, mustard oil — which is\n * measured directly and carries a `source` instead. Present for a DISH, whose\n * `nutrition` above is then wholly derived from these components and never\n * typed in by hand. See `composeNutrition`.\n */\n recipe: populatedRecipeSchema.nullable(),\n /**\n * Who to credit for the photograph. Null when there is no image, or when the\n * image is ours or public domain. See `foodImageCreditSchema`.\n */\n imageCredit: foodImageCreditSchema.nullable(),\n /**\n * The dishes built from this ingredient. Empty for a dish.\n *\n * The reverse of `recipe`, and the half of the composition model that had\n * nowhere to show. A member looking at raw toor dal has no idea why it is in\n * the library until they can see that Dal tadka, Sambar and Khichdi are all\n * worked out from it — at which point the ingredient/dish distinction the\n * whole feature rests on explains itself without a paragraph of help text.\n *\n * Computed per request from `recipe.components.foodId`, never stored: a\n * stored back-reference is a second copy of the edge that goes stale the\n * moment a recipe is edited.\n */\n usedIn: z.array(foodSummarySchema),\n createdAt: isoDateTimeSchema,\n updatedAt: isoDateTimeSchema,\n});\nexport type Food = z.infer<typeof foodSchema>;\n\n// ─── Create / update body ────────────────────────────────────────────────────\n\n/**\n * The writable shape, BEFORE the create-only cross-field rule is attached.\n *\n * Split out because `updateFoodBodySchema` derives from it with `.omit().partial()`,\n * and in Zod v4 those throw at MODULE LOAD when applied to a schema carrying a\n * `.superRefine()`. That failure is invisible to `tsc` and to the bundler; it\n * surfaces as a container that will not start. See\n * `feedback_zod4_partial_on_refined` — this file is exactly the shape that trips\n * it, so the refinement goes on the export and never on the base.\n */\nconst foodBodyBaseSchema = z.object({\n /**\n * Which library this entry joins. `null` is the PLATFORM library — content the\n * product publishes to every gym — and only platform staff may send it.\n *\n * Nullable rather than optional, for the reason `createExerciseBodySchema`\n * spells out: an omitted field would default to global, so one forgetful\n * client could publish a gym's own recipes to every competitor on the\n * product. Going global has to be stated.\n */\n centerId: objectIdSchema.nullable(),\n name: z.string().trim().min(1, 'Name is required').max(120),\n /**\n * NULLABLE as well as optional, and the distinction is load-bearing.\n *\n * `optionalTrimmedString` maps an empty string to `undefined`, and `undefined`\n * on the partial update body means \"leave this alone\". So without `null`\n * there is no way to express REMOVAL: a gym could attach a photograph or a\n * blurb and then never take it off again — the save would return 200 and\n * change nothing, which is the worst kind of failure.\n *\n * undefined don't touch it (every field the request omits)\n * null clear it\n * string set it\n */\n description: optionalTrimmedString(2000).nullable(),\n category: foodCategorySchema,\n thumbnailUrl: optionalTrimmedString(2000).nullable(),\n serving: foodServingSchema.nullable(),\n /**\n * Per 100g, and REQUIRED FOR A BASE FOOD ONLY.\n *\n * Optional here rather than mandatory because a dish does not have one to\n * send: its profile is computed from `recipe`. The rule that exactly one of\n * the two must be present is enforced below, where it can be stated once and\n * read as a sentence.\n */\n nutrition: nutritionSchema.optional(),\n /**\n * Veg / egg / non-veg, and REQUIRED FOR A BASE FOOD ONLY — exactly like\n * `nutrition` above and enforced by the same refinement.\n *\n * A dish must not send one: it is derived from the ingredients, so a typed\n * value would be a second answer free to disagree the moment a component\n * changes. Marking chicken curry \"veg\" by hand is a mistake nobody would\n * catch, and it would be shown to the member who most needs it right.\n */\n dietType: foodDietTypeSchema.optional(),\n /**\n * Vegan, and REQUIRED FOR A BASE FOOD ONLY, exactly like `dietType`.\n *\n * A dish must not send one: it is derived from the ingredients, so a typed\n * value could claim a paneer dish is vegan and nothing downstream would\n * disagree.\n */\n isVegan: z.boolean().optional(),\n ingredients: z.array(z.string().trim().max(200)).max(40),\n /** Where the numbers came from. A dish leaves this null. */\n source: foodSourceSchema.nullable(),\n /** Who to credit for the photograph. */\n imageCredit: foodImageCreditSchema.nullable(),\n /** What it is made of. A base ingredient leaves this null. */\n recipe: foodRecipeSchema.nullable(),\n});\n\n/**\n * ⚠️ NO `.default()` ANYWHERE IN THE BASE ABOVE. This is not a style choice.\n *\n * `.partial()` makes a field optional; it does NOT switch its default off. So a\n * base carrying `serving: ….default(null)` produces, for the update body\n * `{ name: 'Rice' }`, the parsed object `{ name, serving: null, ingredients: [],\n * source: null, recipe: null }` — and `updateFood` does `$set: body`. Renaming a\n * food would silently erase its serving size, its ingredient list, its citation\n * and, worst of all, the recipe a dish's entire nutrition is derived from.\n *\n * That was already live for `serving` and `ingredients` before this module\n * grew a recipe; it is fixed here for all four at once. The defaults live on\n * the CREATE schema below, which is the only place they are wanted, and the\n * behaviour is pinned by a test.\n */\n\n/**\n * A food is EITHER measured or composed, never both and never neither.\n *\n * Both would mean two answers to \"how many calories\": one typed, one derived,\n * free to disagree the moment an ingredient is corrected — and nothing on\n * screen would say which one a chart used. Neither would mean a library card\n * with no number on it at all, in a library that exists to be scanned for\n * exactly that number.\n */\nexport const createFoodBodySchema = foodBodyBaseSchema\n .extend({\n // Defaults belong to CREATE alone — see the warning above the base.\n serving: foodServingSchema.nullable().default(null),\n ingredients: z.array(z.string().trim().max(200)).max(40).default([]),\n source: foodSourceSchema.nullable().default(null),\n imageCredit: foodImageCreditSchema.nullable().default(null),\n recipe: foodRecipeSchema.nullable().default(null),\n })\n .superRefine((value, ctx) => {\n if (value.recipe && value.nutrition) {\n ctx.addIssue({\n code: 'custom',\n path: ['nutrition'],\n message: 'A food built from a recipe works its own nutrition out. Send one or the other.',\n });\n }\n if (!value.recipe && !value.nutrition) {\n ctx.addIssue({\n code: 'custom',\n path: ['nutrition'],\n message: 'Enter the nutrition per 100 g, or build the food from a recipe.',\n });\n }\n // The same either/or, applied to the veg flag. Stated for an ingredient,\n // derived for a dish — see `foodDietTypeSchema`.\n if (value.recipe && value.dietType) {\n ctx.addIssue({\n code: 'custom',\n path: ['dietType'],\n message: 'A dish takes veg / non-veg from its ingredients. Leave this off.',\n });\n }\n if (!value.recipe && !value.dietType) {\n ctx.addIssue({\n code: 'custom',\n path: ['dietType'],\n message: 'Say whether this is veg, egg or non-veg.',\n });\n }\n if (value.recipe && value.isVegan !== undefined) {\n ctx.addIssue({\n code: 'custom',\n path: ['isVegan'],\n message: 'A dish is vegan only if every ingredient is. Leave this off.',\n });\n }\n if (!value.recipe && value.isVegan === undefined) {\n ctx.addIssue({\n code: 'custom',\n path: ['isVegan'],\n message: 'Say whether this contains anything animal-derived.',\n });\n }\n });\nexport type CreateFoodBody = z.infer<typeof createFoodBodySchema>;\n\n/**\n * Derived from the BASE, not from `createFoodBodySchema` — see the note there.\n * A partial update cannot carry the either/or rule anyway: a request changing\n * only the name says nothing about nutrition, and the service applies the rule\n * against the merged document instead.\n */\nexport const updateFoodBodySchema = foodBodyBaseSchema.omit({ centerId: true }).partial();\nexport type UpdateFoodBody = z.infer<typeof updateFoodBodySchema>;\n\n// ─── Library query ───────────────────────────────────────────────────────────\n\n/**\n * Which shelf of the library to list.\n *\n * The distinction `isRecipe` already draws, promoted to a query parameter\n * because it is the primary way the library is browsed rather than a refinement\n * of it. Nobody eats raw toor dal or maida; putting them in the same scroll as\n * chicken biryani is what made a 154-row library unreadable.\n */\nexport const foodKindSchema = z.enum(['dish', 'ingredient']);\nexport type FoodKind = z.infer<typeof foodKindSchema>;\n\n/**\n * List ordering, applied IN THE DATABASE.\n *\n * It has to be the server's: with a paginated list, a client that sorts what it\n * has sorts one page, so \"highest protein\" would mean \"highest protein among\n * the twenty rows that happened to arrive\".\n *\n * `protein-desc` is here because it is the sort this library exists for. A gym\n * member asking \"what should I eat\" is asking for protein per 100 g, and no\n * other ordering answers it.\n */\nexport const foodSortSchema = z.enum([\n 'name',\n 'name-desc',\n 'calories-desc',\n 'calories-asc',\n 'protein-desc',\n]);\nexport type FoodSort = z.infer<typeof foodSortSchema>;\n\n/**\n * The food-library list query.\n *\n * PAGINATED, filtered and sorted server-side. It used to return every row and\n * let the client narrow them, which is how the training library still works —\n * fine at 136 fixed rows, wrong here, because the gym-authored half of this\n * library is the part that grows and no client-side filter can page it.\n *\n * ⚠️ `limit` now DEFAULTS, so a caller that sends nothing gets page 1 rather\n * than everything. Every consumer of this endpoint had to be converted in the\n * same change: a picker silently showing 30 of 154 foods is worse than a slow\n * one, because it looks like the food is missing rather than late.\n */\nexport const foodLibraryQuerySchema = paginationSchema.extend({\n centerId: objectIdSchema.optional(),\n category: foodCategorySchema.optional(),\n kind: foodKindSchema.optional(),\n dietType: foodDietTypeSchema.optional(),\n /**\n * Vegan only. Absent or `false` means \"do not filter\", never \"not vegan\".\n *\n * ⚠️ `z.coerce.boolean()` is deliberately NOT used: it maps the string\n * \"false\" to `true`, because a non-empty string is truthy. That is the whole\n * trap this field exists to avoid.\n *\n * An ENUM of the two literals rather than `z.literal('true')` alone: with only\n * the one literal, `?isVegan=false` is a validation error and the request\n * 422s. Nothing sends that today — the client omits the param when the toggle\n * is off — but \"off\" 422ing is a trap for the next caller, and a bookmarked\n * URL is a caller. Anything that is neither literal still fails loudly, which\n * is right: `?isVegan=1` is a bug, not an opinion.\n */\n isVegan: z\n .enum(['true', 'false'])\n .optional()\n .transform((v) => (v === 'true' ? true : undefined)),\n q: optionalTrimmedString(120),\n sort: foodSortSchema.default('name'),\n /**\n * Only foods at or above this protein per 100 g.\n *\n * A number rather than a `highProtein: true` flag, so the threshold is the\n * caller's and the API is not the place a fitness opinion gets hard-coded.\n * The UI offers 15 g, which is roughly where a food stops being a carb with\n * protein in it.\n */\n minProtein: z.coerce.number().min(0).max(100).optional(),\n});\nexport type FoodLibraryQuery = z.infer<typeof foodLibraryQuerySchema>;\n\n/**\n * Counts for the shelf switch, returned beside the first page.\n *\n * The tab strip has to say \"Dishes 64 / Ingredients 90\" before either shelf is\n * opened, and a paginated list cannot tell it that: `meta.pagination.total`\n * counts the shelf being viewed. These are counted under the SAME filters as\n * the list except `kind`, so the numbers change with a search the way the user\n * expects and a shelf showing \"12\" never opens onto nothing.\n */\nexport const foodShelfCountsSchema = z.object({\n dish: z.number().int(),\n ingredient: z.number().int(),\n});\nexport type FoodShelfCounts = z.infer<typeof foodShelfCountsSchema>;\n\n// ─── Platform seed ───────────────────────────────────────────────────────────\n//\n// Mirrors the training library's seed contract (`exercise.ts`) so the two\n// consoles are the same screen twice, and `seedTallySchema` is shared rather\n// than redeclared.\n\nexport const foodSeedStatusSchema = z.object({\n /** What the content file ships. */\n available: z.object({\n /** Measured ingredients carrying a citation. */\n baseFoods: z.number().int(),\n /** Dishes composed from those ingredients. */\n recipeFoods: z.number().int(),\n charts: z.number().int(),\n /** Of the base foods, how many cite a real database rather than an estimate. */\n cited: z.number().int(),\n }),\n /** What the PLATFORM shelf currently holds in the database. */\n seeded: z.object({\n foods: z.number().int(),\n recipeFoods: z.number().int(),\n charts: z.number().int(),\n }),\n});\nexport type FoodSeedStatus = z.infer<typeof foodSeedStatusSchema>;\n\nexport const foodSeedReportSchema = z.object({\n foods: seedTallySchema,\n charts: seedTallySchema,\n /** Ingredient or food slugs a recipe or chart named that do not exist. */\n missingSlugs: z.array(z.string()),\n errors: z.array(z.string()),\n});\nexport type FoodSeedReport = z.infer<typeof foodSeedReportSchema>;\n\nexport const runFoodSeedBodySchema = z.object({\n /**\n * Load the library but skip the starter charts.\n *\n * The one case where a full re-seed destroys work: a gym that has edited a\n * starter chart in place would have those edits overwritten, while the foods\n * underneath are safe to refresh at any time.\n */\n foodsOnly: z.boolean().optional(),\n});\nexport type RunFoodSeedBody = z.infer<typeof runFoodSeedBodySchema>;\n"]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "gymmonk-schema",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.59.0",
|
|
4
4
|
"description": "Shared Zod schemas, enums and domain types for GymMonk (fitness SaaS) — single source of truth (SSOT) consumed by gymmonk-backend and gymmonk-web-client.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|