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";