Skip to content

Commit 365dae0

Browse files
authored
Merge pull request #4229 from LiteFarmOrg/LF-5360-generalize-the-tape-survey-flow-into-a-reusable-multi-survey-architecture
LF-5360 Generalize the tape survey flow into a reusable multi survey architecture
2 parents 49b10a3 + 943e58a commit 365dae0

31 files changed

Lines changed: 1107 additions & 652 deletions
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2026 LiteFarm.org
3+
* This file is part of LiteFarm.
4+
*
5+
* LiteFarm is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* LiteFarm is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
14+
*/
15+
16+
/**
17+
* Generalizes the bespoke TAPE survey storage into a reusable, multi-survey model:
18+
* - Renames `tape_survey` to `survey_response` and adds a `survey_key` column identifying which survey each row answers
19+
* - Makes `survey_step` nullable, as it is TAPE-specific and not used by every survey
20+
* - Repurposes the TAPE permissions to generic survey_response permissions (add/get/edit).
21+
*
22+
* @param { import("knex").Knex } knex
23+
* @returns { Promise<void> }
24+
*/
25+
export const up = async function (knex) {
26+
await knex.schema.renameTable('tape_survey', 'survey_response');
27+
28+
await knex.schema.alterTable('survey_response', function (table) {
29+
table.string('survey_key').nullable();
30+
});
31+
32+
// Every existing row is a TAPE response.
33+
await knex('survey_response').update({ survey_key: 'tape' });
34+
await knex.schema.alterTable('survey_response', function (table) {
35+
table.string('survey_key').notNullable().alter();
36+
});
37+
38+
await knex.schema.alterTable('survey_response', function (table) {
39+
table.string('survey_step').nullable().alter();
40+
});
41+
42+
// Repurpose the TAPE permissions (names only)
43+
await knex('permissions')
44+
.where({ permission_id: 185 })
45+
.update({ name: 'add:survey_response', description: 'add survey_response' });
46+
await knex('permissions')
47+
.where({ permission_id: 186 })
48+
.update({ name: 'get:survey_response', description: 'get survey_response' });
49+
await knex('permissions')
50+
.where({ permission_id: 187 })
51+
.update({ name: 'edit:survey_response', description: 'edit survey_response' });
52+
};
53+
54+
/**
55+
* @param { import("knex").Knex } knex
56+
* @returns { Promise<void> }
57+
*/
58+
export const down = async function (knex) {
59+
await knex('permissions')
60+
.where({ permission_id: 185 })
61+
.update({ name: 'add:tape_survey', description: 'add tape_survey' });
62+
await knex('permissions')
63+
.where({ permission_id: 186 })
64+
.update({ name: 'get:tape_survey', description: 'get tape_survey' });
65+
await knex('permissions')
66+
.where({ permission_id: 187 })
67+
.update({ name: 'edit:tape_survey', description: 'edit tape_survey' });
68+
69+
await knex.schema.alterTable('survey_response', function (table) {
70+
table.string('survey_step').notNullable().alter();
71+
});
72+
await knex.schema.alterTable('survey_response', function (table) {
73+
table.dropColumn('survey_key');
74+
});
75+
await knex.schema.renameTable('survey_response', 'tape_survey');
76+
};
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright 2026 LiteFarm.org
3+
* This file is part of LiteFarm.
4+
*
5+
* LiteFarm is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* LiteFarm is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU General Public License for more details, see <https://www.gnu.org/licenses/>.
14+
*/
15+
16+
import { Response } from 'express';
17+
import { LiteFarmRequest } from '../types.js';
18+
import SurveyResponseModel from '../models/surveyResponseModel.js';
19+
20+
interface SurveyResponseData {
21+
survey_version: string;
22+
project_id: string;
23+
survey_step?: string;
24+
[key: string]: unknown;
25+
}
26+
27+
interface CreateSurveyResponseReqBody {
28+
survey_key: string;
29+
survey_response: SurveyResponseData;
30+
}
31+
32+
interface LatestSurveyResponseQuery {
33+
survey_key?: string;
34+
}
35+
36+
interface UpdateSurveyResponseParams {
37+
submission_id: string;
38+
}
39+
40+
interface UpdateSurveyResponseReqBody {
41+
survey_response: SurveyResponseData;
42+
}
43+
44+
type InsertableQuery = { insert: (data: Record<string, unknown>) => Promise<unknown> };
45+
46+
const surveyResponseController = {
47+
createSurveyResponse() {
48+
return async (
49+
req: LiteFarmRequest<unknown, unknown, unknown, CreateSurveyResponseReqBody>,
50+
res: Response,
51+
) => {
52+
try {
53+
const { farm_id } = req.headers;
54+
const user_id = req.auth?.user_id;
55+
const { survey_key, survey_response } = req.body;
56+
if (!survey_key) {
57+
return res.status(400).json({ error: 'survey_key is required' });
58+
}
59+
const { survey_version, project_id, survey_step } = survey_response;
60+
61+
const insertQuery = SurveyResponseModel.query().context({
62+
user_id,
63+
}) as unknown as InsertableQuery;
64+
await insertQuery.insert({
65+
farm_id,
66+
survey_key,
67+
survey_response,
68+
survey_version,
69+
project_id,
70+
survey_step,
71+
});
72+
73+
return res.status(201).send();
74+
} catch (error) {
75+
console.error(error);
76+
return res.status(500).json({ error });
77+
}
78+
};
79+
},
80+
81+
getLatestSurveyResponse() {
82+
return async (req: LiteFarmRequest<LatestSurveyResponseQuery>, res: Response) => {
83+
try {
84+
const { farm_id } = req.headers;
85+
const { survey_key } = req.query;
86+
if (!survey_key) {
87+
return res.status(400).json({ error: 'survey_key is required' });
88+
}
89+
// Find the latest survey response of this kind for the farm
90+
const result = await SurveyResponseModel.query()
91+
.where({ farm_id, survey_key })
92+
.orderBy('created_at', 'desc')
93+
.first();
94+
return res.status(200).send(result ?? null);
95+
} catch (error) {
96+
console.error(error);
97+
return res.status(500).json({ error });
98+
}
99+
};
100+
},
101+
102+
// Note: Not currently called from frontend
103+
updateSurveyResponse() {
104+
return async (
105+
req: LiteFarmRequest<
106+
unknown,
107+
UpdateSurveyResponseParams,
108+
unknown,
109+
UpdateSurveyResponseReqBody
110+
>,
111+
res: Response,
112+
) => {
113+
try {
114+
const { farm_id } = req.headers;
115+
const user_id = req.auth?.user_id;
116+
const { submission_id } = req.params;
117+
const { survey_response } = req.body;
118+
const { survey_version, project_id, survey_step } = survey_response;
119+
120+
const existing = (await SurveyResponseModel.query().findOne({ submission_id })) as
121+
| { survey_key: string }
122+
| undefined;
123+
if (!existing) {
124+
return res.status(404).json({ error: 'Survey response not found' });
125+
}
126+
127+
const insertQuery = SurveyResponseModel.query().context({
128+
user_id,
129+
}) as unknown as InsertableQuery;
130+
await insertQuery.insert({
131+
submission_id,
132+
farm_id,
133+
survey_key: existing.survey_key,
134+
survey_response,
135+
survey_version,
136+
project_id,
137+
survey_step,
138+
});
139+
140+
return res.status(204).send();
141+
} catch (error) {
142+
console.error(error);
143+
return res.status(500).json({ error });
144+
}
145+
};
146+
},
147+
};
148+
149+
export default surveyResponseController;

