From e26e89a1fbfb6854846c44f07a9108d869e8d002 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Fri, 24 Oct 2025 13:07:32 -0700 Subject: [PATCH 01/26] LF-4995 Migration to add market_directory_info permissions --- ...1_add_market_directory_info_permissions.js | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js diff --git a/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js b/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js new file mode 100644 index 0000000000..c36b941948 --- /dev/null +++ b/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js @@ -0,0 +1,62 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +export const up = async function (knex) { + await knex('permissions').insert([ + { + permission_id: 179, + name: 'add:market_directory_info', + description: 'add market_directory_info', + }, + { + permission_id: 180, + name: 'edit:market_directory_info', + description: 'edit market_directory_info', + }, + { + permission_id: 181, + name: 'delete:market_directory_info', + description: 'delete market_directory_info', + }, + ]); + await knex('rolePermissions').insert([ + { role_id: 1, permission_id: 179 }, + { role_id: 2, permission_id: 179 }, + { role_id: 5, permission_id: 179 }, + { role_id: 1, permission_id: 180 }, + { role_id: 2, permission_id: 180 }, + { role_id: 5, permission_id: 180 }, + { role_id: 1, permission_id: 181 }, + { role_id: 2, permission_id: 181 }, + { role_id: 5, permission_id: 181 }, + ]); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = function (knex) { + const permissions = [179, 180, 181]; + return Promise.all([ + knex('rolePermissions').whereIn('permission_id', permissions).del(), + knex('permissions').whereIn('permission_id', permissions).del(), + ]); +}; From 2c1e1b671db849c33c614b50880f367dbd1e8d83 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Fri, 24 Oct 2025 13:18:22 -0700 Subject: [PATCH 02/26] LF-4995 Add POST API skeleton for market directory info --- .../marketDirectoryInfoController.ts | 36 +++++++++++++++++++ .../validation/checkMarketDirectoryInfo.ts | 22 ++++++++++++ .../src/routes/marketDirectoryInfoRoute.ts | 30 ++++++++++++++++ packages/api/src/server.ts | 4 ++- 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 packages/api/src/controllers/marketDirectoryInfoController.ts create mode 100644 packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts create mode 100644 packages/api/src/routes/marketDirectoryInfoRoute.ts diff --git a/packages/api/src/controllers/marketDirectoryInfoController.ts b/packages/api/src/controllers/marketDirectoryInfoController.ts new file mode 100644 index 0000000000..f94874bf1e --- /dev/null +++ b/packages/api/src/controllers/marketDirectoryInfoController.ts @@ -0,0 +1,36 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { Request, Response } from 'express'; +import { HttpError } from '../types.js'; + +const marketDirectoryInfoController = { + addMarketDirectoryInfo() { + return async (_req: Request, res: Response) => { + try { + res.status(201).send(); + } catch (error: unknown) { + console.error(error); + const err = error as HttpError; + const status = err.status || err.code || 500; + return res.status(status).json({ + error: err.message || err, + }); + } + }; + }, +}; + +export default marketDirectoryInfoController; diff --git a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts new file mode 100644 index 0000000000..e097322080 --- /dev/null +++ b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { Request, Response, NextFunction } from 'express'; + +export function checkMarketDirectoryInfo() { + return async (_req: Request, _res: Response, next: NextFunction) => { + next(); + }; +} diff --git a/packages/api/src/routes/marketDirectoryInfoRoute.ts b/packages/api/src/routes/marketDirectoryInfoRoute.ts new file mode 100644 index 0000000000..88eee246c8 --- /dev/null +++ b/packages/api/src/routes/marketDirectoryInfoRoute.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import express from 'express'; +import checkScope from '../middleware/acl/checkScope.js'; +import { checkMarketDirectoryInfo } from '../middleware/validation/checkMarketDirectoryInfo.js'; +import MarketDirectoryInfoController from '../controllers/marketDirectoryInfoController.js'; + +const router = express.Router(); + +router.post( + '/', + checkScope(['add:market_directory_info']), + checkMarketDirectoryInfo(), + MarketDirectoryInfoController.addMarketDirectoryInfo(), +); + +export default router; diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index db570b3285..8bd6e20fbd 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -175,6 +175,7 @@ import farmAddonRoute from './routes/farmAddonRoute.js'; import weatherRoute from './routes/weatherRoute.js'; import irrigationPrescriptionRoute from './routes/irrigationPrescriptionRoute.js'; import irrigationPrescriptionRequestRoute from './routes/irrigationPrescriptionRequestRoute.js'; +import marketDirectoryInfoRoute from './routes/marketDirectoryInfoRoute.js'; // register API const router = promiseRouter(); @@ -343,7 +344,8 @@ app .use('/farm_addon', farmAddonRoute) .use('/weather', weatherRoute) .use('/irrigation_prescriptions', irrigationPrescriptionRoute) - .use('/irrigation_prescription_request', irrigationPrescriptionRequestRoute); + .use('/irrigation_prescription_request', irrigationPrescriptionRequestRoute) + .use('/market_directory_info', marketDirectoryInfoRoute); // Allow a 1MB limit on sensors to match incoming Ensemble data app.use('/sensor', express.json({ limit: '1MB' }), rejectBodyInGetAndDelete, sensorRoute); From f4cd6fb91a4e428277c5740349ecefe8d4bf765d Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 27 Oct 2025 13:38:04 -0700 Subject: [PATCH 03/26] LF-4995 Add validation functions --- packages/api/src/util/validation.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/api/src/util/validation.ts b/packages/api/src/util/validation.ts index edabd4739d..6c93cd438f 100644 --- a/packages/api/src/util/validation.ts +++ b/packages/api/src/util/validation.ts @@ -51,3 +51,14 @@ export function isSimpleDateFormat(value: unknown): boolean { return typeof value === 'string' && simpleDateRegex.test(value); } + +const VALID_EMAIL_REGEX = /^$|^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,6}$/i; + +export const isValidEmail = (email: string): boolean => { + return VALID_EMAIL_REGEX.test(email); +}; + +export const isValidURL = async (url: string) => { + // TODO: LF-5011 + return !!url; +}; From 093be65f9d7997d91ed66c9f9ed57b62e55e4ced Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 27 Oct 2025 13:41:55 -0700 Subject: [PATCH 04/26] LF-4995 Add email validation function --- packages/api/src/util/validation.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/api/src/util/validation.ts b/packages/api/src/util/validation.ts index 6c93cd438f..668f5325de 100644 --- a/packages/api/src/util/validation.ts +++ b/packages/api/src/util/validation.ts @@ -57,8 +57,3 @@ const VALID_EMAIL_REGEX = /^$|^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,6}$/i; export const isValidEmail = (email: string): boolean => { return VALID_EMAIL_REGEX.test(email); }; - -export const isValidURL = async (url: string) => { - // TODO: LF-5011 - return !!url; -}; From 05f7dd2203afbe835630232631a4959e3299c0ef Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 27 Oct 2025 13:42:38 -0700 Subject: [PATCH 05/26] LF-4995 Add shared validation functions --- packages/shared/utils/socials.js | 42 ++++++++++++++++++++++++++++++++ packages/shared/utils/url.js | 19 +++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 packages/shared/utils/socials.js create mode 100644 packages/shared/utils/url.js diff --git a/packages/shared/utils/socials.js b/packages/shared/utils/socials.js new file mode 100644 index 0000000000..b89b3aa9f4 --- /dev/null +++ b/packages/shared/utils/socials.js @@ -0,0 +1,42 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { isValidURL } from "../../api/src/util/validation.js"; + +export const SOCIALS = { + INSTAGRAM: "instagram", + FACEBOOK: "facebook", + X: "x", +}; + +const SOCIAL_DOMAINS = { + [SOCIALS.INSTAGRAM]: "instagram.com", + [SOCIALS.FACEBOOK]: "facebook.com", + [SOCIALS.X]: "x.com", +}; + +export const validateAndExtractUsernameOrUrl = async ( + social, + usernameOrUrl +) => { + const domain = SOCIAL_DOMAINS[social]; + const isUrl = usernameOrUrl.includes(domain); + const trimmedInput = isUrl + ? usernameOrUrl.trim() + : usernameOrUrl.trim().replace(/^@/, ""); + const urlToCheck = isUrl ? trimmedInput : `https://${domain}/${trimmedInput}`; + + return (await isValidURL(urlToCheck)) ? trimmedInput : false; +}; diff --git a/packages/shared/utils/url.js b/packages/shared/utils/url.js new file mode 100644 index 0000000000..c3d0af03c1 --- /dev/null +++ b/packages/shared/utils/url.js @@ -0,0 +1,19 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +export const isValidUrl = async (url) => { + // TODO: LF-5011 + return !!url; +}; From da19ee7f7eeecf7af1922b2914fa873a004548ff Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 08:43:18 -0700 Subject: [PATCH 06/26] LF-4995 Move util functions --- .../socials.js => api/src/util/socials.ts} | 31 +++++++++---------- .../utils/url.js => api/src/util/url.ts} | 2 +- 2 files changed, 15 insertions(+), 18 deletions(-) rename packages/{shared/utils/socials.js => api/src/util/socials.ts} (60%) rename packages/{shared/utils/url.js => api/src/util/url.ts} (92%) diff --git a/packages/shared/utils/socials.js b/packages/api/src/util/socials.ts similarity index 60% rename from packages/shared/utils/socials.js rename to packages/api/src/util/socials.ts index b89b3aa9f4..836b1ada93 100644 --- a/packages/shared/utils/socials.js +++ b/packages/api/src/util/socials.ts @@ -13,30 +13,27 @@ * GNU General Public License for more details, see . */ -import { isValidURL } from "../../api/src/util/validation.js"; +import { isValidUrl } from './url.js'; -export const SOCIALS = { - INSTAGRAM: "instagram", - FACEBOOK: "facebook", - X: "x", -}; +export enum Social { + INSTAGRAM = 'instagram', + FACEBOOK = 'facebook', + X = 'x', +} const SOCIAL_DOMAINS = { - [SOCIALS.INSTAGRAM]: "instagram.com", - [SOCIALS.FACEBOOK]: "facebook.com", - [SOCIALS.X]: "x.com", + [Social.INSTAGRAM]: 'instagram.com', + [Social.FACEBOOK]: 'facebook.com', + [Social.X]: 'x.com', }; -export const validateAndExtractUsernameOrUrl = async ( - social, - usernameOrUrl -) => { +export const SOCIALS = Object.values(Social); + +export const validateAndExtractUsernameOrUrl = async (social: Social, usernameOrUrl: string) => { const domain = SOCIAL_DOMAINS[social]; const isUrl = usernameOrUrl.includes(domain); - const trimmedInput = isUrl - ? usernameOrUrl.trim() - : usernameOrUrl.trim().replace(/^@/, ""); + const trimmedInput = isUrl ? usernameOrUrl.trim() : usernameOrUrl.trim().replace(/^@/, ''); const urlToCheck = isUrl ? trimmedInput : `https://${domain}/${trimmedInput}`; - return (await isValidURL(urlToCheck)) ? trimmedInput : false; + return (await isValidUrl(urlToCheck)) ? trimmedInput : false; }; diff --git a/packages/shared/utils/url.js b/packages/api/src/util/url.ts similarity index 92% rename from packages/shared/utils/url.js rename to packages/api/src/util/url.ts index c3d0af03c1..ed915f9d49 100644 --- a/packages/shared/utils/url.js +++ b/packages/api/src/util/url.ts @@ -13,7 +13,7 @@ * GNU General Public License for more details, see . */ -export const isValidUrl = async (url) => { +export const isValidUrl = async (url: string) => { // TODO: LF-5011 return !!url; }; From 9e7783e1e95b8fe61d5b1f26ac49a7dc31c14df3 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 08:46:56 -0700 Subject: [PATCH 07/26] LF-4995 Update LiteFarmRequest interface --- packages/api/src/types.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index d2b5ba7eda..91721468c2 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -21,8 +21,12 @@ export interface HttpError extends Error { } // TODO: Remove farm_id conditional and cast this in a checkScope() that takes the function and casts this to req -export interface LiteFarmRequest - extends Request { +export interface LiteFarmRequest< + QueryParams = unknown, + RouteParams = unknown, + ResBody = unknown, + ReqBody = unknown, +> extends Request { headers: Request['headers'] & { farm_id?: string; }; From a103b03c41ca29ee3ab7710210938637c55e7990 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 09:23:51 -0700 Subject: [PATCH 08/26] LF-4995 Add getAddressComponents Co-authored-by: Joyce Yuki <82857964+kathyavini@users.noreply.github.com> --- packages/api/src/util/googleMaps.ts | 31 +++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 packages/api/src/util/googleMaps.ts diff --git a/packages/api/src/util/googleMaps.ts b/packages/api/src/util/googleMaps.ts new file mode 100644 index 0000000000..b41db6ff95 --- /dev/null +++ b/packages/api/src/util/googleMaps.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { Client } from '@googlemaps/google-maps-services-js'; +const googleClient = new Client({}); + +export async function getAddressComponents(address: string) { + try { + const response = await googleClient.geocode({ + params: { + address, + key: process.env.GOOGLE_API_KEY!, + }, + }); + return response.data.results[0]?.address_components; + } catch (error) { + console.error(error); + } +} From 4d977a59a54e3e350bb83c4f9460b5c8d9f21cf0 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 09:24:38 -0700 Subject: [PATCH 09/26] LF-4995 Add isValidAddress function --- packages/api/src/util/validation.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/api/src/util/validation.ts b/packages/api/src/util/validation.ts index 668f5325de..a4d4db64b8 100644 --- a/packages/api/src/util/validation.ts +++ b/packages/api/src/util/validation.ts @@ -13,6 +13,8 @@ * GNU General Public License for more details, see . */ +import { getAddressComponents } from './googleMaps.js'; + /** * AI-assisted documentation * @@ -57,3 +59,7 @@ const VALID_EMAIL_REGEX = /^$|^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,6}$/i; export const isValidEmail = (email: string): boolean => { return VALID_EMAIL_REGEX.test(email); }; + +export async function isValidAddress(address: string) { + return !!(await getAddressComponents(address)); +} From 183913bcf3752622ca898d3da47e6a9915b3a054 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 09:28:24 -0700 Subject: [PATCH 10/26] LF-4995 Add isValidAddress function --- .../src/models/marketDirectoryInfoModel.js | 33 +++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/api/src/models/marketDirectoryInfoModel.js b/packages/api/src/models/marketDirectoryInfoModel.js index 476630295c..34506cdf6b 100644 --- a/packages/api/src/models/marketDirectoryInfoModel.js +++ b/packages/api/src/models/marketDirectoryInfoModel.js @@ -14,6 +14,7 @@ */ import baseModel from './baseModel.js'; +import { checkAndTrimString } from '../util/util.js'; class MarketDirectoryInfo extends baseModel { static get tableName() { @@ -24,6 +25,34 @@ class MarketDirectoryInfo extends baseModel { return 'id'; } + static get stringProperties() { + const stringProperties = []; + for (const [key, value] of Object.entries(this.jsonSchema.properties)) { + if (value.type.includes('string')) { + stringProperties.push(key); + } + } + return stringProperties; + } + + async $beforeInsert(queryContext) { + await super.$beforeInsert(queryContext); + this.trimStringProperties(); + } + + async $beforeUpdate(opt, queryContext) { + await super.$beforeUpdate(opt, queryContext); + this.trimStringProperties(); + } + + trimStringProperties() { + for (const key of this.constructor.stringProperties) { + if (key in this) { + this[key] = checkAndTrimString(this[key]); + } + } + } + // Optional JSON schema. This is not the database schema! Nothing is generated // based on this. This is only used for validation. Whenever a model instance // is created it is checked against this schema. http://json-schema.org/. @@ -36,7 +65,7 @@ class MarketDirectoryInfo extends baseModel { farm_id: { type: 'string' }, farm_name: { type: 'string', minLength: 1, maxLength: 255 }, logo: { type: ['string', 'null'], maxLength: 255 }, - about: { type: ['string', 'null'] }, + about: { type: ['string', 'null'], maxLength: 255 }, contact_first_name: { type: 'string', minLength: 1, maxLength: 255 }, contact_last_name: { type: ['string', 'null'], maxLength: 255 }, contact_email: { type: 'string', minLength: 1, maxLength: 255 }, @@ -44,7 +73,7 @@ class MarketDirectoryInfo extends baseModel { country_code: { type: ['integer', 'null'], minimum: 1, maximum: 999 }, phone_number: { type: ['string', 'null'], maxLength: 255 }, address: { type: 'string', minLength: 1, maxLength: 255 }, - website: { type: ['string', 'null'] }, + website: { type: ['string', 'null'], maxLength: 255 }, instagram: { type: ['string', 'null'], maxLength: 255 }, facebook: { type: ['string', 'null'], maxLength: 255 }, x: { type: ['string', 'null'], maxLength: 255 }, From f68a0b8d672a64b7597b08aed9ceb13b994f5757 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 09:30:24 -0700 Subject: [PATCH 11/26] LF-4995 Update marketDirectoryInfo validation middleware --- .../validation/checkMarketDirectoryInfo.ts | 65 ++++++++++++++++++- .../src/routes/marketDirectoryInfoRoute.ts | 4 +- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts index e097322080..99cd17806a 100644 --- a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts +++ b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts @@ -13,10 +13,69 @@ * GNU General Public License for more details, see . */ -import { Request, Response, NextFunction } from 'express'; +import { Response, NextFunction } from 'express'; +import { LiteFarmRequest } from '../../types.js'; +import { isValidAddress, isValidEmail } from '../../util/validation.js'; +import { isValidUrl } from '../../util/url.js'; +import { SOCIALS, validateAndExtractUsernameOrUrl } from '../../util/socials.js'; + +export interface MarketDirectoryInfoReqBody { + farm_name?: string; + logo?: string; + about?: string; + contact_first_name?: string; + contact_last_name?: string; + contact_email?: string; + email?: string; + country_code?: number; + phone_number?: string; + address?: string; + website?: string; + instagram?: string; + facebook?: string; + x?: string; +} + +export function checkAndTransformMarketDirectoryInfo() { + return async ( + req: LiteFarmRequest, + res: Response, + next: NextFunction, + ) => { + const { address, website } = req.body; + + for (const emailProperty of ['contact_email', 'email'] as const) { + if (req.body[emailProperty] && !isValidEmail(req.body[emailProperty])) { + return res.status(400).send(`Invalid ${emailProperty}`); + } + } + + if (address && !isValidAddress(address)) { + return res.status(400).send('Invalid address'); + } + + if (website && !isValidUrl(website) /* TODO: LF-5011 */) { + return res.status(400).send('Invalid website'); + } + + const invalidSocials = ( + await Promise.all( + SOCIALS.filter((social) => req.body[social]?.trim()).map(async (social) => { + const formattedSocial = await validateAndExtractUsernameOrUrl(social, req.body[social]!); + + if (formattedSocial) { + req.body[social] = formattedSocial; + return null; + } + return social; + }), + ) + ).filter((social) => social !== null); + + if (invalidSocials.length) { + return res.status(400).send(`Invalid ${invalidSocials.join(', ')}`); + } -export function checkMarketDirectoryInfo() { - return async (_req: Request, _res: Response, next: NextFunction) => { next(); }; } diff --git a/packages/api/src/routes/marketDirectoryInfoRoute.ts b/packages/api/src/routes/marketDirectoryInfoRoute.ts index 88eee246c8..cbfcf35fd5 100644 --- a/packages/api/src/routes/marketDirectoryInfoRoute.ts +++ b/packages/api/src/routes/marketDirectoryInfoRoute.ts @@ -15,7 +15,7 @@ import express from 'express'; import checkScope from '../middleware/acl/checkScope.js'; -import { checkMarketDirectoryInfo } from '../middleware/validation/checkMarketDirectoryInfo.js'; +import { checkAndTransformMarketDirectoryInfo } from '../middleware/validation/checkMarketDirectoryInfo.js'; import MarketDirectoryInfoController from '../controllers/marketDirectoryInfoController.js'; const router = express.Router(); @@ -23,7 +23,7 @@ const router = express.Router(); router.post( '/', checkScope(['add:market_directory_info']), - checkMarketDirectoryInfo(), + checkAndTransformMarketDirectoryInfo(), MarketDirectoryInfoController.addMarketDirectoryInfo(), ); From ad5ad3b02065cf224d2423376797044f23cd8c51 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 09:52:54 -0700 Subject: [PATCH 12/26] LF-4995 Update addMarketDirectoryInfo method --- .../marketDirectoryInfoController.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/api/src/controllers/marketDirectoryInfoController.ts b/packages/api/src/controllers/marketDirectoryInfoController.ts index f94874bf1e..1867333886 100644 --- a/packages/api/src/controllers/marketDirectoryInfoController.ts +++ b/packages/api/src/controllers/marketDirectoryInfoController.ts @@ -13,14 +13,28 @@ * GNU General Public License for more details, see . */ -import { Request, Response } from 'express'; -import { HttpError } from '../types.js'; +import { Response } from 'express'; +import baseController from './baseController.js'; +import MarketDirectoryInfoModel from '../models/marketDirectoryInfoModel.js'; +import { MarketDirectoryInfoReqBody } from '../middleware/validation/checkMarketDirectoryInfo.js'; +import { HttpError, LiteFarmRequest } from '../types.js'; const marketDirectoryInfoController = { addMarketDirectoryInfo() { - return async (_req: Request, res: Response) => { + return async ( + req: LiteFarmRequest, + res: Response, + ) => { + const { farm_id } = req.headers; + try { - res.status(201).send(); + const result = await baseController.post( + MarketDirectoryInfoModel, + { ...req.body, farm_id }, + req, + ); + + return res.status(201).send(result); } catch (error: unknown) { console.error(error); const err = error as HttpError; From f5001c5bf24a934bcdd319b1a502fa0ddcbc1937 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 14:03:36 -0700 Subject: [PATCH 13/26] LF-4995 Update checkAndTransformMarketDirectoryInfo --- .../validation/checkMarketDirectoryInfo.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts index 99cd17806a..79ee207e52 100644 --- a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts +++ b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts @@ -18,6 +18,7 @@ import { LiteFarmRequest } from '../../types.js'; import { isValidAddress, isValidEmail } from '../../util/validation.js'; import { isValidUrl } from '../../util/url.js'; import { SOCIALS, validateAndExtractUsernameOrUrl } from '../../util/socials.js'; +import MarketDirectoryInfoModel from '../../models/marketDirectoryInfoModel.js'; export interface MarketDirectoryInfoReqBody { farm_name?: string; @@ -42,6 +43,17 @@ export function checkAndTransformMarketDirectoryInfo() { res: Response, next: NextFunction, ) => { + if (req.method === 'POST') { + // @ts-expect-error: TS doesn't see query() through softDelete HOC; safe at runtime + const record = await MarketDirectoryInfoModel.query() + .where({ farm_id: req.headers.farm_id }) + .first(); + + if (record) { + return res.status(409).send('Market directory info for this farm already exists'); + } + } + const { address, website } = req.body; for (const emailProperty of ['contact_email', 'email'] as const) { From f02ae7fccbaef866fb1ab234a2e1d0bc29765d8f Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 14:04:32 -0700 Subject: [PATCH 14/26] LF-4995 Update mock factories and tableCleanup --- packages/api/tests/mock.factories.js | 27 ++++++++++++++++++++++++++- packages/api/tests/testEnvironment.js | 1 + 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index 8242b10168..2ddef9aafd 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -1935,7 +1935,7 @@ async function saleFactory({ promisedUserFarm = userFarmFactory() } = {}, sale = function fakeSale(defaultData = {}) { return { - customer_name: faker.name.findName(), + customer_name: faker.name.firstName(), sale_date: faker.date.recent(), ...defaultData, }; @@ -2697,6 +2697,29 @@ async function farm_addonFactory({ .returning('*'); } +function fakeMarketDirectoryInfo(defaultData = {}) { + return { + farm_name: faker.lorem.word(), + contact_first_name: faker.name.firstName(), + contact_email: faker.internet.email(), + address: faker.address.streetAddress(), + ...defaultData, + }; +} + +async function market_directory_infoFactory({ + promisedUserFarm = userFarmFactory(), + marketDirectoryInfo = fakeMarketDirectoryInfo(), +} = {}) { + const [userFarm] = await Promise.all([promisedUserFarm]); + const [{ farm_id, user_id }] = userFarm; + const base = baseProperties(user_id); + + return await knex('market_directory_info') + .insert({ farm_id, ...base, ...marketDirectoryInfo }) + .returning('*'); +} + // External endpoint helper mocks export const buildExternalIrrigationPrescription = async ({ id, @@ -2924,5 +2947,7 @@ export default { farm_addonFactory, buildExternalIrrigationPrescription, buildIrrigationPrescription, + fakeMarketDirectoryInfo, + market_directory_infoFactory, baseProperties, }; diff --git a/packages/api/tests/testEnvironment.js b/packages/api/tests/testEnvironment.js index c4625a4f4f..7f5e503412 100644 --- a/packages/api/tests/testEnvironment.js +++ b/packages/api/tests/testEnvironment.js @@ -146,6 +146,7 @@ async function tableCleanup(knex) { DELETE FROM "animal_removal_reason"; DELETE FROM "farm_addon"; DELETE FROM "addon_partner"; + DELETE FROM "market_directory_info"; DELETE FROM "location"; DELETE FROM "userFarm"; DELETE FROM "farm"; From 47773c790c707969ff8fce2f33b4d359ee8aeb13 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 28 Oct 2025 14:49:55 -0700 Subject: [PATCH 15/26] LF-4995 Fix checkAndTransformMarketDirectoryInfo --- .../api/src/middleware/validation/checkMarketDirectoryInfo.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts index 79ee207e52..489f91b4ee 100644 --- a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts +++ b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts @@ -62,11 +62,11 @@ export function checkAndTransformMarketDirectoryInfo() { } } - if (address && !isValidAddress(address)) { + if (address && !(await isValidAddress(address))) { return res.status(400).send('Invalid address'); } - if (website && !isValidUrl(website) /* TODO: LF-5011 */) { + if (website && !(await isValidUrl(website)) /* TODO: LF-5011 */) { return res.status(400).send('Invalid website'); } From 627fa6da97e4e7fe982fff0d13af545b2431110e Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Wed, 29 Oct 2025 09:11:54 -0700 Subject: [PATCH 16/26] LF-4995 Update marketDirectoryInfo properties maxLength in model --- packages/api/src/models/marketDirectoryInfoModel.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/src/models/marketDirectoryInfoModel.js b/packages/api/src/models/marketDirectoryInfoModel.js index 34506cdf6b..8a9107125e 100644 --- a/packages/api/src/models/marketDirectoryInfoModel.js +++ b/packages/api/src/models/marketDirectoryInfoModel.js @@ -65,7 +65,7 @@ class MarketDirectoryInfo extends baseModel { farm_id: { type: 'string' }, farm_name: { type: 'string', minLength: 1, maxLength: 255 }, logo: { type: ['string', 'null'], maxLength: 255 }, - about: { type: ['string', 'null'], maxLength: 255 }, + about: { type: ['string', 'null'], maxLength: 3000 }, contact_first_name: { type: 'string', minLength: 1, maxLength: 255 }, contact_last_name: { type: ['string', 'null'], maxLength: 255 }, contact_email: { type: 'string', minLength: 1, maxLength: 255 }, @@ -73,7 +73,7 @@ class MarketDirectoryInfo extends baseModel { country_code: { type: ['integer', 'null'], minimum: 1, maximum: 999 }, phone_number: { type: ['string', 'null'], maxLength: 255 }, address: { type: 'string', minLength: 1, maxLength: 255 }, - website: { type: ['string', 'null'], maxLength: 255 }, + website: { type: ['string', 'null'], maxLength: 2000 }, instagram: { type: ['string', 'null'], maxLength: 255 }, facebook: { type: ['string', 'null'], maxLength: 255 }, x: { type: ['string', 'null'], maxLength: 255 }, From be5ca92be6edd0198d9431795f7e61dc2262c7c7 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Wed, 29 Oct 2025 11:14:47 -0700 Subject: [PATCH 17/26] LF-4995 Fix typo in mock factories --- packages/api/tests/mock.factories.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index 2ddef9aafd..218ed9890f 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -22,7 +22,7 @@ async function usersFactory(userObject = fakeUser()) { function fakeUser(defaultData = {}) { const email = faker.lorem.word() + faker.internet.email(); return { - first_name: faker.name.findName(), + first_name: faker.name.firstName(), last_name: faker.name.lastName(), email: email.toLowerCase(), user_id: faker.datatype.uuid(), @@ -38,7 +38,7 @@ function fakeUser(defaultData = {}) { function fakeSSOUser(defaultData = {}) { const email = faker.lorem.word() + faker.internet.email(); return { - first_name: faker.name.findName(), + first_name: faker.name.firstName(), last_name: faker.name.lastName(), email: email.toLowerCase(), user_id: faker.datatype.number({ min: 2, max: 10 }), From 8e62955ad4107dba1c045bb38297d6fab44f8486 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Wed, 29 Oct 2025 11:16:37 -0700 Subject: [PATCH 18/26] LF-4995 Add createUserFarmIds, HeadersParams for tests --- packages/api/tests/types.ts | 19 +++++++++++++++++++ packages/api/tests/utils/testDataSetup.ts | 10 ++++++++++ 2 files changed, 29 insertions(+) create mode 100644 packages/api/tests/types.ts diff --git a/packages/api/tests/types.ts b/packages/api/tests/types.ts new file mode 100644 index 0000000000..690714ed09 --- /dev/null +++ b/packages/api/tests/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +export interface HeadersParams { + user_id: string; + farm_id: string; +} diff --git a/packages/api/tests/utils/testDataSetup.ts b/packages/api/tests/utils/testDataSetup.ts index 25c9ba8027..13fd94905d 100644 --- a/packages/api/tests/utils/testDataSetup.ts +++ b/packages/api/tests/utils/testDataSetup.ts @@ -17,6 +17,7 @@ import knex from '../../src/util/knex.js'; import mocks from '../mock.factories.js'; import LocationModel from '../../src/models/locationModel.js'; import { Farm, Location, User } from '../../src/models/types.js'; +import { HeadersParams } from '../types.js'; /** * Generates a fake user farm object with the specified role. @@ -42,6 +43,15 @@ export async function returnUserFarms(role: number): Promise<{ mainFarm: Farm; u return { mainFarm, user }; } +/** + * Creates a farm and a user, associates them using the given role, + * and returns only their IDs for use in requests or headers. + */ +export async function createUserFarmIds(role: number): Promise { + const { mainFarm, user } = await returnUserFarms(role); + return { farm_id: mainFarm.farm_id, user_id: user.user_id }; +} + /** * Sets up the farm environment by creating a farm, owner, field, and (optionally) a non-owner user (if role id is provided) */ From f46d09df638db7f9969bace06b9f26a195009c3f Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Wed, 29 Oct 2025 11:17:10 -0700 Subject: [PATCH 19/26] LF-4995 Add tests for 'Post Market Directory Info' --- packages/api/src/util/socials.ts | 2 +- .../api/tests/marketDirectoryInfo.test.ts | 151 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 packages/api/tests/marketDirectoryInfo.test.ts diff --git a/packages/api/src/util/socials.ts b/packages/api/src/util/socials.ts index 836b1ada93..e56997f012 100644 --- a/packages/api/src/util/socials.ts +++ b/packages/api/src/util/socials.ts @@ -21,7 +21,7 @@ export enum Social { X = 'x', } -const SOCIAL_DOMAINS = { +export const SOCIAL_DOMAINS = { [Social.INSTAGRAM]: 'instagram.com', [Social.FACEBOOK]: 'facebook.com', [Social.X]: 'x.com', diff --git a/packages/api/tests/marketDirectoryInfo.test.ts b/packages/api/tests/marketDirectoryInfo.test.ts new file mode 100644 index 0000000000..a6beca2bd2 --- /dev/null +++ b/packages/api/tests/marketDirectoryInfo.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import chai from 'chai'; +import { faker } from '@faker-js/faker'; + +import chaiHttp from 'chai-http'; +chai.use(chaiHttp); + +import server from '../src/server.js'; +import knex from '../src/util/knex.js'; +import { tableCleanup } from './testEnvironment.js'; + +jest.mock('jsdom'); +jest.mock('../src/middleware/acl/checkJwt.js', () => + jest.fn((req, _res, next) => { + req.auth = {}; + req.auth.user_id = req.get('user_id'); + next(); + }), +); + +const INVALID_SUFFIX = '_INVALID'; + +jest.mock('../src/util/googleMaps.js', () => ({ + getAddressComponents: async (address: string) => { + return address.endsWith(INVALID_SUFFIX) ? undefined : [{}]; + }, +})); + +jest.mock('../src/util/url.js', () => ({ + isValidUrl: async (url: string) => { + return !url.endsWith(INVALID_SUFFIX); + }, +})); + +import mocks from './mock.factories.js'; +import { createUserFarmIds } from './utils/testDataSetup.js'; +import { MarketDirectoryInfoReqBody } from '../src/middleware/validation/checkMarketDirectoryInfo.js'; +import MarketDirectoryInfoModel from '../src/models/marketDirectoryInfoModel.js'; +import { SOCIAL_DOMAINS } from '../src/util/socials.js'; +import { HeadersParams } from './types.js'; + +const marketDirectoryInfo = mocks.fakeMarketDirectoryInfo({ + logo: faker.internet.url(), + about: faker.lorem.sentences(), + contact_last_name: faker.name.lastName(), + email: faker.internet.email(), + country_code: Math.floor(Math.random() * 999) + 1, + phone_number: faker.phone.phoneNumber(), + website: faker.internet.url(), + instagram: `https://${SOCIAL_DOMAINS.instagram}/username`, + facebook: `https://${SOCIAL_DOMAINS.facebook}/username`, + x: `https://${SOCIAL_DOMAINS.x}/username`, +}); + +const fakeInvalidString = (input: string = '') => `${input}${INVALID_SUFFIX}`; + +const invalidTestCases = [ + ['address', fakeInvalidString(faker.address.streetAddress())], + ['website', fakeInvalidString(faker.internet.url())], + ['contact_email', faker.lorem.word()], + ['email', faker.lorem.word()], + ['instagram', fakeInvalidString(faker.lorem.word())], // mock username + ['facebook', fakeInvalidString(SOCIAL_DOMAINS.facebook)], // mock url + ['x', `https://${fakeInvalidString(SOCIAL_DOMAINS.x)}`], // mock url +]; + +async function postRequest(data: MarketDirectoryInfoReqBody, { user_id, farm_id }: HeadersParams) { + return chai + .request(server) + .post('/market_directory_info') + .set('content-type', 'application/json') + .set('user_id', user_id) + .set('farm_id', farm_id) + .send(data); +} + +describe('Market Directory Info Tests', () => { + afterAll(async () => { + await tableCleanup(knex); + await knex.destroy(); + }); + + describe('Post Market Directory Info', () => { + test('Admin users should be able to create a market directory info', async () => { + const adminRoles = [1, 2, 5]; + + for (const role of adminRoles) { + const userFarmIds = await createUserFarmIds(role); + const res = await postRequest(marketDirectoryInfo, userFarmIds); + + expect(res.status).toBe(201); + + // @ts-expect-error: TS doesn't see query() through softDelete HOC; safe at runtime + const record = await MarketDirectoryInfoModel.query() + .where('farm_id', userFarmIds.farm_id) + .first(); + + for (const property in marketDirectoryInfo) { + const key = property as keyof typeof marketDirectoryInfo; + expect(record[key]).toBe(marketDirectoryInfo[key]); + } + } + }); + + test('Worker should not be able to create a market directory info', async () => { + const userFarmIds = await createUserFarmIds(3); + const res = await postRequest(marketDirectoryInfo, userFarmIds); + + expect(res.status).toBe(403); + }); + + test('Should return 409 conflict if market directory info already exists', async () => { + const userFarmIds = await createUserFarmIds(1); + await mocks.market_directory_infoFactory({ + promisedUserFarm: Promise.resolve([userFarmIds]), + }); + + const res = await postRequest(marketDirectoryInfo, userFarmIds); + expect(res.status).toBe(409); + }); + + describe('Should return 400 for invalid data', () => { + test.each(invalidTestCases)('%s', async (property, data) => { + const userFarmIds = await createUserFarmIds(1); + const res = await postRequest({ ...marketDirectoryInfo, [property]: data }, userFarmIds); + + expect(res.status).toBe(400); + + if (!res.error) { + throw new Error('Expected an error'); // type guard + } + + expect(res.error.text).toBe(`Invalid ${property}`); + }); + }); + }); +}); From 8737b507608bf767eb2ea44f827e2527467d1862 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Wed, 29 Oct 2025 11:46:22 -0700 Subject: [PATCH 20/26] LF-4995 Add permission for get:market_directory_info --- ...1024195921_add_market_directory_info_permissions.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js b/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js index c36b941948..bd05dd0291 100644 --- a/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js +++ b/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js @@ -35,6 +35,11 @@ export const up = async function (knex) { name: 'delete:market_directory_info', description: 'delete market_directory_info', }, + { + permission_id: 182, + name: 'get:market_directory_info', + description: 'get market_directory_info', + }, ]); await knex('rolePermissions').insert([ { role_id: 1, permission_id: 179 }, @@ -46,6 +51,9 @@ export const up = async function (knex) { { role_id: 1, permission_id: 181 }, { role_id: 2, permission_id: 181 }, { role_id: 5, permission_id: 181 }, + { role_id: 1, permission_id: 182 }, + { role_id: 2, permission_id: 182 }, + { role_id: 5, permission_id: 182 }, ]); }; @@ -54,7 +62,7 @@ export const up = async function (knex) { * @returns { Promise } */ export const down = function (knex) { - const permissions = [179, 180, 181]; + const permissions = [179, 180, 181, 182]; return Promise.all([ knex('rolePermissions').whereIn('permission_id', permissions).del(), knex('permissions').whereIn('permission_id', permissions).del(), From 3ded73e78fc3de0de0ae56b96a9adc0f83a93424 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 3 Nov 2025 18:20:46 -0800 Subject: [PATCH 21/26] LF-4995 Update social validation * Extract username * Adjust checkAndTransformMarketDirectoryInfo and tests --- .../validation/checkMarketDirectoryInfo.ts | 23 +++------- packages/api/src/util/socials.ts | 45 ++++++++++++++++--- .../api/tests/marketDirectoryInfo.test.ts | 12 ++--- 3 files changed, 51 insertions(+), 29 deletions(-) diff --git a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts index 489f91b4ee..8b0d4456c3 100644 --- a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts +++ b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts @@ -17,7 +17,7 @@ import { Response, NextFunction } from 'express'; import { LiteFarmRequest } from '../../types.js'; import { isValidAddress, isValidEmail } from '../../util/validation.js'; import { isValidUrl } from '../../util/url.js'; -import { SOCIALS, validateAndExtractUsernameOrUrl } from '../../util/socials.js'; +import { SOCIALS, validateSocialAndExtractUsername } from '../../util/socials.js'; import MarketDirectoryInfoModel from '../../models/marketDirectoryInfoModel.js'; export interface MarketDirectoryInfoReqBody { @@ -70,22 +70,13 @@ export function checkAndTransformMarketDirectoryInfo() { return res.status(400).send('Invalid website'); } - const invalidSocials = ( - await Promise.all( - SOCIALS.filter((social) => req.body[social]?.trim()).map(async (social) => { - const formattedSocial = await validateAndExtractUsernameOrUrl(social, req.body[social]!); + for (const social of SOCIALS.filter((social) => req.body[social]?.trim())) { + const socialUsername = validateSocialAndExtractUsername(social, req.body[social]!); - if (formattedSocial) { - req.body[social] = formattedSocial; - return null; - } - return social; - }), - ) - ).filter((social) => social !== null); - - if (invalidSocials.length) { - return res.status(400).send(`Invalid ${invalidSocials.join(', ')}`); + if (!socialUsername) { + return res.status(400).send(`Invalid ${social}`); + } + req.body[social] = socialUsername; } next(); diff --git a/packages/api/src/util/socials.ts b/packages/api/src/util/socials.ts index e56997f012..0d1436aadc 100644 --- a/packages/api/src/util/socials.ts +++ b/packages/api/src/util/socials.ts @@ -13,8 +13,6 @@ * GNU General Public License for more details, see . */ -import { isValidUrl } from './url.js'; - export enum Social { INSTAGRAM = 'instagram', FACEBOOK = 'facebook', @@ -29,11 +27,44 @@ export const SOCIAL_DOMAINS = { export const SOCIALS = Object.values(Social); -export const validateAndExtractUsernameOrUrl = async (social: Social, usernameOrUrl: string) => { +/** + * Validates a social username or URL and extracts the username. + * + * This function handles: + * - Full URLs with optional scheme (http/https) and optional "www." + * e.g., "https://www.instagram.com/username/?hl=en", "http://instagram.com/username/#" + * - Direct usernames with or without a leading "@" + * e.g., "username", "@username" + * + * It rejects: + * - Domain-only inputs, e.g., "instagram.com" or "www.instagram.com" + * - Usernames containing invalid characters (only A-Z, a-z, 0-9, ".", "_", "-" are allowed) + */ +export const validateSocialAndExtractUsername = (social: Social, usernameOrUrl: string) => { + const trimmedInput = usernameOrUrl.trim(); const domain = SOCIAL_DOMAINS[social]; - const isUrl = usernameOrUrl.includes(domain); - const trimmedInput = isUrl ? usernameOrUrl.trim() : usernameOrUrl.trim().replace(/^@/, ''); - const urlToCheck = isUrl ? trimmedInput : `https://${domain}/${trimmedInput}`; - return (await isValidUrl(urlToCheck)) ? trimmedInput : false; + // reject if it’s just the url without username + if (new RegExp(`^(https?://)?(www.)?${domain}/?$`).test(trimmedInput)) { + return false; + } + + // Match URL: [http(s)://][www.]domain/{username}[optional trailing path or query] + // Capture groups: + // 1: http(s):// (optional) + // 2: www. (optional) + // 3: username ([A-Za-z0-9._-]+) + // 4: trailing path/query (optional) ([/?#].*)? e.g., "/?hl=en", "/#" + const urlMatch = trimmedInput.match( + new RegExp(`^(https?://)?(www.)?${domain}/([A-Za-z0-9._-]+)([/?#].*)?$`), + ); + + if (urlMatch) { + return urlMatch[3]; + } + + const username = trimmedInput.replace(/^@/, ''); + + // validate allowed characters + return /^[A-Za-z0-9._-]+$/.test(username) ? username : false; }; diff --git a/packages/api/tests/marketDirectoryInfo.test.ts b/packages/api/tests/marketDirectoryInfo.test.ts index a6beca2bd2..ec5947f103 100644 --- a/packages/api/tests/marketDirectoryInfo.test.ts +++ b/packages/api/tests/marketDirectoryInfo.test.ts @@ -61,9 +61,9 @@ const marketDirectoryInfo = mocks.fakeMarketDirectoryInfo({ country_code: Math.floor(Math.random() * 999) + 1, phone_number: faker.phone.phoneNumber(), website: faker.internet.url(), - instagram: `https://${SOCIAL_DOMAINS.instagram}/username`, - facebook: `https://${SOCIAL_DOMAINS.facebook}/username`, - x: `https://${SOCIAL_DOMAINS.x}/username`, + instagram: 'username', + facebook: 'username', + x: 'username', }); const fakeInvalidString = (input: string = '') => `${input}${INVALID_SUFFIX}`; @@ -73,9 +73,9 @@ const invalidTestCases = [ ['website', fakeInvalidString(faker.internet.url())], ['contact_email', faker.lorem.word()], ['email', faker.lorem.word()], - ['instagram', fakeInvalidString(faker.lorem.word())], // mock username - ['facebook', fakeInvalidString(SOCIAL_DOMAINS.facebook)], // mock url - ['x', `https://${fakeInvalidString(SOCIAL_DOMAINS.x)}`], // mock url + ['instagram', SOCIAL_DOMAINS['instagram']], // domain without username + ['facebook', `/${faker.internet.userName()}`], // username with invalid character + ['x', `https://${SOCIAL_DOMAINS['x']}/username!}`], // url with invalid username ]; async function postRequest(data: MarketDirectoryInfoReqBody, { user_id, farm_id }: HeadersParams) { From c7204892eb80e6a24ccd27ddd32a058e96d498b8 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 3 Nov 2025 18:27:24 -0800 Subject: [PATCH 22/26] LF-4995 Add tests for validateSocialAndExtractUsername --- packages/api/src/util/socials.test.ts | 86 +++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/api/src/util/socials.test.ts diff --git a/packages/api/src/util/socials.test.ts b/packages/api/src/util/socials.test.ts new file mode 100644 index 0000000000..fbf249fb2f --- /dev/null +++ b/packages/api/src/util/socials.test.ts @@ -0,0 +1,86 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { faker } from '@faker-js/faker'; +import { SOCIAL_DOMAINS, SOCIALS, validateSocialAndExtractUsername } from './socials.js'; + +const social1 = SOCIALS[0]; +const social2 = SOCIALS[1]; +const domain = SOCIAL_DOMAINS[social1]; +const username = faker.internet.userName(); + +const invalidUsernames = [`/username`, 'user?name', 'username!', '@user@name']; + +const validUrls = [ + `${domain}/${username}`, + ` ${domain}/${username} `, + `http://${domain}/${username}`, + `https://${domain}/${username}`, + `https://${domain}/${username}/`, + `www.${domain}/${username}`, + `http://www.${domain}/${username}`, + `https://www.${domain}/${username}`, + `https://${domain}/${username}/#`, + `https://${domain}/${username}/?hl=en`, + `https://${domain}/${username}/valid_url_example`, +]; + +const invalidUrls = [ + `${domain}`, + `${domain}/`, + `www.${domain}`, + `www.${domain}/`, + `https://${domain}`, + `https://${domain}/`, + `https://${domain}/?`, + `https://${domain}/#`, + `https://${domain}/ `, + `https://${domain}/https://${domain}/${username}`, + `http://${SOCIAL_DOMAINS[social2]}/${username}`, + `https://${SOCIAL_DOMAINS[social2]}/${username}`, + `https://www.${SOCIAL_DOMAINS[social2]}/${username}`, +]; + +describe('Test socials', () => { + describe('validateSocialAndExtractUsername', () => { + test('Returns username unchanged when input is plain username', () => { + const result = validateSocialAndExtractUsername(social1, username); + expect(result).toBe(username); + }); + + test('Strips @ prefix from username', () => { + const result = validateSocialAndExtractUsername(social1, `@${username}`); + expect(result).toBe(username); + }); + + test.each(invalidUsernames)( + 'Return false for username with invalid character (%s)', + (invalidUsername) => { + const result = validateSocialAndExtractUsername(social1, invalidUsername); + expect(result).toBe(false); + }, + ); + + test.each(validUrls)('Extract username for valid URL (%s)', (url) => { + const result = validateSocialAndExtractUsername(social1, url); + expect(result).toBe(username); + }); + + test.each(invalidUrls)('Return false for invalid URL (%s)', (url) => { + const result = validateSocialAndExtractUsername(social1, url); + expect(result).toBe(false); + }); + }); +}); From fb4edc83d03bf6acc7f74f95aa7b9db6df6554bd Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Mon, 3 Nov 2025 18:31:25 -0800 Subject: [PATCH 23/26] LF-4995 Update tsconfig to exclude test files --- packages/api/src/tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/api/src/tsconfig.json b/packages/api/src/tsconfig.json index 9b779269cb..b69968499c 100644 --- a/packages/api/src/tsconfig.json +++ b/packages/api/src/tsconfig.json @@ -3,5 +3,6 @@ "compilerOptions": { "allowJs": true, "outDir": "../dist" - } + }, + "exclude": ["**/*.test.*"] } From 8990ace46786d5ed5d140c6ea0b37b70da350c8d Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 4 Nov 2025 08:19:32 -0800 Subject: [PATCH 24/26] LF-4995 Move test file --- .../api/{src/util => tests/utils-tests}/socials.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) rename packages/api/{src/util => tests/utils-tests}/socials.test.ts (96%) diff --git a/packages/api/src/util/socials.test.ts b/packages/api/tests/utils-tests/socials.test.ts similarity index 96% rename from packages/api/src/util/socials.test.ts rename to packages/api/tests/utils-tests/socials.test.ts index fbf249fb2f..b0c526996d 100644 --- a/packages/api/src/util/socials.test.ts +++ b/packages/api/tests/utils-tests/socials.test.ts @@ -14,7 +14,11 @@ */ import { faker } from '@faker-js/faker'; -import { SOCIAL_DOMAINS, SOCIALS, validateSocialAndExtractUsername } from './socials.js'; +import { + SOCIAL_DOMAINS, + SOCIALS, + validateSocialAndExtractUsername, +} from '../../src/util/socials.js'; const social1 = SOCIALS[0]; const social2 = SOCIALS[1]; From fb9be43e802bda5af648f2ace68a4b695c9e69b6 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 4 Nov 2025 08:21:01 -0800 Subject: [PATCH 25/26] Revert "LF-4995 Update tsconfig to exclude test files" This reverts commit fb4edc83d03bf6acc7f74f95aa7b9db6df6554bd. --- packages/api/src/tsconfig.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/api/src/tsconfig.json b/packages/api/src/tsconfig.json index b69968499c..9b779269cb 100644 --- a/packages/api/src/tsconfig.json +++ b/packages/api/src/tsconfig.json @@ -3,6 +3,5 @@ "compilerOptions": { "allowJs": true, "outDir": "../dist" - }, - "exclude": ["**/*.test.*"] + } } From 74e1b14b4514b81c1886472c5daa28307ed4e370 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Tue, 4 Nov 2025 08:36:31 -0800 Subject: [PATCH 26/26] LF-4995 Improve tests --- .../api/tests/marketDirectoryInfo.test.ts | 6 ++--- .../api/tests/utils-tests/socials.test.ts | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/api/tests/marketDirectoryInfo.test.ts b/packages/api/tests/marketDirectoryInfo.test.ts index ec5947f103..22999c416b 100644 --- a/packages/api/tests/marketDirectoryInfo.test.ts +++ b/packages/api/tests/marketDirectoryInfo.test.ts @@ -61,9 +61,9 @@ const marketDirectoryInfo = mocks.fakeMarketDirectoryInfo({ country_code: Math.floor(Math.random() * 999) + 1, phone_number: faker.phone.phoneNumber(), website: faker.internet.url(), - instagram: 'username', - facebook: 'username', - x: 'username', + instagram: faker.internet.userName(), + facebook: faker.internet.userName(), + x: faker.internet.userName(), }); const fakeInvalidString = (input: string = '') => `${input}${INVALID_SUFFIX}`; diff --git a/packages/api/tests/utils-tests/socials.test.ts b/packages/api/tests/utils-tests/socials.test.ts index b0c526996d..73c4b216aa 100644 --- a/packages/api/tests/utils-tests/socials.test.ts +++ b/packages/api/tests/utils-tests/socials.test.ts @@ -69,22 +69,25 @@ describe('Test socials', () => { expect(result).toBe(username); }); - test.each(invalidUsernames)( - 'Return false for username with invalid character (%s)', - (invalidUsername) => { + describe('Return false for username with invalid character', () => { + test.each(invalidUsernames)('%s', (invalidUsername) => { const result = validateSocialAndExtractUsername(social1, invalidUsername); expect(result).toBe(false); - }, - ); + }); + }); - test.each(validUrls)('Extract username for valid URL (%s)', (url) => { - const result = validateSocialAndExtractUsername(social1, url); - expect(result).toBe(username); + describe('Extract username for valid URL', () => { + test.each(validUrls)('%s', (url) => { + const result = validateSocialAndExtractUsername(social1, url); + expect(result).toBe(username); + }); }); - test.each(invalidUrls)('Return false for invalid URL (%s)', (url) => { - const result = validateSocialAndExtractUsername(social1, url); - expect(result).toBe(false); + describe('Return false for invalid URL', () => { + test.each(invalidUrls)('%s', (url) => { + const result = validateSocialAndExtractUsername(social1, url); + expect(result).toBe(false); + }); }); }); });