feat: Update NPS Value Survey (#9638)
Co-authored-by: कारतोफ्फेलस्क्रिप्ट™ <aditya@netroy.in> Co-authored-by: Tomi Turtiainen <10324676+tomi@users.noreply.github.com>
This commit is contained in:
@@ -71,6 +71,7 @@ import { InvitationController } from './controllers/invitation.controller';
|
||||
import { OrchestrationService } from '@/services/orchestration.service';
|
||||
import { ProjectController } from './controllers/project.controller';
|
||||
import { RoleController } from './controllers/role.controller';
|
||||
import { UserSettingsController } from './controllers/userSettings.controller';
|
||||
|
||||
const exec = promisify(callbackExec);
|
||||
|
||||
@@ -148,6 +149,7 @@ export class Server extends AbstractServer {
|
||||
ProjectController,
|
||||
RoleController,
|
||||
CurlController,
|
||||
UserSettingsController,
|
||||
];
|
||||
|
||||
if (
|
||||
|
||||
52
packages/cli/src/controllers/userSettings.controller.ts
Normal file
52
packages/cli/src/controllers/userSettings.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Patch, RestController } from '@/decorators';
|
||||
import { BadRequestError } from '@/errors/response-errors/bad-request.error';
|
||||
import { NpsSurveyRequest } from '@/requests';
|
||||
import { UserService } from '@/services/user.service';
|
||||
import type { NpsSurveyState } from 'n8n-workflow';
|
||||
|
||||
function getNpsSurveyState(state: unknown): NpsSurveyState | undefined {
|
||||
if (typeof state !== 'object' || state === null) {
|
||||
return;
|
||||
}
|
||||
if (!('lastShownAt' in state) || typeof state.lastShownAt !== 'number') {
|
||||
return;
|
||||
}
|
||||
if ('responded' in state && state.responded === true) {
|
||||
return {
|
||||
responded: true,
|
||||
lastShownAt: state.lastShownAt,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
'waitingForResponse' in state &&
|
||||
state.waitingForResponse === true &&
|
||||
'ignoredCount' in state &&
|
||||
typeof state.ignoredCount === 'number'
|
||||
) {
|
||||
return {
|
||||
waitingForResponse: true,
|
||||
ignoredCount: state.ignoredCount,
|
||||
lastShownAt: state.lastShownAt,
|
||||
};
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@RestController('/user-settings')
|
||||
export class UserSettingsController {
|
||||
constructor(private readonly userService: UserService) {}
|
||||
|
||||
@Patch('/nps-survey')
|
||||
async updateNpsSurvey(req: NpsSurveyRequest.NpsSurveyUpdate): Promise<void> {
|
||||
const state = getNpsSurveyState(req.body);
|
||||
if (!state) {
|
||||
throw new BadRequestError('Invalid nps survey state structure');
|
||||
}
|
||||
|
||||
await this.userService.updateSettings(req.user.id, {
|
||||
npsSurvey: state,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '@db/types';
|
||||
|
||||
export class AddActivatedAtUserSetting1717498465931 implements ReversibleMigration {
|
||||
async up({ queryRunner, escape }: MigrationContext) {
|
||||
const now = Date.now();
|
||||
await queryRunner.query(
|
||||
`UPDATE ${escape.tableName('user')}
|
||||
SET settings = JSON_SET(COALESCE(settings, '{}'), '$.userActivatedAt', CAST('${now}' AS JSON))
|
||||
WHERE settings IS NOT NULL AND JSON_EXTRACT(settings, '$.userActivated') = true`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ queryRunner, escape }: MigrationContext) {
|
||||
await queryRunner.query(
|
||||
`UPDATE ${escape.tableName('user')}
|
||||
SET settings = JSON_REMOVE(CAST(settings AS JSON), '$.userActivatedAt')
|
||||
WHERE settings IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,7 @@ import { RemoveFailedExecutionStatus1711018413374 } from '../common/171101841337
|
||||
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
|
||||
import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess';
|
||||
import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable';
|
||||
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
|
||||
|
||||
export const mysqlMigrations: Migration[] = [
|
||||
InitialMigration1588157391238,
|
||||
@@ -117,4 +118,5 @@ export const mysqlMigrations: Migration[] = [
|
||||
RemoveNodesAccess1712044305787,
|
||||
CreateProject1714133768519,
|
||||
MakeExecutionStatusNonNullable1714133768521,
|
||||
AddActivatedAtUserSetting1717498465931,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '@db/types';
|
||||
|
||||
export class AddActivatedAtUserSetting1717498465931 implements ReversibleMigration {
|
||||
async up({ queryRunner, escape }: MigrationContext) {
|
||||
const now = Date.now();
|
||||
await queryRunner.query(
|
||||
`UPDATE ${escape.tableName('user')}
|
||||
SET settings = jsonb_set(COALESCE(settings::jsonb, '{}'), '{userActivatedAt}', to_jsonb(${now}))
|
||||
WHERE settings IS NOT NULL AND (settings->>'userActivated')::boolean = true`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ queryRunner, escape }: MigrationContext) {
|
||||
await queryRunner.query(
|
||||
`UPDATE ${escape.tableName('user')} SET settings = settings::jsonb - 'userActivatedAt' WHERE settings IS NOT NULL`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ import { RemoveFailedExecutionStatus1711018413374 } from '../common/171101841337
|
||||
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
|
||||
import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess';
|
||||
import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable';
|
||||
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
|
||||
|
||||
export const postgresMigrations: Migration[] = [
|
||||
InitialMigration1587669153312,
|
||||
@@ -115,4 +116,5 @@ export const postgresMigrations: Migration[] = [
|
||||
RemoveNodesAccess1712044305787,
|
||||
CreateProject1714133768519,
|
||||
MakeExecutionStatusNonNullable1714133768521,
|
||||
AddActivatedAtUserSetting1717498465931,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
|
||||
|
||||
export class AddActivatedAtUserSetting1717498465931 implements ReversibleMigration {
|
||||
transaction = false as const;
|
||||
|
||||
async up({ queryRunner, escape }: MigrationContext) {
|
||||
const now = Date.now();
|
||||
await queryRunner.query(
|
||||
`UPDATE ${escape.tableName('user')}
|
||||
SET settings = JSON_SET(settings, '$.userActivatedAt', ${now})
|
||||
WHERE JSON_EXTRACT(settings, '$.userActivated') = true;`,
|
||||
);
|
||||
}
|
||||
|
||||
async down({ queryRunner, escape }: MigrationContext) {
|
||||
await queryRunner.query(
|
||||
`UPDATE ${escape.tableName('user')} SET settings = JSON_REMOVE(settings, '$.userActivatedAt')`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,7 @@ import { RemoveFailedExecutionStatus1711018413374 } from '../common/171101841337
|
||||
import { MoveSshKeysToDatabase1711390882123 } from '../common/1711390882123-MoveSshKeysToDatabase';
|
||||
import { RemoveNodesAccess1712044305787 } from '../common/1712044305787-RemoveNodesAccess';
|
||||
import { MakeExecutionStatusNonNullable1714133768521 } from '../common/1714133768521-MakeExecutionStatusNonNullable';
|
||||
import { AddActivatedAtUserSetting1717498465931 } from './1717498465931-AddActivatedAtUserSetting';
|
||||
|
||||
const sqliteMigrations: Migration[] = [
|
||||
InitialMigration1588102412422,
|
||||
@@ -111,6 +112,7 @@ const sqliteMigrations: Migration[] = [
|
||||
RemoveNodesAccess1712044305787,
|
||||
CreateProject1714133768519,
|
||||
MakeExecutionStatusNonNullable1714133768521,
|
||||
AddActivatedAtUserSetting1717498465931,
|
||||
];
|
||||
|
||||
export { sqliteMigrations };
|
||||
|
||||
@@ -601,3 +601,13 @@ export declare namespace ProjectRequest {
|
||||
>;
|
||||
type Delete = AuthenticatedRequest<{ projectId: string }, {}, {}, { transferId?: string }>;
|
||||
}
|
||||
|
||||
// ----------------------------------
|
||||
// /nps-survey
|
||||
// ----------------------------------
|
||||
export declare namespace NpsSurveyRequest {
|
||||
// can be refactored to
|
||||
// type NpsSurveyUpdate = AuthenticatedRequest<{}, {}, NpsSurveyState>;
|
||||
// once some schema validation is added
|
||||
type NpsSurveyUpdate = AuthenticatedRequest<{}, {}, unknown>;
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ export class EventsService extends EventEmitter {
|
||||
await Container.get(UserService).updateSettings(owner.id, {
|
||||
firstSuccessfulWorkflowId: workflowId,
|
||||
userActivated: true,
|
||||
userActivatedAt: runData.startedAt.getTime(),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { UserSettingsController } from '@/controllers/userSettings.controller';
|
||||
import type { NpsSurveyRequest } from '@/requests';
|
||||
import type { UserService } from '@/services/user.service';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { NpsSurveyState } from 'n8n-workflow';
|
||||
|
||||
const NOW = 1717607016208;
|
||||
jest.useFakeTimers({
|
||||
now: NOW,
|
||||
});
|
||||
|
||||
describe('UserSettingsController', () => {
|
||||
const userService = mock<UserService>();
|
||||
const controller = new UserSettingsController(userService);
|
||||
|
||||
describe('NPS Survey', () => {
|
||||
test.each([
|
||||
[
|
||||
'updates user settings, setting response state to done',
|
||||
{
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
},
|
||||
[],
|
||||
],
|
||||
[
|
||||
'updates user settings, setting response state to done, ignoring other keys like waitForResponse',
|
||||
{
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
waitingForResponse: true,
|
||||
},
|
||||
['waitingForResponse'],
|
||||
],
|
||||
[
|
||||
'updates user settings, setting response state to done, ignoring other keys like ignoredCount',
|
||||
{
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
ignoredCount: 1,
|
||||
},
|
||||
['ignoredCount'],
|
||||
],
|
||||
[
|
||||
'updates user settings, setting response state to done, ignoring other unknown keys',
|
||||
{
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
x: 1,
|
||||
},
|
||||
['x'],
|
||||
],
|
||||
[
|
||||
'updates user settings, updating ignore count',
|
||||
{
|
||||
waitingForResponse: true,
|
||||
lastShownAt: 1717607016208,
|
||||
ignoredCount: 1,
|
||||
},
|
||||
[],
|
||||
],
|
||||
[
|
||||
'updates user settings, reseting to waiting state',
|
||||
{
|
||||
waitingForResponse: true,
|
||||
ignoredCount: 0,
|
||||
lastShownAt: 1717607016208,
|
||||
},
|
||||
[],
|
||||
],
|
||||
[
|
||||
'updates user settings, updating ignore count, ignoring unknown keys',
|
||||
{
|
||||
waitingForResponse: true,
|
||||
lastShownAt: 1717607016208,
|
||||
ignoredCount: 1,
|
||||
x: 1,
|
||||
},
|
||||
['x'],
|
||||
],
|
||||
])('%s', async (_, toUpdate, toIgnore: string[] | undefined) => {
|
||||
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
|
||||
req.user.id = '1';
|
||||
req.body = toUpdate;
|
||||
await controller.updateNpsSurvey(req);
|
||||
|
||||
const npsSurvey = Object.keys(toUpdate).reduce(
|
||||
(accu, key) => {
|
||||
if ((toIgnore ?? []).includes(key)) {
|
||||
return accu;
|
||||
}
|
||||
accu[key] = (toUpdate as Record<string, unknown>)[key];
|
||||
return accu;
|
||||
},
|
||||
{} as Record<string, unknown>,
|
||||
);
|
||||
expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey });
|
||||
});
|
||||
|
||||
it('updates user settings, setting response state to done', async () => {
|
||||
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
|
||||
req.user.id = '1';
|
||||
|
||||
const npsSurvey: NpsSurveyState = {
|
||||
responded: true,
|
||||
lastShownAt: 1717607016208,
|
||||
};
|
||||
req.body = npsSurvey;
|
||||
|
||||
await controller.updateNpsSurvey(req);
|
||||
|
||||
expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey });
|
||||
});
|
||||
|
||||
it('updates user settings, updating ignore count', async () => {
|
||||
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
|
||||
req.user.id = '1';
|
||||
|
||||
const npsSurvey: NpsSurveyState = {
|
||||
waitingForResponse: true,
|
||||
lastShownAt: 1717607016208,
|
||||
ignoredCount: 1,
|
||||
};
|
||||
req.body = npsSurvey;
|
||||
|
||||
await controller.updateNpsSurvey(req);
|
||||
|
||||
expect(userService.updateSettings).toHaveBeenCalledWith('1', { npsSurvey });
|
||||
});
|
||||
|
||||
test.each([
|
||||
['is missing', {}],
|
||||
['is undefined', undefined],
|
||||
['is responded but missing lastShownAt', { responded: true }],
|
||||
['is waitingForResponse but missing lastShownAt', { waitingForResponse: true }],
|
||||
[
|
||||
'is waitingForResponse but missing ignoredCount',
|
||||
{ lastShownAt: 123, waitingForResponse: true },
|
||||
],
|
||||
])('thows error when request payload is %s', async (_, payload) => {
|
||||
const req = mock<NpsSurveyRequest.NpsSurveyUpdate>();
|
||||
req.user.id = '1';
|
||||
req.body = payload;
|
||||
|
||||
await expect(controller.updateNpsSurvey(req)).rejects.toThrowError();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user