packages/api/src/controllers/tapeSurveyController.js

Lines changed: 0 additions & 87 deletions
This file was deleted.

packages/api/src/middleware/acl/hasFarmAccess.js

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ const entitiesGetters = {
3030
nomination_id: fromNomination,
3131
transplant_task: fromTransPlantTask,
3232
product_id: fromProductFarm,
33-
tape_survey_id: fromTapeSurvey,
34-
submission_id: fromTapeSurvey,
33+
submission_id: fromSurveyResponse,
3534
};
3635
import userFarmModel from '../../models/userFarmModel.js';
3736

@@ -285,8 +284,8 @@ function fromProductFarm(product_id, _next, farm_id) {
285284
return knex('product_farm').where({ product_id, farm_id }).first();
286285
}
287286

288-
function fromTapeSurvey(submission_id) {
289-
return knex('tape_survey').where({ submission_id }).first();
287+
function fromSurveyResponse(submission_id) {
288+
return knex('survey_response').where({ submission_id }).first();
290289
}
291290

292291
function sameFarm(object, farm) {

packages/api/src/models/tapeSurveyModel.js renamed to packages/api/src/models/surveyResponseModel.js

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@
1515

1616
import BaseFormatModel from './baseFormatModel.js';
1717

18-
class TapeSurveyModel extends BaseFormatModel {
18+
class SurveyResponseModel extends BaseFormatModel {
1919
static get tableName() {
20-
return 'tape_survey';
20+
return 'survey_response';
2121
}
2222

2323
static get idColumn() {
@@ -27,14 +27,15 @@ class TapeSurveyModel extends BaseFormatModel {
2727
static get jsonSchema() {
2828
return {
2929
type: 'object',
30-
required: ['farm_id', 'survey_version', 'project_id', 'survey_step', 'survey_response'],
30+
required: ['farm_id', 'survey_key', 'survey_version', 'project_id', 'survey_response'],
3131
properties: {
3232
id: { type: 'integer' },
3333
submission_id: { type: 'string' },
3434
farm_id: { type: 'string' },
35+
survey_key: { type: 'string' },
3536
survey_version: { type: 'string' },
3637
project_id: { type: 'string' },
37-
survey_step: { type: 'string' },
38+
survey_step: { type: ['string', 'null'] },
3839
survey_response: { type: 'object' },
3940
created_by_user_id: { type: 'string' },
4041
created_at: { type: 'string', format: 'date-time' },
@@ -54,4 +55,4 @@ class TapeSurveyModel extends BaseFormatModel {
5455
}
5556
}
5657

57-
export default TapeSurveyModel;
58+
export default SurveyResponseModel;

0 commit comments

Comments
 (0)