diff --git a/packages/api/db/migration/20260317000000_add_farm_note.js b/packages/api/db/migration/20260317000000_add_farm_note.js
new file mode 100644
index 0000000000..2662ef7eee
--- /dev/null
+++ b/packages/api/db/migration/20260317000000_add_farm_note.js
@@ -0,0 +1,72 @@
+/*
+ * Copyright 2026 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.schema.createTable('farm_note', (table) => {
+ table.uuid('id').primary().defaultTo(knex.raw('uuid_generate_v4()'));
+ table.uuid('farm_id').notNullable().references('farm_id').inTable('farm');
+ table.string('user_id').notNullable().references('user_id').inTable('users');
+ table.text('note').notNullable();
+ table.boolean('is_private').notNullable().defaultTo(false);
+ table.text('image_url');
+ table.string('created_by_user_id').references('user_id').inTable('users');
+ table.string('updated_by_user_id').references('user_id').inTable('users');
+ table.dateTime('created_at').notNullable().defaultTo(knex.fn.now());
+ table.dateTime('updated_at').notNullable().defaultTo(knex.fn.now());
+ table.boolean('deleted').notNullable().defaultTo(false);
+ });
+
+ await knex('permissions').insert([
+ { permission_id: 188, name: 'get:farm_notes', description: 'get farm_notes' },
+ { permission_id: 189, name: 'add:farm_notes', description: 'add farm_notes' },
+ { permission_id: 190, name: 'edit:farm_notes', description: 'edit farm_notes' },
+ { permission_id: 191, name: 'delete:farm_notes', description: 'delete farm_notes' },
+ ]);
+
+ await knex('rolePermissions').insert([
+ { role_id: 1, permission_id: 188 },
+ { role_id: 2, permission_id: 188 },
+ { role_id: 3, permission_id: 188 },
+ { role_id: 5, permission_id: 188 },
+ { role_id: 1, permission_id: 189 },
+ { role_id: 2, permission_id: 189 },
+ { role_id: 3, permission_id: 189 },
+ { role_id: 5, permission_id: 189 },
+ { role_id: 1, permission_id: 190 },
+ { role_id: 2, permission_id: 190 },
+ { role_id: 3, permission_id: 190 },
+ { role_id: 5, permission_id: 190 },
+ { role_id: 1, permission_id: 191 },
+ { role_id: 2, permission_id: 191 },
+ { role_id: 3, permission_id: 191 },
+ { role_id: 5, permission_id: 191 },
+ ]);
+};
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export const down = async function (knex) {
+ await knex.schema.dropTable('farm_note');
+
+ const permissions = [188, 189, 190, 191];
+ await knex('rolePermissions').whereIn('permission_id', permissions).del();
+ await knex('permissions').whereIn('permission_id', permissions).del();
+};
diff --git a/packages/api/db/migration/20260317000001_add_farm_notes_read.js b/packages/api/db/migration/20260317000001_add_farm_notes_read.js
new file mode 100644
index 0000000000..f96b82edbf
--- /dev/null
+++ b/packages/api/db/migration/20260317000001_add_farm_notes_read.js
@@ -0,0 +1,35 @@
+/*
+ * Copyright 2026 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.schema.createTable('farm_notes_read', (table) => {
+ table.string('user_id').notNullable().references('user_id').inTable('users');
+ table.uuid('farm_id').notNullable().references('farm_id').inTable('farm');
+ table.dateTime('last_read_at').notNullable();
+ table.primary(['user_id', 'farm_id']);
+ });
+};
+
+/**
+ * @param { import("knex").Knex } knex
+ * @returns { Promise }
+ */
+export const down = async function (knex) {
+ await knex.schema.dropTable('farm_notes_read');
+};
diff --git a/packages/api/package-lock.json b/packages/api/package-lock.json
index 7557887f49..0921a0b66e 100644
--- a/packages/api/package-lock.json
+++ b/packages/api/package-lock.json
@@ -75,8 +75,10 @@
"@types/express": "^4.17.21",
"@types/jest": "^30.0.0",
"@types/jwk-to-pem": "^2.0.3",
+ "@types/multer": "^2.1.0",
"@types/node": "^22.5.4",
"@types/ua-parser-js": "^0.7.39",
+ "@types/uuid": "^10.0.0",
"eslint": "^9.10.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-json": "^4.0.1",
@@ -5610,6 +5612,16 @@
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
"license": "MIT"
},
+ "node_modules/@types/multer": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.1.0.tgz",
+ "integrity": "sha512-zYZb0+nJhOHtPpGDb3vqPjwpdeGlGC157VpkqNQL+UU2qwoacoQ7MpsAmUptI/0Oa127X32JzWDqQVEXp2RcIA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/express": "*"
+ }
+ },
"node_modules/@types/node": {
"version": "22.19.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.1.tgz",
@@ -5707,6 +5719,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/uuid": {
+ "version": "10.0.0",
+ "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
+ "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/webidl-conversions": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
diff --git a/packages/api/package.json b/packages/api/package.json
index 1416f33946..c287aa216c 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -124,8 +124,10 @@
"@types/express": "^4.17.21",
"@types/jest": "^30.0.0",
"@types/jwk-to-pem": "^2.0.3",
+ "@types/multer": "^2.1.0",
"@types/node": "^22.5.4",
"@types/ua-parser-js": "^0.7.39",
+ "@types/uuid": "^10.0.0",
"eslint": "^9.10.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-json": "^4.0.1",
diff --git a/packages/api/src/controllers/farmNoteController.ts b/packages/api/src/controllers/farmNoteController.ts
new file mode 100644
index 0000000000..e74a0b8359
--- /dev/null
+++ b/packages/api/src/controllers/farmNoteController.ts
@@ -0,0 +1,109 @@
+/*
+ * Copyright 2026 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 { FarmNoteBody, FarmNoteParams } from '../middleware/validation/checkFarmNote.js';
+import FarmNoteModel from '../models/farmNoteModel.js';
+import { LiteFarmRequest } from '../types.js';
+
+const farmNoteController = {
+ getFarmNotes() {
+ return async (req: LiteFarmRequest, res: Response) => {
+ try {
+ const { farm_id } = req.headers;
+ const { user_id } = req.auth!;
+
+ /* @ts-expect-error known issue with models */
+ const notes = await FarmNoteModel.query()
+ .whereNotDeleted()
+ .where('farm_id', farm_id)
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ .where((builder: any) => {
+ builder.where('is_private', false).orWhere('user_id', user_id);
+ })
+ .orderBy('updated_at', 'desc');
+
+ return res.status(200).json(notes);
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ error });
+ }
+ };
+ },
+
+ createFarmNote() {
+ return async (req: LiteFarmRequest, res: Response) => {
+ try {
+ const { farm_id } = req.headers;
+ const { user_id } = req.auth!;
+
+ /* @ts-expect-error known issue with models */
+ const note = await FarmNoteModel.query()
+ .context({ user_id })
+ .insert({ farm_id, user_id, ...res.locals.farmNoteData })
+ .returning('*');
+
+ return res.status(201).json(note);
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ error });
+ }
+ };
+ },
+
+ editFarmNote() {
+ return async (
+ req: LiteFarmRequest,
+ res: Response,
+ ) => {
+ try {
+ const { id } = req.params;
+ const { user_id } = req.auth!;
+
+ /* @ts-expect-error known issue with models */
+ const updated = await FarmNoteModel.query()
+ .context({ user_id })
+ .patchAndFetchById(id, res.locals.farmNoteData);
+
+ return res.status(200).json(updated);
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ error });
+ }
+ };
+ },
+
+ deleteFarmNote() {
+ return async (
+ req: LiteFarmRequest,
+ res: Response,
+ ) => {
+ try {
+ const { id } = req.params;
+ const { user_id } = req.auth!;
+
+ /* @ts-expect-error known issue with models */
+ await FarmNoteModel.query().context({ user_id }).deleteById(id);
+
+ return res.status(200).json({ message: 'Note deleted' });
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ error });
+ }
+ };
+ },
+};
+
+export default farmNoteController;
diff --git a/packages/api/src/controllers/farmNotesReadController.js b/packages/api/src/controllers/farmNotesReadController.js
new file mode 100644
index 0000000000..34980704c9
--- /dev/null
+++ b/packages/api/src/controllers/farmNotesReadController.js
@@ -0,0 +1,59 @@
+/*
+ * Copyright 2026 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 FarmNotesReadModel from '../models/farmNotesReadModel.js';
+
+const farmNotesReadController = {
+ getFarmNotesRead() {
+ return async (req, res) => {
+ try {
+ const { user_id } = req.auth;
+ const { farm_id } = req.headers;
+
+ const row = await FarmNotesReadModel.query().where({ user_id, farm_id }).first();
+
+ return res.status(200).json({ last_read_at: row ? row.last_read_at : null });
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ error });
+ }
+ };
+ },
+
+ markFarmNotesRead() {
+ return async (req, res) => {
+ try {
+ const { user_id } = req.auth;
+ const { farm_id } = req.headers;
+
+ await FarmNotesReadModel.query()
+ .insert({
+ user_id,
+ farm_id,
+ last_read_at: new Date().toISOString(),
+ })
+ .onConflict(['user_id', 'farm_id'])
+ .merge();
+
+ return res.status(204).send();
+ } catch (error) {
+ console.error(error);
+ return res.status(500).json({ error });
+ }
+ };
+ },
+};
+
+export default farmNotesReadController;
diff --git a/packages/api/src/middleware/validation/checkFarmNote.ts b/packages/api/src/middleware/validation/checkFarmNote.ts
new file mode 100644
index 0000000000..b0c681d4da
--- /dev/null
+++ b/packages/api/src/middleware/validation/checkFarmNote.ts
@@ -0,0 +1,114 @@
+/*
+ * Copyright 2026 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 { NextFunction, Response } from 'express';
+import { v4 as uuidv4 } from 'uuid';
+import { PutObjectCommand } from '@aws-sdk/client-s3';
+import FarmNoteModel from '../../models/farmNoteModel.js';
+import {
+ s3,
+ getPrivateS3Url,
+ getPrivateS3BucketName,
+ imaginaryPost,
+} from '../../util/digitalOceanSpaces.js';
+import { HttpError, LiteFarmRequest } from '../../types.js';
+
+export interface FarmNoteBody {
+ data: string;
+}
+
+export interface FarmNoteParams {
+ id: string;
+}
+
+export function checkFarmNoteBody() {
+ return async (
+ req: LiteFarmRequest & {
+ // eslint-disable-next-line no-undef
+ file?: Express.Multer.File;
+ },
+ res: Response,
+ next: NextFunction,
+ ) => {
+ try {
+ const data = JSON.parse(req.body.data);
+ const { farm_id } = req.headers;
+ const farmNoteData = {
+ note: data.note,
+ is_private: data.is_private,
+ image_url: undefined as string | undefined | null,
+ };
+
+ if (req.file) {
+ const fileName = `${farm_id}/farm_note/${uuidv4()}.webp`;
+ const compressedImage = await imaginaryPost(
+ req.file,
+ { width: '1024', type: 'webp' },
+ { endpoint: 'resize' },
+ );
+ await s3.send(
+ new PutObjectCommand({
+ Body: compressedImage.data,
+ Bucket: getPrivateS3BucketName(),
+ Key: fileName,
+ ACL: 'private',
+ }),
+ );
+ farmNoteData.image_url = `${getPrivateS3Url()}/${fileName}`;
+ } else if (data.image_url === null) {
+ farmNoteData.image_url = null;
+ }
+ res.locals.farmNoteData = farmNoteData;
+
+ next();
+ } 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 function checkFarmNoteId(action: string) {
+ return async (
+ req: LiteFarmRequest,
+ res: Response,
+ next: NextFunction,
+ ) => {
+ const user_id = req.auth?.user_id;
+ const { id } = req.params;
+
+ try {
+ /* @ts-expect-error known issue with models */
+ const existing = await FarmNoteModel.query().findById(id).whereNotDeleted();
+ if (!existing) {
+ return res.status(404).json({ error: 'Note not found' });
+ }
+ if (existing.user_id !== user_id) {
+ return res.status(403).json({ error: `Not authorized to ${action} this note` });
+ }
+
+ next();
+ } 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 });
+ }
+ };
+}
diff --git a/packages/api/src/models/farmNoteModel.js b/packages/api/src/models/farmNoteModel.js
new file mode 100644
index 0000000000..65abb0e3cc
--- /dev/null
+++ b/packages/api/src/models/farmNoteModel.js
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2026 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 BaseModel from './baseModel.js';
+
+class FarmNoteModel extends BaseModel {
+ static get tableName() {
+ return 'farm_note';
+ }
+
+ static get idColumn() {
+ return 'id';
+ }
+
+ // Override to expose updated_at and created_by_user_id for display on the frontend.
+ static get hidden() {
+ return ['updated_by_user_id', 'created_at', 'deleted'];
+ }
+
+ static get jsonSchema() {
+ return {
+ type: 'object',
+ required: ['note', 'farm_id'],
+ properties: {
+ id: { type: 'string' },
+ farm_id: { type: 'string' },
+ user_id: { type: 'string' },
+ note: { type: 'string' },
+ is_private: { type: 'boolean' },
+ image_url: { type: ['string', 'null'] },
+ ...this.baseProperties,
+ },
+ };
+ }
+}
+
+export default FarmNoteModel;
diff --git a/packages/api/src/models/farmNotesReadModel.js b/packages/api/src/models/farmNotesReadModel.js
new file mode 100644
index 0000000000..d589bea2f3
--- /dev/null
+++ b/packages/api/src/models/farmNotesReadModel.js
@@ -0,0 +1,40 @@
+/*
+ * Copyright 2026 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 Model from './baseFormatModel.js';
+
+class FarmNotesReadModel extends Model {
+ static get tableName() {
+ return 'farm_notes_read';
+ }
+
+ static get idColumn() {
+ return ['user_id', 'farm_id'];
+ }
+
+ static get jsonSchema() {
+ return {
+ type: 'object',
+ required: ['user_id', 'farm_id', 'last_read_at'],
+ properties: {
+ user_id: { type: 'string' },
+ farm_id: { type: 'string' },
+ last_read_at: { type: 'string', format: 'date-time' },
+ },
+ };
+ }
+}
+
+export default FarmNotesReadModel;
diff --git a/packages/api/src/routes/farmNoteRoute.ts b/packages/api/src/routes/farmNoteRoute.ts
new file mode 100644
index 0000000000..b8aa2dee42
--- /dev/null
+++ b/packages/api/src/routes/farmNoteRoute.ts
@@ -0,0 +1,56 @@
+/*
+ * Copyright 2026 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 hasFarmAccess from '../middleware/acl/hasFarmAccess.js';
+import multerDiskUpload from '../util/fileUpload.js';
+import controller from '../controllers/farmNoteController.js';
+import { checkFarmNoteBody, checkFarmNoteId } from '../middleware/validation/checkFarmNote.js';
+import validateFileExtension from '../middleware/validation/uploadImage.js';
+
+const router = express.Router();
+
+router.get('/', checkScope(['get:farm_notes']), controller.getFarmNotes());
+
+router.post(
+ '/',
+ checkScope(['add:farm_notes']),
+ multerDiskUpload,
+ validateFileExtension,
+ checkFarmNoteBody(),
+ controller.createFarmNote(),
+);
+
+router.patch(
+ '/:id',
+ checkScope(['edit:farm_notes']),
+ hasFarmAccess({ tableName: 'farm_note' }),
+ checkFarmNoteId('edit'),
+ multerDiskUpload,
+ validateFileExtension,
+ checkFarmNoteBody(),
+ controller.editFarmNote(),
+);
+
+router.delete(
+ '/:id',
+ checkScope(['delete:farm_notes']),
+ hasFarmAccess({ tableName: 'farm_note' }),
+ checkFarmNoteId('delete'),
+ controller.deleteFarmNote(),
+);
+
+export default router;
diff --git a/packages/api/src/routes/farmNotesReadRoute.js b/packages/api/src/routes/farmNotesReadRoute.js
new file mode 100644
index 0000000000..c9c3904eda
--- /dev/null
+++ b/packages/api/src/routes/farmNotesReadRoute.js
@@ -0,0 +1,34 @@
+/*
+ * Copyright 2026 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 hasFarmAccess from '../middleware/acl/hasFarmAccess.js';
+import controller from '../controllers/farmNotesReadController.js';
+
+const router = express.Router();
+
+// Both endpoints use get:farm_notes scope. The (user_id, farm_id) composite key
+// is resolved from req.auth and req.headers — no entity ID lookup needed.
+router.get('/', checkScope(['get:farm_notes']), hasFarmAccess({}), controller.getFarmNotesRead());
+
+router.patch(
+ '/',
+ checkScope(['get:farm_notes']),
+ hasFarmAccess({}),
+ controller.markFarmNotesRead(),
+);
+
+export default router;
diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts
index c2126d6088..f2d30829d6 100644
--- a/packages/api/src/server.ts
+++ b/packages/api/src/server.ts
@@ -181,6 +181,8 @@ import marketProductCategoryRoute from './routes/marketProductCategoryRoute.js';
import marketDirectoryPartnerRoute from './routes/marketDirectoryPartnerRoute.js';
import offlineEventLogRoute from './routes/offlineEventLogRoute.js';
import tapeSurveyRoute from './routes/tapeSurveyRoute.js';
+import farmNoteRoute from './routes/farmNoteRoute.js';
+import farmNotesReadRoute from './routes/farmNotesReadRoute.js';
// register API
const router = promiseRouter();
@@ -362,7 +364,9 @@ app
.use('/market_product_categories', marketProductCategoryRoute)
.use('/market_directory_partners', marketDirectoryPartnerRoute)
.use('/offline_event_log', offlineEventLogRoute)
- .use('/tape_survey', tapeSurveyRoute);
+ .use('/tape_survey', tapeSurveyRoute)
+ .use('/farm_note', farmNoteRoute)
+ .use('/farm_notes_read', farmNotesReadRoute);
// 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/tests/farmNote.test.js b/packages/api/tests/farmNote.test.js
new file mode 100644
index 0000000000..70ad9ee1cd
--- /dev/null
+++ b/packages/api/tests/farmNote.test.js
@@ -0,0 +1,357 @@
+/*
+ * Copyright 2026 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 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';
+import mocks from './mock.factories.js';
+import { createUserFarmIds } from './utils/testDataSetup.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();
+ }),
+);
+
+// Mock S3/imaginary so file upload tests work without live infrastructure
+jest.mock('../src/util/digitalOceanSpaces.js', () => ({
+ s3: { send: jest.fn().mockResolvedValue({}) },
+ getPrivateS3BucketName: jest.fn().mockReturnValue('test-bucket'),
+ getPrivateS3Url: jest.fn().mockReturnValue('http://localhost:9000/test-bucket'),
+ imaginaryPost: jest.fn().mockResolvedValue({ data: Buffer.from('fake-image') }),
+}));
+
+jest.mock('@aws-sdk/client-s3', () => ({
+ PutObjectCommand: jest.fn().mockImplementation((args) => args),
+}));
+
+async function getRequest({ user_id, farm_id }) {
+ return chai.request(server).get('/farm_note').set('user_id', user_id).set('farm_id', farm_id);
+}
+
+async function postRequest(data, file, { user_id, farm_id }) {
+ const request = chai
+ .request(server)
+ .post('/farm_note')
+ .set('user_id', user_id)
+ .set('farm_id', farm_id)
+ .field('data', JSON.stringify(data));
+
+ if (file) {
+ request.attach('_file_', file.buffer, file.name);
+ }
+
+ return request;
+}
+
+async function patchRequest(id, data, file, { user_id, farm_id }) {
+ const request = chai
+ .request(server)
+ .patch(`/farm_note/${id}`)
+ .set('user_id', user_id)
+ .set('farm_id', farm_id);
+
+ if (data) {
+ request.field('data', JSON.stringify(data));
+ }
+ if (file) {
+ request.attach('_file_', file.buffer, file.name);
+ }
+
+ return request;
+}
+
+async function deleteRequest(id, { user_id, farm_id }) {
+ return chai
+ .request(server)
+ .delete(`/farm_note/${id}`)
+ .set('user_id', user_id)
+ .set('farm_id', farm_id);
+}
+
+const expectFarmNote = (expectedNote, returnedNotes) => {
+ const returnedNote = returnedNotes.find(({ id }) => id === expectedNote.id);
+ for (const property of ['note', 'is_private', '']) {
+ expect(returnedNote[property]).toBe(expectedNote[property]);
+ }
+};
+
+describe('Farm Note tests', () => {
+ afterAll(async () => {
+ await tableCleanup(knex);
+ await knex.destroy();
+ });
+
+ describe('GET /farm_note', () => {
+ test('Returns public notes for any farm member', async () => {
+ const { user_id: author_id, farm_id } = await createUserFarmIds(1);
+ const farmNote = { note: 'Public note', is_private: false };
+ const [createdNote] = await mocks.farm_noteFactory(
+ { promisedUserFarm: { farm_id, user_id: author_id } },
+ farmNote,
+ );
+
+ for (const roleId of [1, 2, 3, 5]) {
+ const [{ user_id: reader_id }] = await mocks.userFarmFactory({
+ promisedFarm: [{ farm_id }],
+ roleId,
+ });
+
+ const res = await getRequest({ user_id: reader_id, farm_id });
+
+ expect(res.status).toBe(200);
+ expect(Array.isArray(res.body)).toBe(true);
+ expect(res.body.length).toBeGreaterThanOrEqual(1);
+ expectFarmNote(createdNote, res.body);
+ }
+ });
+
+ test('Private notes are only returned to the author', async () => {
+ const { user_id: author_id, farm_id } = await createUserFarmIds(1);
+ const [{ user_id: other_id }] = await mocks.userFarmFactory({ promisedFarm: [{ farm_id }] });
+ const farmNote = { note: 'Private note for author', is_private: true };
+ const [createdNote] = await mocks.farm_noteFactory(
+ { promisedUserFarm: { farm_id, user_id: author_id } },
+ farmNote,
+ );
+
+ // Author can see their own private note
+ const authorRes = await getRequest({ user_id: author_id, farm_id });
+ expect(authorRes.status).toBe(200);
+ expectFarmNote(createdNote, authorRes.body);
+
+ // Other member cannot see the private note
+ const otherRes = await getRequest({ user_id: other_id, farm_id });
+ expect(otherRes.status).toBe(200);
+ expect(otherRes.body.find(({ id }) => id === createdNote.id)).toBe(undefined);
+ });
+
+ test('Returns 403 for user on a different farm', async () => {
+ const { user_id } = await createUserFarmIds(1);
+ const [{ farm_id: other_farm_id }] = await mocks.farmFactory();
+
+ const res = await getRequest({ user_id, farm_id: other_farm_id });
+ expect(res.status).toBe(403);
+ });
+
+ test('Deleted notes are not returned', async () => {
+ const userFarmIds = await createUserFarmIds(1);
+ const [createdNote] = await mocks.farm_noteFactory({ promisedUserFarm: userFarmIds });
+ await knex('farm_note').where('id', createdNote.id).update({ deleted: true });
+
+ const res = await getRequest(userFarmIds);
+ const deletedNote = res.body.find(({ id }) => id === createdNote.id);
+ expect(deletedNote).toBe(undefined);
+ });
+
+ test('Should return notes ordered by updated_at desc', async () => {
+ const userFarmIds = await createUserFarmIds(1);
+
+ for (const note of ['A', 'B', 'C']) {
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ await mocks.farm_noteFactory({ promisedUserFarm: userFarmIds }, { note });
+ }
+
+ const res = await getRequest(userFarmIds);
+ expect(res.body.length).toBe(3);
+ expect(res.body[0].note).toBe('C');
+ expect(res.body[1].note).toBe('B');
+ expect(res.body[2].note).toBe('A');
+
+ // Update note B
+ await knex('farm_note')
+ .where({ ...userFarmIds, note: 'B' })
+ .update({ note: 'Newest note', updated_at: new Date().toISOString() });
+
+ const updatedRes = await getRequest(userFarmIds);
+ expect(updatedRes.body.length).toBe(3);
+ expect(updatedRes.body[0].note).toBe('Newest note');
+ expect(updatedRes.body[1].note).toBe('C');
+ expect(updatedRes.body[2].note).toBe('A');
+ });
+
+ test('Returns empty array when farm has no notes', async () => {
+ const userFarmIds = await createUserFarmIds(1);
+
+ const res = await getRequest(userFarmIds);
+ expect(res.status).toBe(200);
+ expect(res.body.length).toBe(0);
+ });
+ });
+
+ describe('POST /farm_note', () => {
+ test('Creates a note without file', async () => {
+ const userFarmIds = await createUserFarmIds(1);
+ const farmNote = { note: 'Test create note', is_private: false };
+
+ const res = await postRequest(farmNote, undefined, userFarmIds);
+ expect(res.status).toBe(201);
+ expect(res.body.note).toBe(farmNote.note);
+ expect(res.body.farm_id).toBe(userFarmIds.farm_id);
+ expect(res.body.user_id).toBe(userFarmIds.user_id);
+ expect(res.body.updated_at).toBeDefined();
+ });
+
+ test('Creates a private note', async () => {
+ const userFarmIds = await createUserFarmIds(1);
+ const farmNote = { note: 'Private create note', is_private: true };
+
+ const res = await postRequest(farmNote, undefined, userFarmIds);
+ expect(res.status).toBe(201);
+ expect(res.body.is_private).toBe(true);
+ });
+
+ test('Creates a note with file, stores image_url', async () => {
+ const userFarmIds = await createUserFarmIds(1);
+
+ const res = await postRequest(
+ { note: 'Note with file', is_private: false },
+ { buffer: Buffer.from('fake-image-data'), name: 'test.jpg' },
+ userFarmIds,
+ );
+
+ expect(res.status).toBe(201);
+ expect(res.body.image_url).toBeDefined();
+ expect(typeof res.body.image_url).toBe('string');
+ });
+
+ test('Returns 403 for user on a different farm', async () => {
+ const { user_id } = await createUserFarmIds(1);
+ const [{ farm_id: other_farm_id }] = await mocks.farmFactory();
+
+ const res = await postRequest({ note: 'Should fail', is_private: false }, undefined, {
+ user_id,
+ farm_id: other_farm_id,
+ });
+
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe('PATCH /farm_note/:id', () => {
+ test('Author can update note text and is_private', async () => {
+ const userFarmIds = await createUserFarmIds(1);
+ const originalNote = { note: 'Original text', is_private: false };
+ const [createdNote] = await mocks.farm_noteFactory(
+ { promisedUserFarm: userFarmIds },
+ originalNote,
+ );
+
+ const updatedNote = { note: 'Updated text', is_private: true };
+ const res = await patchRequest(createdNote.id, updatedNote, undefined, userFarmIds);
+
+ expect(res.status).toBe(200);
+ expect(res.body.note).toBe(updatedNote.note);
+ expect(res.body.is_private).toBe(updatedNote.is_private);
+ });
+
+ test('Non-author receives 403', async () => {
+ const { user_id: author_id, farm_id } = await createUserFarmIds(1);
+ const [{ user_id: other_id }] = await mocks.userFarmFactory({ promisedFarm: [{ farm_id }] });
+ const [createdNote] = await mocks.farm_noteFactory(
+ { promisedUserFarm: { farm_id, user_id: author_id } },
+ { note: 'Should not be edited', is_private: false },
+ );
+
+ const res = await patchRequest(createdNote.id, { note: 'Edited by non-author' }, undefined, {
+ user_id: other_id,
+ farm_id,
+ });
+
+ expect(res.status).toBe(403);
+ });
+
+ test('Returns 403 for user on a different farm', async () => {
+ const { user_id: author_id, farm_id } = await createUserFarmIds(1);
+ const [{ user_id: other_user_id, farm_id: other_farm_id }] = await mocks.userFarmFactory();
+ const [createdNote] = await mocks.farm_noteFactory(
+ { promisedUserFarm: { farm_id, user_id: author_id } },
+ { note: 'Wrong farm', is_private: false },
+ );
+
+ const res = await patchRequest(createdNote.id, { note: 'Should fail' }, undefined, {
+ user_id: other_user_id,
+ farm_id: other_farm_id,
+ });
+
+ expect(res.status).toBe(403);
+ });
+
+ test('Author can remove the image', async () => {
+ const userFarmIds = await createUserFarmIds(1);
+ const [createdNote] = await mocks.farm_noteFactory(
+ { promisedUserFarm: userFarmIds },
+ {
+ note: 'NOTE with image to be removed',
+ image_url: 'http://example.com/image.jpg',
+ },
+ );
+
+ const res = await patchRequest(createdNote.id, { image_url: null }, undefined, userFarmIds);
+ expect(res.status).toBe(200);
+ expect(res.body.image_url).toBe(null);
+ });
+ });
+
+ describe('DELETE /farm_note/:id', () => {
+ test('Author can soft-delete their note', async () => {
+ const userFarmIds = await createUserFarmIds(1);
+ const [createdNote] = await mocks.farm_noteFactory(
+ { promisedUserFarm: userFarmIds },
+ { note: 'To be deleted' },
+ );
+
+ const res = await deleteRequest(createdNote.id, userFarmIds);
+ expect(res.status).toBe(200);
+
+ const [deletedRecord] = await knex('farm_note').where('id', createdNote.id);
+ expect(deletedRecord.deleted).toBe(true);
+ });
+
+ test('Non-author receives 403', async () => {
+ const { user_id: author_id, farm_id } = await createUserFarmIds(1);
+ const [{ user_id: other_id }] = await mocks.userFarmFactory({ promisedFarm: [{ farm_id }] });
+ const [createdNote] = await mocks.farm_noteFactory(
+ { promisedUserFarm: { user_id: author_id, farm_id } },
+ { note: 'Protected note', is_private: false },
+ );
+
+ const res = await deleteRequest(createdNote.id, { user_id: other_id, farm_id });
+ expect(res.status).toBe(403);
+ });
+
+ test('Returns 403 for user on a different farm', async () => {
+ const { user_id: author_id, farm_id } = await createUserFarmIds(1);
+ const [{ user_id: other_user_id, farm_id: other_farm_id }] = await mocks.userFarmFactory();
+ const [createdNote] = await mocks.farm_noteFactory(
+ { promisedUserFarm: { farm_id, user_id: author_id } },
+ { note: 'Wrong farm', is_private: false },
+ );
+
+ const res = await deleteRequest(createdNote.id, {
+ user_id: other_user_id,
+ farm_id: other_farm_id,
+ });
+ expect(res.status).toBe(403);
+ });
+ });
+});
diff --git a/packages/api/tests/farmNotesRead.test.js b/packages/api/tests/farmNotesRead.test.js
new file mode 100644
index 0000000000..a6e2387fce
--- /dev/null
+++ b/packages/api/tests/farmNotesRead.test.js
@@ -0,0 +1,145 @@
+/*
+ * Copyright 2026 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 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';
+import mocks from './mock.factories.js';
+import { createUserFarmIds } from './utils/testDataSetup.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 setFarmNoteRead = async ({ farm_id, user_id }) => {
+ return await knex('farm_notes_read')
+ .insert({ user_id, farm_id, last_read_at: new Date().toISOString() })
+ .onConflict(['user_id', 'farm_id'])
+ .merge();
+};
+
+async function getRequest({ user_id, farm_id }) {
+ return chai
+ .request(server)
+ .get('/farm_notes_read')
+ .set('user_id', user_id)
+ .set('farm_id', farm_id);
+}
+
+async function patchRequest({ user_id, farm_id }) {
+ return chai
+ .request(server)
+ .patch('/farm_notes_read')
+ .set('user_id', user_id)
+ .set('farm_id', farm_id);
+}
+
+describe('Farm Notes Read tests', () => {
+ afterAll(async () => {
+ await tableCleanup(knex);
+ await knex.destroy();
+ });
+
+ describe('GET /farm_notes_read', () => {
+ test.each([1, 2, 3, 5])(
+ 'Returns { last_read_at: null } when no record exists (role %i)',
+ async (role) => {
+ const userFarmIds = await createUserFarmIds(role);
+
+ const res = await getRequest(userFarmIds);
+ expect(res.status).toBe(200);
+ expect(res.body).toEqual({ last_read_at: null });
+ },
+ );
+
+ test.each([1, 2, 3, 5])(
+ 'Returns { last_read_at: } after mark-read (role %i)',
+ async (role) => {
+ const userFarmIds = await createUserFarmIds(role);
+ await setFarmNoteRead(userFarmIds);
+
+ const res = await getRequest(userFarmIds);
+ expect(res.status).toBe(200);
+ expect(res.body.last_read_at).not.toBeNull();
+ expect(typeof res.body.last_read_at).toBe('string');
+ },
+ );
+
+ test('Returns 403 for user on a different farm', async () => {
+ const { user_id } = await createUserFarmIds(1);
+ const [{ farm_id: other_farm_id }] = await mocks.farmFactory();
+
+ const res = await getRequest({ user_id, farm_id: other_farm_id });
+ expect(res.status).toBe(403);
+ });
+ });
+
+ describe('PATCH /farm_notes_read', () => {
+ test.each([1, 2, 3, 5])(
+ 'Creates a row when none exists and sets last_read_at (role %i)',
+ async (role) => {
+ const userFarmIds = await createUserFarmIds(role);
+
+ const res = await patchRequest(userFarmIds);
+ expect(res.status).toBe(204);
+
+ const row = await knex('farm_notes_read').where(userFarmIds).first();
+ expect(row).toBeDefined();
+ expect(row.last_read_at).toBeDefined();
+ },
+ );
+
+ test.each([1, 2, 3, 5])(
+ 'Updates last_read_at when a row already exists (role %i)',
+ async (role) => {
+ const userFarmIds = await createUserFarmIds(role);
+
+ // First mark-read
+ await patchRequest(userFarmIds);
+
+ const first = await knex('farm_notes_read').where(userFarmIds).first();
+
+ // Brief wait to ensure timestamp differs
+ await new Promise((resolve) => setTimeout(resolve, 10));
+
+ // Second mark-read
+ await patchRequest(userFarmIds);
+
+ const second = await knex('farm_notes_read').where(userFarmIds).first();
+
+ expect(new Date(second.last_read_at).getTime()).toBeGreaterThanOrEqual(
+ new Date(first.last_read_at).getTime(),
+ );
+ },
+ );
+
+ test('Returns 403 for user on a different farm', async () => {
+ const { user_id } = await createUserFarmIds(1);
+ const [{ farm_id: other_farm_id }] = await mocks.farmFactory();
+
+ const res = await patchRequest({ user_id, farm_id: other_farm_id });
+ expect(res.status).toBe(403);
+ });
+ });
+});
diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js
index e7cb3a4a6b..30c278ddb4 100644
--- a/packages/api/tests/mock.factories.js
+++ b/packages/api/tests/mock.factories.js
@@ -2879,6 +2879,25 @@ export const buildIrrigationPrescription = async ({
};
};
+const fakeFarmNote = (defaultData = {}) => {
+ return {
+ note: faker.lorem.sentence(),
+ is_private: false,
+ ...defaultData,
+ };
+};
+
+const farm_noteFactory = async (
+ { promisedUserFarm = userFarmFactory({ roleId: 1 }) } = {},
+ farmNote = fakeFarmNote(),
+) => {
+ const [{ user_id, farm_id }] = await Promise.all([promisedUserFarm]);
+
+ return await knex('farm_note')
+ .insert({ user_id, farm_id, ...baseProperties(user_id), ...farmNote })
+ .returning('*');
+};
+
export default {
weather_stationFactory,
fakeStation,
@@ -3056,5 +3075,7 @@ export default {
fakeMarketDirectoryPartnerAuth,
market_directory_partner_authFactory,
market_directory_partner_permissionsFactory,
+ fakeFarmNote,
+ farm_noteFactory,
baseProperties,
};
diff --git a/packages/api/tests/testEnvironment.js b/packages/api/tests/testEnvironment.js
index 0b140810b1..e70b6143fe 100644
--- a/packages/api/tests/testEnvironment.js
+++ b/packages/api/tests/testEnvironment.js
@@ -154,6 +154,8 @@ async function tableCleanup(knex) {
DELETE FROM "market_directory_partner_auth";
DELETE FROM "market_directory_partner";
DELETE FROM "tape_survey";
+ DELETE FROM "farm_notes_read";
+ DELETE FROM "farm_note";
DELETE FROM "location";
DELETE FROM "userFarm";
DELETE FROM "farm";