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..bd05dd0291
--- /dev/null
+++ b/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js
@@ -0,0 +1,70 @@
+/*
+ * 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',
+ },
+ {
+ permission_id: 182,
+ name: 'get:market_directory_info',
+ description: 'get 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 },
+ { role_id: 1, permission_id: 182 },
+ { role_id: 2, permission_id: 182 },
+ { role_id: 5, permission_id: 182 },
+ ]);
+};
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export const down = function (knex) {
+ const permissions = [179, 180, 181, 182];
+ return Promise.all([
+ knex('rolePermissions').whereIn('permission_id', permissions).del(),
+ knex('permissions').whereIn('permission_id', permissions).del(),
+ ]);
+};
diff --git a/packages/api/src/controllers/marketDirectoryInfoController.ts b/packages/api/src/controllers/marketDirectoryInfoController.ts
new file mode 100644
index 0000000000..1867333886
--- /dev/null
+++ b/packages/api/src/controllers/marketDirectoryInfoController.ts
@@ -0,0 +1,50 @@
+/*
+ * 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 { 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: LiteFarmRequest,
+ res: Response,
+ ) => {
+ const { farm_id } = req.headers;
+
+ try {
+ 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;
+ 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..8b0d4456c3
--- /dev/null
+++ b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts
@@ -0,0 +1,84 @@
+/*
+ * 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 { Response, NextFunction } from 'express';
+import { LiteFarmRequest } from '../../types.js';
+import { isValidAddress, isValidEmail } from '../../util/validation.js';
+import { isValidUrl } from '../../util/url.js';
+import { SOCIALS, validateSocialAndExtractUsername } from '../../util/socials.js';
+import MarketDirectoryInfoModel from '../../models/marketDirectoryInfoModel.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,
+ ) => {
+ 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) {
+ if (req.body[emailProperty] && !isValidEmail(req.body[emailProperty])) {
+ return res.status(400).send(`Invalid ${emailProperty}`);
+ }
+ }
+
+ if (address && !(await isValidAddress(address))) {
+ return res.status(400).send('Invalid address');
+ }
+
+ if (website && !(await isValidUrl(website)) /* TODO: LF-5011 */) {
+ return res.status(400).send('Invalid website');
+ }
+
+ for (const social of SOCIALS.filter((social) => req.body[social]?.trim())) {
+ const socialUsername = validateSocialAndExtractUsername(social, req.body[social]!);
+
+ if (!socialUsername) {
+ return res.status(400).send(`Invalid ${social}`);
+ }
+ req.body[social] = socialUsername;
+ }
+
+ next();
+ };
+}
diff --git a/packages/api/src/models/marketDirectoryInfoModel.js b/packages/api/src/models/marketDirectoryInfoModel.js
index 476630295c..8a9107125e 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: 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 },
@@ -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: 2000 },
instagram: { type: ['string', 'null'], maxLength: 255 },
facebook: { type: ['string', 'null'], maxLength: 255 },
x: { type: ['string', 'null'], maxLength: 255 },
diff --git a/packages/api/src/routes/marketDirectoryInfoRoute.ts b/packages/api/src/routes/marketDirectoryInfoRoute.ts
new file mode 100644
index 0000000000..cbfcf35fd5
--- /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 { checkAndTransformMarketDirectoryInfo } from '../middleware/validation/checkMarketDirectoryInfo.js';
+import MarketDirectoryInfoController from '../controllers/marketDirectoryInfoController.js';
+
+const router = express.Router();
+
+router.post(
+ '/',
+ checkScope(['add:market_directory_info']),
+ checkAndTransformMarketDirectoryInfo(),
+ 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);
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;
};
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);
+ }
+}
diff --git a/packages/api/src/util/socials.ts b/packages/api/src/util/socials.ts
new file mode 100644
index 0000000000..0d1436aadc
--- /dev/null
+++ b/packages/api/src/util/socials.ts
@@ -0,0 +1,70 @@
+/*
+ * 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 enum Social {
+ INSTAGRAM = 'instagram',
+ FACEBOOK = 'facebook',
+ X = 'x',
+}
+
+export const SOCIAL_DOMAINS = {
+ [Social.INSTAGRAM]: 'instagram.com',
+ [Social.FACEBOOK]: 'facebook.com',
+ [Social.X]: 'x.com',
+};
+
+export const SOCIALS = Object.values(Social);
+
+/**
+ * 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];
+
+ // 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/src/util/url.ts b/packages/api/src/util/url.ts
new file mode 100644
index 0000000000..ed915f9d49
--- /dev/null
+++ b/packages/api/src/util/url.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 const isValidUrl = async (url: string) => {
+ // TODO: LF-5011
+ return !!url;
+};
diff --git a/packages/api/src/util/validation.ts b/packages/api/src/util/validation.ts
index edabd4739d..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
*
@@ -51,3 +53,13 @@ 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 async function isValidAddress(address: string) {
+ return !!(await getAddressComponents(address));
+}
diff --git a/packages/api/tests/marketDirectoryInfo.test.ts b/packages/api/tests/marketDirectoryInfo.test.ts
new file mode 100644
index 0000000000..22999c416b
--- /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: faker.internet.userName(),
+ facebook: faker.internet.userName(),
+ x: faker.internet.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', 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) {
+ 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}`);
+ });
+ });
+ });
+});
diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js
index 8242b10168..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 }),
@@ -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";
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-tests/socials.test.ts b/packages/api/tests/utils-tests/socials.test.ts
new file mode 100644
index 0000000000..73c4b216aa
--- /dev/null
+++ b/packages/api/tests/utils-tests/socials.test.ts
@@ -0,0 +1,93 @@
+/*
+ * 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 '../../src/util/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);
+ });
+
+ describe('Return false for username with invalid character', () => {
+ test.each(invalidUsernames)('%s', (invalidUsername) => {
+ const result = validateSocialAndExtractUsername(social1, invalidUsername);
+ expect(result).toBe(false);
+ });
+ });
+
+ describe('Extract username for valid URL', () => {
+ test.each(validUrls)('%s', (url) => {
+ const result = validateSocialAndExtractUsername(social1, url);
+ expect(result).toBe(username);
+ });
+ });
+
+ describe('Return false for invalid URL', () => {
+ test.each(invalidUrls)('%s', (url) => {
+ const result = validateSocialAndExtractUsername(social1, url);
+ expect(result).toBe(false);
+ });
+ });
+ });
+});
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)
*/