Skip to content

Commit

Permalink
Merge pull request #95 from hudl/export-import-settings
Browse files Browse the repository at this point in the history
Export import settings
  • Loading branch information
james-vaughn authored Oct 24, 2024
2 parents 5856be4 + 709d1fa commit 7936b0f
Show file tree
Hide file tree
Showing 9 changed files with 364 additions and 12 deletions.
76 changes: 72 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"@microsoft/microsoft-graph-client": "3.0.7",
"@slack/web-api": "7.6.0",
"isomorphic-fetch": "3.0.0",
"simple-oauth2": "5.1.0"
"simple-oauth2": "5.1.0",
"uuid": "^10.0.0"
}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ const sendUpcomingEventMessage = async (

export const update: Handler = async () => {
const batchSize = 10;

const lambda = new LambdaClient({
apiVersion: 'latest',
region: config.region,
Expand Down
58 changes: 56 additions & 2 deletions src/services/dynamo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,22 @@ import {
BatchGetCommand,
ScanCommand,
UpdateCommand,
UpdateCommandInput,
UpdateCommandInput,
} from '@aws-sdk/lib-dynamodb';
import config from '../../config';
import { CalendarEvent } from './calendar';
import { v4 as uuidv4 } from 'uuid';

type StatusMapping = {
export type StatusMapping = {
calendarText: string;
slackStatus: SlackStatus;
};

export type ExportedSettings = {
settingsId: string;
statusMappings: StatusMapping[];
};

export type UserSettings = {
email: string;
slackToken?: string;
Expand All @@ -27,6 +33,7 @@ export type UserSettings = {
meetingReminderTimingOverride?: number;
lastReminderEventId?: string;
snoozed?: boolean;
exportedSettings?: ExportedSettings[]
};

const toDynamoStatus = (status: SlackStatus) => ({
Expand Down Expand Up @@ -273,3 +280,50 @@ export const setSnoozed = async (email: string, snoozed: boolean): Promise<UserS
throw err;
}
};

export const getExportedSettingsBySettingsId = async (settingsId: string): Promise<ExportedSettings> => {
const client = getClient();
const command = new ScanCommand({
TableName: config.dynamoDb.tableName,
ProjectionExpression: 'exportedSettings',
});

try {
const response = await client.send(command);
if (!response?.Items) {
return {} as ExportedSettings;
}

const userSettings = response.Items.map((item) => item as UserSettings);
const exportedSettings = userSettings.flatMap((item) => item.exportedSettings?.map(es => es as ExportedSettings));
return exportedSettings
.filter((item) => item && item?.settingsId === settingsId)[0]
?? {} as ExportedSettings;

} catch (err) {
console.error(err, 'Error getting exported settings for id', settingsId);
throw err;
}
};

export const exportSettings = async (email: string, statusMappings: StatusMapping[]): Promise<string> => {
const settingsId = uuidv4();

try {
await updateUserSettings(email, {
UpdateExpression: 'set exportedSettings = list_append(if_not_exists(exportedSettings, :default), :s)',
ExpressionAttributeValues: {
':default': [],
':s': [{
settingsId: settingsId,
statusMappings: statusMappings,
}]
},
});

return settingsId;
} catch (err) {
console.error(err, 'Error storing current event for email: ', email);
throw err;
}
};
4 changes: 3 additions & 1 deletion src/slackbot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from './services/dynamo';
import { slackInstallUrl } from './utils/urls';
import { handleSettings } from './slackbot/settings';
import {handleMappings} from "./slackbot/mappings";

const MILLIS_IN_SEC = 1000;
const FIVE_MIN_IN_SEC = 300;
Expand Down Expand Up @@ -76,7 +77,7 @@ const validateSlackRequest = async (event: ApiGatewayEvent): Promise<boolean> =>
return crypto.timingSafeEqual(Buffer.from(calculatedSignature, 'utf8'), Buffer.from(slackHash, 'utf8'));
};

const serializeStatusMappings = ({ defaultStatus, statusMappings }: UserSettings): string => {
export const serializeStatusMappings = ({ defaultStatus, statusMappings }: UserSettings): string => {
let defaultStatusString = '_Not set_';
if (defaultStatus) {
const statusText = defaultStatus.text ? ` \`${defaultStatus.text}\`` : '';
Expand Down Expand Up @@ -224,6 +225,7 @@ const commandHandlerMap: {
'set-default': handleSetDefault,
'remove-default': handleRemoveDefault,
settings: handleSettings,
mappings: handleMappings,
};

const handleSlackEventCallback = async ({
Expand Down
133 changes: 133 additions & 0 deletions src/slackbot/__tests__/mappings.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import {
upsertStatusMappings,
getExportedSettingsBySettingsId,
getSettingsForUsers,
exportSettings,
UserSettings, ExportedSettings
} from '../../services/dynamo';
import {handleMappings} from "../mappings";
import {serializeStatusMappings} from "../../slackbot";

jest.mock('../../services/dynamo');
jest.mock('../../slackbot');

const exportSettingsMock = <jest.Mock>exportSettings;
const getSettingsForUsersMock = <jest.Mock>getSettingsForUsers;
const upsertStatusMappingsMock = <jest.Mock>upsertStatusMappings;
const getExportedSettingsBySettingsIdMock = <jest.Mock>getExportedSettingsBySettingsId;

const serializeStatusMappingsMock = <jest.Mock>serializeStatusMappings;

const userSettings = {
email: 'blah@blah.com',
statusMappings: [
{
calendarText: 'busy',
slackStatus: {
text: 'busy',
emoji: ':calendar:',
},
},
],
};

describe('handleMappings', () => {
describe('With no arguments provided', () => {
test('Returns a message requesting at least one argument', async () => {
const message = await handleMappings(userSettings, []);

expect(message).toBe(
'You must provide at least one argument. See the wiki for more information: https://github.com/hudl/CalendarToSlack/wiki',
);
});
});
describe('With unsupported arguments', () => {
test('Returns a message requesting a supported argument', async () => {
const message = await handleMappings(userSettings, ['my-command = hello']);

expect(message).toBe(
'No supported arguments given. See the wiki for more information: https://github.com/hudl/CalendarToSlack/wiki',
);
});
test('Does not update any settings in DynamoDB', async () => {
await handleMappings(userSettings, ['my-command = hello']);

expect(exportSettingsMock).not.toBeCalled();
expect(upsertStatusMappingsMock).not.toBeCalled();
});
});

describe('With export argument', () => {
beforeEach(() => {
exportSettingsMock.mockResolvedValueOnce("123");
});
test('Returns exported message', async () => {
const message = await handleMappings(userSettings, ['export']);

expect(message).toBe(
'Your status mappings have been exported with the ID: 123'
);
});
test('Exports settings in DynamoDB', async () => {
await handleMappings(userSettings, ['export']);

expect(exportSettingsMock).toBeCalledWith(userSettings.email, userSettings.statusMappings);
});
});

describe('With list argument', () => {
test('With exported settings Returns list of exported settings', async () => {
const statusMappings = [
{
exportedSettings: [
{settingsId: '123'},
{settingsId: '456'},
]
}
];
getSettingsForUsersMock.mockResolvedValueOnce(statusMappings);

const message = await handleMappings(userSettings, ['list']);

expect(message).toBe(
'Status mapping IDs for user: 123\n456'
);
});

test('With no exported settings Returns no exported settings message', async () => {
const statusMappings: UserSettings[] = [];
getSettingsForUsersMock.mockResolvedValueOnce(statusMappings);

const message = await handleMappings(userSettings, ['list']);

expect(message).toBe(
'No exported status mappings found for user'
);
});
});

describe('With import argument', () => {
beforeEach(() => {
upsertStatusMappingsMock.mockResolvedValueOnce(userSettings);
serializeStatusMappingsMock.mockReturnValue('serializedStatusMappings');
});

test('With imported settings Id matching a valid settingsId, imports settings', async () => {
const exportedSettings: ExportedSettings = {settingsId: '123', statusMappings: []};
getExportedSettingsBySettingsIdMock.mockResolvedValueOnce(exportedSettings);

const message = await handleMappings(userSettings, ['import=123']);

expect(message).toBe(`Your status mappings have been updated:\nserializedStatusMappings`);
});

test('With invalid settingsId, reports error', async () => {
const exportedSettings: ExportedSettings = {} as ExportedSettings;
getExportedSettingsBySettingsIdMock.mockResolvedValueOnce(exportedSettings);

const message = await handleMappings(userSettings, ['import=123']);

expect(message).toBe(`No status mappings found for 123`);
});
});
});
Loading

0 comments on commit 7936b0f

Please sign in to comment.