From 32477025d442d109553f487124150ab86baef3f0 Mon Sep 17 00:00:00 2001 From: litefarm-pr-bot <266148868+litefarm-pr-bot@users.noreply.github.com> Date: Tue, 17 Mar 2026 15:11:29 -0700 Subject: [PATCH 1/5] LF-5211: Add farm notes data model and API Adds the full backend for the farm notes feature: three Knex migrations (farm_note table, farm_notes_read table, permissions), two Objection models, two controllers with CRUD + read-status handlers, two Express routes, server mounts, hasFarmAccess entity getter, and integration tests. Co-Authored-By: Claude Sonnet 4.6 --- .../migration/20260317000000_add_farm_note.js | 42 +++ .../20260317000001_add_farm_notes_read.js | 40 ++ ...0260317000002_add_farm_note_permissions.js | 55 +++ .../api/src/controllers/farmNoteController.js | 141 +++++++ .../controllers/farmNotesReadController.js | 70 ++++ .../api/src/middleware/acl/hasFarmAccess.js | 5 + packages/api/src/models/farmNoteModel.js | 50 +++ packages/api/src/models/farmNotesReadModel.js | 41 +++ packages/api/src/routes/farmNoteRoute.js | 48 +++ packages/api/src/routes/farmNotesReadRoute.js | 34 ++ packages/api/src/server.ts | 6 +- packages/api/tests/farmNote.test.js | 343 ++++++++++++++++++ packages/api/tests/farmNotesRead.test.js | 151 ++++++++ packages/api/tests/testEnvironment.js | 2 + 14 files changed, 1027 insertions(+), 1 deletion(-) create mode 100644 packages/api/db/migration/20260317000000_add_farm_note.js create mode 100644 packages/api/db/migration/20260317000001_add_farm_notes_read.js create mode 100644 packages/api/db/migration/20260317000002_add_farm_note_permissions.js create mode 100644 packages/api/src/controllers/farmNoteController.js create mode 100644 packages/api/src/controllers/farmNotesReadController.js create mode 100644 packages/api/src/models/farmNoteModel.js create mode 100644 packages/api/src/models/farmNotesReadModel.js create mode 100644 packages/api/src/routes/farmNoteRoute.js create mode 100644 packages/api/src/routes/farmNotesReadRoute.js create mode 100644 packages/api/tests/farmNote.test.js create mode 100644 packages/api/tests/farmNotesRead.test.js 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..f761f7980b --- /dev/null +++ b/packages/api/db/migration/20260317000000_add_farm_note.js @@ -0,0 +1,42 @@ +/* + * 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('farm_note_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); + }); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = async function (knex) { + await knex.schema.dropTable('farm_note'); +}; 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..68fb25b85f --- /dev/null +++ b/packages/api/db/migration/20260317000001_add_farm_notes_read.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 . + */ + +/** + * @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']); + 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); + }); +}; + +/** + * @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/db/migration/20260317000002_add_farm_note_permissions.js b/packages/api/db/migration/20260317000002_add_farm_note_permissions.js new file mode 100644 index 0000000000..8c4d04b83f --- /dev/null +++ b/packages/api/db/migration/20260317000002_add_farm_note_permissions.js @@ -0,0 +1,55 @@ +/* + * 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('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) { + 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/src/controllers/farmNoteController.js b/packages/api/src/controllers/farmNoteController.js new file mode 100644 index 0000000000..1cb6921d16 --- /dev/null +++ b/packages/api/src/controllers/farmNoteController.js @@ -0,0 +1,141 @@ +/* + * 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 FarmNoteModel from '../models/farmNoteModel.js'; +import { + s3, + getPublicS3BucketName, + getPublicS3Url, + imaginaryPost, +} from '../util/digitalOceanSpaces.js'; +import { v4 as uuidv4 } from 'uuid'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; + +const farmNoteController = { + getFarmNotes() { + return async (req, res) => { + try { + const { farm_id } = req.headers; + const { user_id } = req.auth; + + const notes = await FarmNoteModel.query() + .whereNotDeleted() + .where('farm_id', farm_id) + .where(function () { + this.where('is_private', false).orWhere('user_id', user_id); + }) + .orderBy('created_at', 'desc'); + + return res.status(200).json(notes); + } catch (error) { + console.error(error); + return res.status(500).json({ error }); + } + }; + }, + + createFarmNote() { + return async (req, res) => { + try { + const { farm_id } = req.headers; + const { user_id } = req.auth; + const data = JSON.parse(req.body.data); + + let image_url; + if (req.file) { + const TYPE = 'webp'; + const fileName = `farm_note/${farm_id}/${uuidv4()}.${TYPE}`; + const compressedImage = await imaginaryPost( + req.file, + { width: '1024', type: TYPE }, + { endpoint: 'resize' }, + ); + await s3.send( + new PutObjectCommand({ + Body: compressedImage.data, + Bucket: getPublicS3BucketName(), + Key: fileName, + ACL: 'public-read', + }), + ); + image_url = `${getPublicS3Url()}/${fileName}`; + } + + const note = await FarmNoteModel.query() + .context({ user_id }) + .insert({ farm_id, user_id, ...data, ...(image_url ? { image_url } : {}) }) + .returning('*'); + + return res.status(201).json(note); + } catch (error) { + console.error(error); + return res.status(500).json({ error }); + } + }; + }, + + editFarmNote() { + return async (req, res) => { + try { + const { farm_note_id } = req.params; + const { user_id } = req.auth; + + const existing = await FarmNoteModel.query().findById(farm_note_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 edit this note' }); + } + + const { note, is_private } = req.body; + const updated = await FarmNoteModel.query() + .context({ user_id }) + .patchAndFetchById(farm_note_id, { note, is_private }); + + return res.status(200).json(updated); + } catch (error) { + console.error(error); + return res.status(500).json({ error }); + } + }; + }, + + deleteFarmNote() { + return async (req, res) => { + try { + const { farm_note_id } = req.params; + const { user_id } = req.auth; + + const existing = await FarmNoteModel.query().findById(farm_note_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 delete this note' }); + } + + await FarmNoteModel.query().context({ user_id }).deleteById(farm_note_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..f7c44a85f3 --- /dev/null +++ b/packages/api/src/controllers/farmNotesReadController.js @@ -0,0 +1,70 @@ +/* + * 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() + .whereNotDeleted() + .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; + const last_read_at = new Date().toISOString(); + + const existing = await FarmNotesReadModel.query() + .whereNotDeleted() + .where({ user_id, farm_id }) + .first(); + + if (existing) { + await FarmNotesReadModel.query() + .context({ user_id }) + .patch({ last_read_at }) + .where({ user_id, farm_id }); + } else { + await FarmNotesReadModel.query() + .context({ user_id }) + .insert({ user_id, farm_id, last_read_at }); + } + + 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/acl/hasFarmAccess.js b/packages/api/src/middleware/acl/hasFarmAccess.js index 5c38db570f..9d1d7f7552 100644 --- a/packages/api/src/middleware/acl/hasFarmAccess.js +++ b/packages/api/src/middleware/acl/hasFarmAccess.js @@ -32,6 +32,7 @@ const entitiesGetters = { product_id: fromProductFarm, tape_survey_id: fromTapeSurvey, submission_id: fromTapeSurvey, + farm_note_id: fromFarmNote, }; import userFarmModel from '../../models/userFarmModel.js'; @@ -289,6 +290,10 @@ function fromTapeSurvey(submission_id) { return knex('tape_survey').where({ submission_id }).first(); } +function fromFarmNote(farm_note_id) { + return knex('farm_note').where({ farm_note_id }).first(); +} + function sameFarm(object, farm) { return object.farm_id === farm; } diff --git a/packages/api/src/models/farmNoteModel.js b/packages/api/src/models/farmNoteModel.js new file mode 100644 index 0000000000..c2de0156b1 --- /dev/null +++ b/packages/api/src/models/farmNoteModel.js @@ -0,0 +1,50 @@ +/* + * 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 'farm_note_id'; + } + + // Override to expose created_at for date display on the frontend. + // BaseModel strips created_at by default (see baseModel.js line 83). + static get hidden() { + return ['created_by_user_id', 'updated_by_user_id', 'updated_at', 'deleted']; + } + + static get jsonSchema() { + return { + type: 'object', + required: ['note', 'farm_id'], + properties: { + farm_note_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..f5d17b6b74 --- /dev/null +++ b/packages/api/src/models/farmNotesReadModel.js @@ -0,0 +1,41 @@ +/* + * 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 FarmNotesReadModel extends BaseModel { + 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' }, + ...this.baseProperties, + }, + }; + } +} + +export default FarmNotesReadModel; diff --git a/packages/api/src/routes/farmNoteRoute.js b/packages/api/src/routes/farmNoteRoute.js new file mode 100644 index 0000000000..5e31b9530a --- /dev/null +++ b/packages/api/src/routes/farmNoteRoute.js @@ -0,0 +1,48 @@ +/* + * 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'; + +const router = express.Router(); + +router.get('/', checkScope(['get:farm_notes']), hasFarmAccess({}), controller.getFarmNotes()); + +router.post( + '/', + checkScope(['add:farm_notes']), + hasFarmAccess({}), + multerDiskUpload, + controller.createFarmNote(), +); + +router.patch( + '/:farm_note_id', + checkScope(['edit:farm_notes']), + hasFarmAccess({ params: 'farm_note_id' }), + controller.editFarmNote(), +); + +router.delete( + '/:farm_note_id', + checkScope(['delete:farm_notes']), + hasFarmAccess({ params: 'farm_note_id' }), + 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..03077c40a2 --- /dev/null +++ b/packages/api/tests/farmNote.test.js @@ -0,0 +1,343 @@ +/* + * 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'; + +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({}) }, + getPublicS3BucketName: jest.fn().mockReturnValue('test-bucket'), + getPublicS3Url: 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), +})); + +function fakeUserFarm(role = 1) { + return { ...mocks.fakeUserFarm(), role_id: role }; +} + +async function insertNote(knex, { farm_id, user_id, note, is_private = false, image_url = null }) { + const [row] = await knex('farm_note') + .insert({ + farm_id, + user_id, + note, + is_private, + image_url, + created_by_user_id: user_id, + updated_by_user_id: user_id, + }) + .returning('*'); + return row; +} + +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 mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ user_id: reader_id }] = await mocks.userFarmFactory( + { promisedFarm: [{ farm_id }] }, + fakeUserFarm(2), + ); + await insertNote(knex, { + farm_id, + user_id: author_id, + note: 'Public note', + is_private: false, + }); + + const res = await chai + .request(server) + .get('/farm_note') + .set('user_id', reader_id) + .set('farm_id', farm_id); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body.length).toBeGreaterThanOrEqual(1); + expect(res.body.some((n) => n.note === 'Public note')).toBe(true); + }); + + test('Private notes are only returned to the author', async () => { + const [{ user_id: author_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ user_id: other_id }] = await mocks.userFarmFactory( + { promisedFarm: [{ farm_id }] }, + fakeUserFarm(2), + ); + await insertNote(knex, { + farm_id, + user_id: author_id, + note: 'Private note for author', + is_private: true, + }); + + // Author can see their own private note + const authorRes = await chai + .request(server) + .get('/farm_note') + .set('user_id', author_id) + .set('farm_id', farm_id); + expect(authorRes.status).toBe(200); + expect(authorRes.body.some((n) => n.note === 'Private note for author')).toBe(true); + + // Other member cannot see the private note + const otherRes = await chai + .request(server) + .get('/farm_note') + .set('user_id', other_id) + .set('farm_id', farm_id); + expect(otherRes.status).toBe(200); + expect(otherRes.body.some((n) => n.note === 'Private note for author')).toBe(false); + }); + + test('Returns a plain array (not wrapped in an object)', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + + const res = await chai + .request(server) + .get('/farm_note') + .set('user_id', user_id) + .set('farm_id', farm_id); + + expect(res.status).toBe(200); + expect(Array.isArray(res.body)).toBe(true); + expect(res.body).not.toHaveProperty('has_unread'); + }); + + test('Returns 403 for user on a different farm', async () => { + const [{ user_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ farm_id: other_farm_id }] = await mocks.farmFactory(); + + const res = await chai + .request(server) + .get('/farm_note') + .set('user_id', user_id) + .set('farm_id', other_farm_id); + + expect(res.status).toBe(403); + }); + }); + + describe('POST /farm_note', () => { + test('Creates a note without file', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + + const res = await chai + .request(server) + .post('/farm_note') + .set('user_id', user_id) + .set('farm_id', farm_id) + .field('data', JSON.stringify({ note: 'Test create note', is_private: false })); + + expect(res.status).toBe(201); + expect(res.body.note).toBe('Test create note'); + expect(res.body.farm_id).toBe(farm_id); + expect(res.body.user_id).toBe(user_id); + expect(res.body.created_at).toBeDefined(); + }); + + test('Creates a private note', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + + const res = await chai + .request(server) + .post('/farm_note') + .set('user_id', user_id) + .set('farm_id', farm_id) + .field('data', JSON.stringify({ note: 'Private create note', is_private: true })); + + expect(res.status).toBe(201); + expect(res.body.is_private).toBe(true); + }); + + test('Creates a note with file, stores image_url', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + + const res = await chai + .request(server) + .post('/farm_note') + .set('user_id', user_id) + .set('farm_id', farm_id) + .field('data', JSON.stringify({ note: 'Note with image', is_private: false })) + .attach('_file_', Buffer.from('fake-image-data'), 'test.jpg'); + + 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 mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ farm_id: other_farm_id }] = await mocks.farmFactory(); + + const res = await chai + .request(server) + .post('/farm_note') + .set('user_id', user_id) + .set('farm_id', other_farm_id) + .field('data', JSON.stringify({ note: 'Should fail', is_private: false })); + + expect(res.status).toBe(403); + }); + }); + + describe('PATCH /farm_note/:farm_note_id', () => { + test('Author can update note text and is_private', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + const note = await insertNote(knex, { + farm_id, + user_id, + note: 'Original text', + is_private: false, + }); + + const res = await chai + .request(server) + .patch(`/farm_note/${note.farm_note_id}`) + .set('user_id', user_id) + .set('farm_id', farm_id) + .send({ note: 'Updated text', is_private: true }); + + expect(res.status).toBe(200); + expect(res.body.note).toBe('Updated text'); + expect(res.body.is_private).toBe(true); + }); + + test('Non-author receives 403', async () => { + const [{ user_id: author_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ user_id: other_id }] = await mocks.userFarmFactory( + { promisedFarm: [{ farm_id }] }, + fakeUserFarm(2), + ); + const note = await insertNote(knex, { + farm_id, + user_id: author_id, + note: 'Should not be edited', + }); + + const res = await chai + .request(server) + .patch(`/farm_note/${note.farm_note_id}`) + .set('user_id', other_id) + .set('farm_id', farm_id) + .send({ note: 'Edited by non-author', is_private: false }); + + expect(res.status).toBe(403); + }); + + test('Returns 403 for user on a different farm', async () => { + const [{ user_id: author_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ user_id: other_user_id, farm_id: other_farm_id }] = await mocks.userFarmFactory( + {}, + fakeUserFarm(1), + ); + const note = await insertNote(knex, { + farm_id, + user_id: author_id, + note: 'Wrong farm', + }); + + const res = await chai + .request(server) + .patch(`/farm_note/${note.farm_note_id}`) + .set('user_id', other_user_id) + .set('farm_id', other_farm_id) + .send({ note: 'Should fail', is_private: false }); + + expect(res.status).toBe(403); + }); + }); + + describe('DELETE /farm_note/:farm_note_id', () => { + test('Author can soft-delete their note', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + const note = await insertNote(knex, { farm_id, user_id, note: 'To be deleted' }); + + const res = await chai + .request(server) + .delete(`/farm_note/${note.farm_note_id}`) + .set('user_id', user_id) + .set('farm_id', farm_id); + + expect(res.status).toBe(200); + + // Verify soft-deleted — should not appear in GET + const getRes = await chai + .request(server) + .get('/farm_note') + .set('user_id', user_id) + .set('farm_id', farm_id); + expect(getRes.body.some((n) => n.farm_note_id === note.farm_note_id)).toBe(false); + }); + + test('Non-author receives 403', async () => { + const [{ user_id: author_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ user_id: other_id }] = await mocks.userFarmFactory( + { promisedFarm: [{ farm_id }] }, + fakeUserFarm(2), + ); + const note = await insertNote(knex, { farm_id, user_id: author_id, note: 'Protected note' }); + + const res = await chai + .request(server) + .delete(`/farm_note/${note.farm_note_id}`) + .set('user_id', other_id) + .set('farm_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 mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ user_id: other_user_id, farm_id: other_farm_id }] = await mocks.userFarmFactory( + {}, + fakeUserFarm(1), + ); + const note = await insertNote(knex, { farm_id, user_id: author_id, note: 'Wrong farm' }); + + const res = await chai + .request(server) + .delete(`/farm_note/${note.farm_note_id}`) + .set('user_id', other_user_id) + .set('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..f0be2c2844 --- /dev/null +++ b/packages/api/tests/farmNotesRead.test.js @@ -0,0 +1,151 @@ +/* + * 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'; + +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(); + }), +); + +function fakeUserFarm(role = 1) { + return { ...mocks.fakeUserFarm(), role_id: role }; +} + +describe('Farm Notes Read tests', () => { + afterAll(async () => { + await tableCleanup(knex); + await knex.destroy(); + }); + + describe('GET /farm_notes_read', () => { + test('Returns { last_read_at: null } when no record exists', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + + const res = await chai + .request(server) + .get('/farm_notes_read') + .set('user_id', user_id) + .set('farm_id', farm_id); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ last_read_at: null }); + }); + + test('Returns { last_read_at: } after mark-read', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + + await chai + .request(server) + .patch('/farm_notes_read') + .set('user_id', user_id) + .set('farm_id', farm_id); + + const res = await chai + .request(server) + .get('/farm_notes_read') + .set('user_id', user_id) + .set('farm_id', farm_id); + + 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 mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ farm_id: other_farm_id }] = await mocks.farmFactory(); + + const res = await chai + .request(server) + .get('/farm_notes_read') + .set('user_id', user_id) + .set('farm_id', other_farm_id); + + expect(res.status).toBe(403); + }); + }); + + describe('PATCH /farm_notes_read', () => { + test('Creates a row when none exists and sets last_read_at', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + + const res = await chai + .request(server) + .patch('/farm_notes_read') + .set('user_id', user_id) + .set('farm_id', farm_id); + + expect(res.status).toBe(204); + + const row = await knex('farm_notes_read').where({ user_id, farm_id }).first(); + expect(row).toBeDefined(); + expect(row.last_read_at).toBeDefined(); + }); + + test('Updates last_read_at when a row already exists', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + + // First mark-read + await chai + .request(server) + .patch('/farm_notes_read') + .set('user_id', user_id) + .set('farm_id', farm_id); + + const first = await knex('farm_notes_read').where({ user_id, farm_id }).first(); + + // Brief wait to ensure timestamp differs + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Second mark-read + await chai + .request(server) + .patch('/farm_notes_read') + .set('user_id', user_id) + .set('farm_id', farm_id); + + const second = await knex('farm_notes_read').where({ user_id, farm_id }).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 mocks.userFarmFactory({}, fakeUserFarm(1)); + const [{ farm_id: other_farm_id }] = await mocks.farmFactory(); + + const res = await chai + .request(server) + .patch('/farm_notes_read') + .set('user_id', user_id) + .set('farm_id', other_farm_id); + + expect(res.status).toBe(403); + }); + }); +}); 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"; From f3c3a317b3bcfa6ff6ab849116abacd23b151548 Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Wed, 18 Mar 2026 13:35:56 -0700 Subject: [PATCH 2/5] LF-5211 Combine two migration files --- .../migration/20260317000000_add_farm_note.js | 30 ++++++++++ ...0260317000002_add_farm_note_permissions.js | 55 ------------------- 2 files changed, 30 insertions(+), 55 deletions(-) delete mode 100644 packages/api/db/migration/20260317000002_add_farm_note_permissions.js diff --git a/packages/api/db/migration/20260317000000_add_farm_note.js b/packages/api/db/migration/20260317000000_add_farm_note.js index f761f7980b..54b066c069 100644 --- a/packages/api/db/migration/20260317000000_add_farm_note.js +++ b/packages/api/db/migration/20260317000000_add_farm_note.js @@ -31,6 +31,32 @@ export const up = async function (knex) { 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 }, + ]); }; /** @@ -39,4 +65,8 @@ export const up = async function (knex) { */ 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/20260317000002_add_farm_note_permissions.js b/packages/api/db/migration/20260317000002_add_farm_note_permissions.js deleted file mode 100644 index 8c4d04b83f..0000000000 --- a/packages/api/db/migration/20260317000002_add_farm_note_permissions.js +++ /dev/null @@ -1,55 +0,0 @@ -/* - * 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('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) { - const permissions = [188, 189, 190, 191]; - await knex('rolePermissions').whereIn('permission_id', permissions).del(); - await knex('permissions').whereIn('permission_id', permissions).del(); -}; From 6f997ffe95d55c39456eac0fdd35202a217af08d Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Wed, 18 Mar 2026 13:58:02 -0700 Subject: [PATCH 3/5] LF-5211 Adjust table schema --- .../db/migration/20260317000001_add_farm_notes_read.js | 5 ----- .../api/src/controllers/farmNotesReadController.js | 10 ++-------- packages/api/src/models/farmNotesReadModel.js | 5 ++--- 3 files changed, 4 insertions(+), 16 deletions(-) diff --git a/packages/api/db/migration/20260317000001_add_farm_notes_read.js b/packages/api/db/migration/20260317000001_add_farm_notes_read.js index 68fb25b85f..f96b82edbf 100644 --- a/packages/api/db/migration/20260317000001_add_farm_notes_read.js +++ b/packages/api/db/migration/20260317000001_add_farm_notes_read.js @@ -23,11 +23,6 @@ export const up = async function (knex) { table.uuid('farm_id').notNullable().references('farm_id').inTable('farm'); table.dateTime('last_read_at').notNullable(); table.primary(['user_id', 'farm_id']); - 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); }); }; diff --git a/packages/api/src/controllers/farmNotesReadController.js b/packages/api/src/controllers/farmNotesReadController.js index f7c44a85f3..657874bce7 100644 --- a/packages/api/src/controllers/farmNotesReadController.js +++ b/packages/api/src/controllers/farmNotesReadController.js @@ -22,10 +22,7 @@ const farmNotesReadController = { const { user_id } = req.auth; const { farm_id } = req.headers; - const row = await FarmNotesReadModel.query() - .whereNotDeleted() - .where({ user_id, farm_id }) - .first(); + 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) { @@ -42,10 +39,7 @@ const farmNotesReadController = { const { farm_id } = req.headers; const last_read_at = new Date().toISOString(); - const existing = await FarmNotesReadModel.query() - .whereNotDeleted() - .where({ user_id, farm_id }) - .first(); + const existing = await FarmNotesReadModel.query().where({ user_id, farm_id }).first(); if (existing) { await FarmNotesReadModel.query() diff --git a/packages/api/src/models/farmNotesReadModel.js b/packages/api/src/models/farmNotesReadModel.js index f5d17b6b74..d589bea2f3 100644 --- a/packages/api/src/models/farmNotesReadModel.js +++ b/packages/api/src/models/farmNotesReadModel.js @@ -13,9 +13,9 @@ * GNU General Public License for more details, see . */ -import BaseModel from './baseModel.js'; +import Model from './baseFormatModel.js'; -class FarmNotesReadModel extends BaseModel { +class FarmNotesReadModel extends Model { static get tableName() { return 'farm_notes_read'; } @@ -32,7 +32,6 @@ class FarmNotesReadModel extends BaseModel { user_id: { type: 'string' }, farm_id: { type: 'string' }, last_read_at: { type: 'string', format: 'date-time' }, - ...this.baseProperties, }, }; } From 63fc2633567e9950f48bf45c15032b783a0c7e8c Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Wed, 18 Mar 2026 14:46:39 -0700 Subject: [PATCH 4/5] LF-5211 Extract farm_note_id validation --- .../api/src/controllers/farmNoteController.js | 20 +------ .../middleware/validation/checkFarmNote.ts | 52 +++++++++++++++++++ packages/api/src/routes/farmNoteRoute.js | 3 ++ 3 files changed, 57 insertions(+), 18 deletions(-) create mode 100644 packages/api/src/middleware/validation/checkFarmNote.ts diff --git a/packages/api/src/controllers/farmNoteController.js b/packages/api/src/controllers/farmNoteController.js index 1cb6921d16..879fe79e74 100644 --- a/packages/api/src/controllers/farmNoteController.js +++ b/packages/api/src/controllers/farmNoteController.js @@ -13,6 +13,8 @@ * GNU General Public License for more details, see . */ +import { v4 as uuidv4 } from 'uuid'; +import { PutObjectCommand } from '@aws-sdk/client-s3'; import FarmNoteModel from '../models/farmNoteModel.js'; import { s3, @@ -20,8 +22,6 @@ import { getPublicS3Url, imaginaryPost, } from '../util/digitalOceanSpaces.js'; -import { v4 as uuidv4 } from 'uuid'; -import { PutObjectCommand } from '@aws-sdk/client-s3'; const farmNoteController = { getFarmNotes() { @@ -92,14 +92,6 @@ const farmNoteController = { const { farm_note_id } = req.params; const { user_id } = req.auth; - const existing = await FarmNoteModel.query().findById(farm_note_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 edit this note' }); - } - const { note, is_private } = req.body; const updated = await FarmNoteModel.query() .context({ user_id }) @@ -119,14 +111,6 @@ const farmNoteController = { const { farm_note_id } = req.params; const { user_id } = req.auth; - const existing = await FarmNoteModel.query().findById(farm_note_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 delete this note' }); - } - await FarmNoteModel.query().context({ user_id }).deleteById(farm_note_id); return res.status(200).json({ message: 'Note deleted' }); diff --git a/packages/api/src/middleware/validation/checkFarmNote.ts b/packages/api/src/middleware/validation/checkFarmNote.ts new file mode 100644 index 0000000000..a91048ea83 --- /dev/null +++ b/packages/api/src/middleware/validation/checkFarmNote.ts @@ -0,0 +1,52 @@ +/* + * 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 FarmNoteModel from '../../models/farmNoteModel.js'; +import { HttpError, LiteFarmRequest } from '../../types.js'; + +export interface FarmNoteParams { + farm_note_id: string; +} + +export function checkFarmNoteId(action: string) { + return async ( + req: LiteFarmRequest, + res: Response, + next: NextFunction, + ) => { + const user_id = req.auth?.user_id; + const { farm_note_id } = req.params; + + try { + /* @ts-expect-error known issue with models */ + const existing = await FarmNoteModel.query().findById(farm_note_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/routes/farmNoteRoute.js b/packages/api/src/routes/farmNoteRoute.js index 5e31b9530a..b4594977c7 100644 --- a/packages/api/src/routes/farmNoteRoute.js +++ b/packages/api/src/routes/farmNoteRoute.js @@ -18,6 +18,7 @@ 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 { checkFarmNoteId } from '../middleware/validation/checkFarmNote.js'; const router = express.Router(); @@ -35,6 +36,7 @@ router.patch( '/:farm_note_id', checkScope(['edit:farm_notes']), hasFarmAccess({ params: 'farm_note_id' }), + checkFarmNoteId('edit'), controller.editFarmNote(), ); @@ -42,6 +44,7 @@ router.delete( '/:farm_note_id', checkScope(['delete:farm_notes']), hasFarmAccess({ params: 'farm_note_id' }), + checkFarmNoteId('delete'), controller.deleteFarmNote(), ); From 343aa8b60420e1ced93172126606602a0d2ba3aa Mon Sep 17 00:00:00 2001 From: Sayaka Ono Date: Wed, 18 Mar 2026 16:22:08 -0700 Subject: [PATCH 5/5] LF-5211 Create checkFarmNoteBody middleware * use middlware in post/patch routes * add/adjust tests --- packages/api/package-lock.json | 19 ++++++ packages/api/package.json | 2 + .../api/src/controllers/farmNoteController.js | 34 +--------- .../middleware/validation/checkFarmNote.ts | 62 +++++++++++++++++++ packages/api/src/routes/farmNoteRoute.js | 5 +- packages/api/tests/farmNote.test.js | 21 ++++++- 6 files changed, 108 insertions(+), 35 deletions(-) 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.js b/packages/api/src/controllers/farmNoteController.js index 879fe79e74..0a1d05c977 100644 --- a/packages/api/src/controllers/farmNoteController.js +++ b/packages/api/src/controllers/farmNoteController.js @@ -13,15 +13,7 @@ * GNU General Public License for more details, see . */ -import { v4 as uuidv4 } from 'uuid'; -import { PutObjectCommand } from '@aws-sdk/client-s3'; import FarmNoteModel from '../models/farmNoteModel.js'; -import { - s3, - getPublicS3BucketName, - getPublicS3Url, - imaginaryPost, -} from '../util/digitalOceanSpaces.js'; const farmNoteController = { getFarmNotes() { @@ -51,31 +43,10 @@ const farmNoteController = { try { const { farm_id } = req.headers; const { user_id } = req.auth; - const data = JSON.parse(req.body.data); - - let image_url; - if (req.file) { - const TYPE = 'webp'; - const fileName = `farm_note/${farm_id}/${uuidv4()}.${TYPE}`; - const compressedImage = await imaginaryPost( - req.file, - { width: '1024', type: TYPE }, - { endpoint: 'resize' }, - ); - await s3.send( - new PutObjectCommand({ - Body: compressedImage.data, - Bucket: getPublicS3BucketName(), - Key: fileName, - ACL: 'public-read', - }), - ); - image_url = `${getPublicS3Url()}/${fileName}`; - } const note = await FarmNoteModel.query() .context({ user_id }) - .insert({ farm_id, user_id, ...data, ...(image_url ? { image_url } : {}) }) + .insert({ farm_id, user_id, ...res.locals.farmNoteData }) .returning('*'); return res.status(201).json(note); @@ -92,10 +63,9 @@ const farmNoteController = { const { farm_note_id } = req.params; const { user_id } = req.auth; - const { note, is_private } = req.body; const updated = await FarmNoteModel.query() .context({ user_id }) - .patchAndFetchById(farm_note_id, { note, is_private }); + .patchAndFetchById(farm_note_id, res.locals.farmNoteData); return res.status(200).json(updated); } catch (error) { diff --git a/packages/api/src/middleware/validation/checkFarmNote.ts b/packages/api/src/middleware/validation/checkFarmNote.ts index a91048ea83..a68f30d927 100644 --- a/packages/api/src/middleware/validation/checkFarmNote.ts +++ b/packages/api/src/middleware/validation/checkFarmNote.ts @@ -14,13 +14,75 @@ */ 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, + getPublicS3BucketName, + getPublicS3Url, + imaginaryPost, +} from '../../util/digitalOceanSpaces.js'; import { HttpError, LiteFarmRequest } from '../../types.js'; +export interface FarmNoteBody { + data: string; +} + export interface FarmNoteParams { farm_note_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: getPublicS3BucketName(), + Key: fileName, + ACL: 'public-read', + }), + ); + farmNoteData.image_url = `${getPublicS3Url()}/${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, diff --git a/packages/api/src/routes/farmNoteRoute.js b/packages/api/src/routes/farmNoteRoute.js index b4594977c7..3268c71abf 100644 --- a/packages/api/src/routes/farmNoteRoute.js +++ b/packages/api/src/routes/farmNoteRoute.js @@ -18,7 +18,7 @@ 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 { checkFarmNoteId } from '../middleware/validation/checkFarmNote.js'; +import { checkFarmNoteBody, checkFarmNoteId } from '../middleware/validation/checkFarmNote.js'; const router = express.Router(); @@ -29,6 +29,7 @@ router.post( checkScope(['add:farm_notes']), hasFarmAccess({}), multerDiskUpload, + checkFarmNoteBody(), controller.createFarmNote(), ); @@ -37,6 +38,8 @@ router.patch( checkScope(['edit:farm_notes']), hasFarmAccess({ params: 'farm_note_id' }), checkFarmNoteId('edit'), + multerDiskUpload, + checkFarmNoteBody(), controller.editFarmNote(), ); diff --git a/packages/api/tests/farmNote.test.js b/packages/api/tests/farmNote.test.js index 03077c40a2..101ed937e3 100644 --- a/packages/api/tests/farmNote.test.js +++ b/packages/api/tests/farmNote.test.js @@ -137,7 +137,6 @@ describe('Farm Note tests', () => { expect(res.status).toBe(200); expect(Array.isArray(res.body)).toBe(true); - expect(res.body).not.toHaveProperty('has_unread'); }); test('Returns 403 for user on a different farm', async () => { @@ -232,7 +231,7 @@ describe('Farm Note tests', () => { .patch(`/farm_note/${note.farm_note_id}`) .set('user_id', user_id) .set('farm_id', farm_id) - .send({ note: 'Updated text', is_private: true }); + .field('data', JSON.stringify({ note: 'Updated text', is_private: true })); expect(res.status).toBe(200); expect(res.body.note).toBe('Updated text'); @@ -282,6 +281,24 @@ describe('Farm Note tests', () => { expect(res.status).toBe(403); }); + + test('Author can remove the image', async () => { + const [{ user_id, farm_id }] = await mocks.userFarmFactory({}, fakeUserFarm(1)); + const note = await insertNote(knex, { + farm_id, + user_id, + note: 'NOTE with image to be removed', + image_url: 'http://example.com/image.jpg', + }); + const res = await chai + .request(server) + .patch(`/farm_note/${note.farm_note_id}`) + .set('user_id', user_id) + .set('farm_id', farm_id) + .field('data', JSON.stringify({ image_url: null })); + expect(res.status).toBe(200); + expect(res.body.image_url).toBe(null); + }); }); describe('DELETE /farm_note/:farm_note_id', () => {