Skip to content

Commit 72c340e

Browse files
committed
wip: fixes controller
1 parent af610b4 commit 72c340e

File tree

4 files changed

+434
-0
lines changed

4 files changed

+434
-0
lines changed

src/controllers/fixes.js

Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
/*
2+
* Copyright 2025 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
/* eslint-disable no-use-before-define */
13+
14+
/**
15+
* @import { FixEntity, FixEntityCollection } from "@adobe/spacecat-shared-data-access"
16+
*/
17+
18+
import {
19+
badRequest,
20+
createResponse,
21+
noContent,
22+
notFound,
23+
ok,
24+
} from '@adobe/spacecat-shared-http-utils';
25+
import { ValidationError } from '@adobe/spacecat-shared-data-access';
26+
import {
27+
hasText, isIsoDate, isNonEmptyObject, isValidUUID,
28+
} from '@adobe/spacecat-shared-utils';
29+
import { FixDto } from '../dto/fix.js';
30+
31+
/**
32+
* @typedef {Object} Context
33+
* @property {Object.<string, undefined | null | boolean | number | string>} [params]
34+
*/
35+
36+
export class FixesController {
37+
/** @type {FixEntityCollection} */
38+
#FixEntity;
39+
40+
constructor(dataAccess) {
41+
this.#FixEntity = dataAccess.FixEntity;
42+
}
43+
44+
/**
45+
* Gets all suggestions for a given site and opportunity.
46+
*
47+
* @param {Context} context - request context
48+
* @returns {Promise<Response>} Array of suggestions response.
49+
*/
50+
async getAllForSuggestion(context) {
51+
const { siteId, opptyId, suggestionId } = context.params ?? {};
52+
53+
let res = checkRequestParams(siteId, opptyId, suggestionId);
54+
if (res) return res;
55+
56+
const fixEntities = await this.#FixEntity.allBySuggestionId(suggestionId);
57+
58+
// Check whether the suggestion belongs to the opportunity,
59+
// and the opportunity belongs to the site.
60+
if (fixEntities.length > 0) {
61+
res = checkOwnership(fixEntities[0], suggestionId, opptyId, siteId);
62+
if (res) return res;
63+
}
64+
65+
const fixes = fixEntities.map((fix) => FixDto.toJSON(fix));
66+
return ok(fixes);
67+
}
68+
69+
/**
70+
* Gets all suggestions for a given site, opportunity and status.
71+
*
72+
* @param {Context} context - request context
73+
* @returns {Promise<Response>} Array of suggestions response.
74+
*/
75+
async getByStatus(context) {
76+
const {
77+
siteId, opptyId, suggestionId, status,
78+
} = context.params ?? {};
79+
let res = checkRequestParams(siteId, opptyId, suggestionId);
80+
if (res) return res;
81+
82+
if (!hasText(status)) {
83+
return badRequest('Status is required');
84+
}
85+
86+
const fixEntities = await this.#FixEntity.allBySuggestionIdAndStatus(suggestionId, status);
87+
if (fixEntities.length > 0) {
88+
res = checkOwnership(fixEntities[0], suggestionId, opptyId, siteId);
89+
if (res) return res;
90+
}
91+
92+
return ok(fixEntities.map((fix) => FixDto.toJSON(fix)));
93+
}
94+
95+
/**
96+
* Get a suggestion given a site, opportunity and suggestion ID
97+
*
98+
* @param {Context} context - request context
99+
* @returns {Promise<Response>} Suggestion response.
100+
*/
101+
async getByID(context) {
102+
const {
103+
siteId, opptyId, suggestionId, fixId,
104+
} = context.params ?? {};
105+
106+
let res = checkRequestParams(siteId, opptyId, suggestionId);
107+
if (res) return res;
108+
if (!isValidUUID(fixId)) {
109+
return badRequest('Fix ID is required');
110+
}
111+
112+
const fix = await this.#FixEntity.findById(fixId);
113+
res = fix ? checkOwnership(fix, opptyId, siteId) : notFound('Fix not found');
114+
if (res) return res;
115+
116+
return ok(FixDto.toJSON(fix));
117+
}
118+
119+
/**
120+
* Creates one or more fixes for a given site, opportunity, and suggestion.
121+
*
122+
* @param {Context} context - request context
123+
* @returns {Promise<Response>} Array of suggestions response.
124+
*/
125+
async createFixes(context) {
126+
const { siteId, opptyId, suggestionId } = context.params ?? {};
127+
128+
const res = checkRequestParams(siteId, opptyId, suggestionId);
129+
if (res) return res;
130+
131+
if (!Array.isArray(context.data)) {
132+
return context.data ? badRequest('No updates provided') : badRequest('Request body must be an array');
133+
}
134+
135+
const FixEntity = this.#FixEntity;
136+
const fixes = await Promise.all(context.data.map(async (fixData, index) => {
137+
try {
138+
return {
139+
index,
140+
fix: FixDto.toJSON(await FixEntity.create({ ...fixData, suggestionId })),
141+
statusCode: 201,
142+
};
143+
} catch (error) {
144+
return {
145+
index,
146+
message: error.message,
147+
statusCode: error instanceof ValidationError ? 400 : 500,
148+
};
149+
}
150+
}));
151+
const succeeded = countSucceeded(fixes);
152+
return createResponse({
153+
fixes,
154+
metadata: {
155+
total: fixes.length,
156+
success: succeeded,
157+
failed: fixes.length - succeeded,
158+
},
159+
}, 207);
160+
}
161+
162+
/**
163+
* Update the status of one or multiple suggestions in one transaction
164+
*
165+
* @param {Context} context - request context
166+
* @returns {Promise<Response>} the updated opportunity data
167+
*/
168+
async patchFixesStatus(context) {
169+
const { siteId, opptyId, suggestionId } = context.params ?? {};
170+
171+
const res = checkRequestParams(siteId, opptyId, suggestionId);
172+
if (res) return res;
173+
174+
if (!Array.isArray(context.data)) {
175+
return context.data ? badRequest('No updates provided') : badRequest('Request body must be an array of [{ id: <fix id>, status: <fix status> },...]');
176+
}
177+
178+
const fixes = await Promise.all(
179+
context.data.map((data, index) => this.#patchFixStatus(data.id, data.status, index)),
180+
);
181+
const succeeded = countSucceeded(fixes);
182+
return createResponse({
183+
fixes,
184+
metadata: { total: fixes.length, success: succeeded, failed: fixes.length - succeeded },
185+
}, 207);
186+
}
187+
188+
async #patchFixStatus(uuid, status, index, suggestionId, opportunityId, siteId) {
189+
if (!hasText(uuid)) {
190+
return {
191+
index,
192+
uuid: '',
193+
message: 'fix id is required',
194+
statusCode: 400,
195+
};
196+
}
197+
if (!hasText(status)) {
198+
return {
199+
index,
200+
uuid,
201+
message: 'fix status is required',
202+
statusCode: 400,
203+
};
204+
}
205+
206+
const fix = await this.#FixEntity.findById(uuid);
207+
const res = checkOwnership(fix, opportunityId, siteId);
208+
if (res) return res;
209+
210+
try {
211+
if (fix.getStatus() === status) {
212+
return {
213+
index, uuid, message: 'No updates provided', statusCode: 400,
214+
};
215+
}
216+
217+
fix.setStatus(status);
218+
return {
219+
index, uuid, fix: FixDto.toJSON(await fix.save()), statusCode: 200,
220+
};
221+
} catch (error) {
222+
const statusCode = error instanceof ValidationError ? 400 : 500;
223+
return { index, message: error.message, statusCode };
224+
}
225+
}
226+
227+
/**
228+
* Updates data for a suggestion
229+
*
230+
* @param {Context} context - request context
231+
* @returns {Promise<Response>} the updated suggestion data
232+
*/
233+
async patchFix(context) {
234+
const {
235+
sideId, opportunityId, suggestion, fixId,
236+
} = context.params ?? {};
237+
let res = checkRequestParams(sideId, opportunityId, suggestion);
238+
if (res) return res;
239+
240+
if (!isValidUUID(fixId)) {
241+
return badRequest('Fix ID is required');
242+
}
243+
const fix = await this.#FixEntity.findById(suggestion, fixId);
244+
res = checkOwnership(fix, suggestion, opportunityId, sideId);
245+
if (res) return res;
246+
247+
if (!context.data) {
248+
return badRequest('No updates provided');
249+
}
250+
251+
const {
252+
executedBy, executedAt, publishedAt, changeDetails,
253+
} = context.data;
254+
255+
let hasUpdates = false;
256+
try {
257+
if (executedBy !== fix.getExecutedBy() && hasText(executedBy)) {
258+
fix.setExecutedBy(executedBy);
259+
hasUpdates = true;
260+
}
261+
262+
if (executedAt !== fix.getExecutedAt() && isIsoDate(executedAt)) {
263+
fix.setExecutedAt(executedAt);
264+
hasUpdates = true;
265+
}
266+
267+
if (publishedAt !== fix.getPublishedAt() && isIsoDate(publishedAt)) {
268+
fix.setPublishedAt(publishedAt);
269+
hasUpdates = true;
270+
}
271+
272+
if (isNonEmptyObject(changeDetails)) {
273+
fix.setChangeDetails(changeDetails);
274+
hasUpdates = true;
275+
}
276+
277+
if (hasUpdates) {
278+
return ok(FixDto.toJSON(await fix.save()));
279+
} else {
280+
return badRequest('No updates provided');
281+
}
282+
} catch (e) {
283+
return e instanceof ValidationError
284+
? badRequest(e.message)
285+
: createResponse({ message: 'Error updating suggestion' }, 500);
286+
}
287+
}
288+
289+
/**
290+
* Removes a fix
291+
* @param {Context} context - request context
292+
* @returns {Promise<Response>}
293+
*/
294+
async removeFix(context) {
295+
const {
296+
siteId, opportunityId, suggestionId, fixId,
297+
} = context.params ?? {};
298+
299+
let res = !isValidUUID(fixId)
300+
? badRequest('Fix ID is required')
301+
: checkRequestParams(siteId, opportunityId, suggestionId);
302+
if (res) return res;
303+
304+
const fix = await this.#FixEntity.findById(fixId);
305+
res = fix ? checkOwnership(fix, suggestionId, opportunityId, siteId) : notFound('Fix not found');
306+
if (res) return res;
307+
308+
try {
309+
await fix.remove();
310+
return noContent();
311+
} catch (e) {
312+
return createResponse({ message: `Error removing fix: ${e.message}` }, 500);
313+
}
314+
}
315+
}
316+
317+
/**
318+
* Checks whether sideId, opportunityId and suggestionId are valid UUIDs.
319+
* @param {any} siteId
320+
* @param {any} opportunityId
321+
* @param {any} suggestionId
322+
* @returns {Response | null} badRequest response or null
323+
*/
324+
function checkRequestParams(siteId, opportunityId, suggestionId) {
325+
if (!isValidUUID(siteId)) {
326+
return badRequest('Site ID required');
327+
}
328+
329+
if (!isValidUUID(opportunityId)) {
330+
return badRequest('Opportunity ID required');
331+
}
332+
333+
if (!isValidUUID(suggestionId)) {
334+
return badRequest('Suggestion ID required');
335+
}
336+
return null;
337+
}
338+
339+
/**
340+
* Checks if the fix belongs to the opportunity and the opportunity belongs to the site.
341+
*
342+
* @param {FixEntity} fix
343+
* @param {string} suggestionId
344+
* @param {string} opportunityId
345+
* @param {string} siteId
346+
* @returns {Promise<null | Response>}
347+
*/
348+
async function checkOwnership(fix, suggestionId, opportunityId, siteId) {
349+
const suggestion = await fix.getSuggestion();
350+
if (
351+
!suggestion
352+
|| suggestion.getId() !== suggestionId
353+
|| suggestion.getOpportunityId() !== opportunityId
354+
) {
355+
return notFound('Suggestion not found');
356+
}
357+
358+
const opportunity = await suggestion.getOpportunity();
359+
if (!opportunity || opportunity.getSiteId() !== siteId) {
360+
return notFound('Opportunity not found');
361+
}
362+
return null;
363+
}
364+
365+
/**
366+
* @param {Array<{statusCode: number}>} items
367+
* @returns {number} number of succeeded items
368+
*/
369+
function countSucceeded(items) {
370+
return items.reduce((succ, item) => succ + (item.statusCode < 400), 0);
371+
}

0 commit comments

Comments
 (0)