Skip to content

Commit

Permalink
feat: auto-reveal votes on a session ends (#37, #43)
Browse files Browse the repository at this point in the history
  • Loading branch information
dgurkaynak committed Aug 29, 2022
1 parent 87cf346 commit 901dcd3
Show file tree
Hide file tree
Showing 11 changed files with 175 additions and 46 deletions.
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ ISSUES_LINK=https://github.com/dgurkaynak/slack-poker-planner/issues
DATA_FOLDER=./

# How long do sessions live after creation? (in milliseconds)
SESSION_TTL=604800000
MAX_VOTING_DURATION=604800000

# Redis
# If USE_REDIS is falsy, sessions will be kept in-memory,
Expand Down
11 changes: 11 additions & 0 deletions package-lock.json

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

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
"express-handlebars": "^6.0.6",
"lodash": "^4.17.19",
"node-fetch": "^2.6.1",
"parse-duration": "^1.0.2",
"pino": "^8.4.2",
"pretty-ms": "^7.0.1",
"quoted-string-space-split": "^1.1.1",
Expand Down
6 changes: 0 additions & 6 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import * as exphbs from 'express-handlebars';
import { OAuthRoute } from './routes/oauth';
import { PPCommandRoute } from './routes/pp-command';
import { InteractivityRoute } from './routes/interactivity';
import prettyMilliseconds from 'pretty-ms';
import * as SessionStore from './session/session-model';

async function main() {
Expand Down Expand Up @@ -65,10 +64,6 @@ async function initServer(): Promise<void> {

function initRoutes(server: express.Express) {
const router = express.Router();
const humanReadableSessionTTL = prettyMilliseconds(
Number(process.env.SESSION_TTL),
{ verbose: true }
);

router.get('/', (req, res, next) => {
res.render('index', {
Expand All @@ -79,7 +74,6 @@ function initRoutes(server: express.Express) {
SLACK_APP_ID: process.env.SLACK_APP_ID,
COUNTLY_URL: process.env.COUNTLY_URL,
COUNTLY_APP_KEY: process.env.COUNTLY_APP_KEY,
HUMAN_READABLE_SESSION_TTL: humanReadableSessionTTL,
},
});
});
Expand Down
54 changes: 53 additions & 1 deletion src/routes/interactivity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import find from 'lodash/find';
import get from 'lodash/get';
import isObject from 'lodash/isObject';
import splitSpacesExcludeQuotes from 'quoted-string-space-split';
import parseDuration from 'parse-duration';
import prettyMilliseconds from 'pretty-ms';

export class InteractivityRoute {
/**
Expand Down Expand Up @@ -417,6 +419,44 @@ export class InteractivityRoute {
throw new Error(SessionControllerErrorCode.INVALID_POINTS);
}

/////////////////////////////
// Get the voting duration //
/////////////////////////////
const votingDurationInputState = get(
payload,
'view.state.values.votingDuration'
);
if (
!isObject(votingDurationInputState) ||
isEmpty(votingDurationInputState)
) {
logger.error({
msg:
'Could not create session: Voting duration is not an object or empty',
errorId,
payload,
});
throw new Error(SessionControllerErrorCode.INVALID_VOTING_DURATION);
}
const votingDurationStr = (votingDurationInputState as any)[
Object.keys(votingDurationInputState)[0]
].value;

if (!votingDurationStr || votingDurationStr.trim().length == 0) {
throw new Error(SessionControllerErrorCode.INVALID_VOTING_DURATION);
}

const votingDurationMs = parseDuration(votingDurationStr);
if (typeof votingDurationMs !== 'number') {
throw new Error(SessionControllerErrorCode.INVALID_VOTING_DURATION);
}
if (
votingDurationMs < 60000 ||
votingDurationMs > Number(process.env.MAX_VOTING_DURATION)
) {
throw new Error(SessionControllerErrorCode.INVALID_VOTING_DURATION);
}

////////////////////////////
// Get "other" checkboxes //
////////////////////////////
Expand All @@ -438,11 +478,12 @@ export class InteractivityRoute {
// Create session struct
const session: ISession = {
id: generateId(),
expiresAt: Date.now() + Number(process.env.SESSION_TTL),
endsAt: Date.now() + votingDurationMs,
title,
points,
votes: {},
state: 'active',
teamId: team.id,
channelId,
userId: payload.user.id,
participants,
Expand Down Expand Up @@ -481,6 +522,7 @@ export class InteractivityRoute {
[ChannelSettingKey.POINTS]: session.points.join(' '),
[ChannelSettingKey.PROTECTED]: JSON.stringify(session.protected),
[ChannelSettingKey.AVERAGE]: JSON.stringify(session.average),
[ChannelSettingKey.VOTING_DURATION]: prettyMilliseconds(votingDurationMs),
})
);
if (upsertSettingErr) {
Expand Down Expand Up @@ -586,6 +628,16 @@ export class InteractivityRoute {
`Oops, Slack API sends a payload that we don't expect. Please try again.\n\n` +
`If this problem is persistent, you can open an issue on <${process.env.ISSUES_LINK}> ` +
`with following error code: ${errorId}`;
} else if (
err.message == SessionControllerErrorCode.INVALID_VOTING_DURATION
) {
shouldLog = false;
errorMessage = `Voting window must be between 1m and ${prettyMilliseconds(
Number(process.env.MAX_VOTING_DURATION)
)}`;
modalErrors = {
votingDuration: errorMessage,
};
}

if (shouldLog) {
Expand Down
7 changes: 7 additions & 0 deletions src/routes/pp-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
SessionController,
DEFAULT_POINTS,
} from '../session/session-controller';
import prettyMilliseconds from 'pretty-ms';

export class PPCommandRoute {
/**
Expand Down Expand Up @@ -175,6 +176,7 @@ export class PPCommandRoute {
[ChannelSettingKey.POINTS]: DEFAULT_POINTS,
[ChannelSettingKey.PROTECTED]: false,
[ChannelSettingKey.AVERAGE]: false,
[ChannelSettingKey.VOTING_DURATION]: '24h',
};
if (channelSettings?.[ChannelSettingKey.PARTICIPANTS]) {
settings[ChannelSettingKey.PARTICIPANTS] = channelSettings[
Expand All @@ -199,6 +201,10 @@ export class PPCommandRoute {
channelSettings[ChannelSettingKey.AVERAGE]
);
}
if (channelSettings?.[ChannelSettingKey.VOTING_DURATION]) {
settings[ChannelSettingKey.VOTING_DURATION] =
channelSettings[ChannelSettingKey.VOTING_DURATION];
}

await SessionController.openModal({
triggerId: cmd.trigger_id,
Expand All @@ -209,6 +215,7 @@ export class PPCommandRoute {
points: settings[ChannelSettingKey.POINTS],
isProtected: settings[ChannelSettingKey.PROTECTED],
calculateAverage: settings[ChannelSettingKey.AVERAGE],
votingDuration: settings[ChannelSettingKey.VOTING_DURATION],
});

// Send acknowledgement back to API -- HTTP 200
Expand Down
8 changes: 6 additions & 2 deletions src/session/isession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,17 @@ export interface ISession {
*/
id: string;
/**
* The timestamp of expiration.
* The timestamp of vote ending.
*/
expiresAt: number;
endsAt: number;
/**
* Title of the session. Mentions are excluded.
*/
title: string;
/**
* Slack Team ID.
*/
teamId: string;
/**
* Slack Channel ID.
*/
Expand Down
81 changes: 79 additions & 2 deletions src/session/session-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { ISession } from './isession';
import chunk from 'lodash/chunk';
import map from 'lodash/map';
import groupBy from 'lodash/groupBy';
import { ITeam } from '../team/team-model';
import { ITeam, TeamStore } from '../team/team-model';
import { WebClient } from '@slack/web-api';
import logger from '../lib/logger';

Expand All @@ -30,6 +30,7 @@ export enum SessionControllerErrorCode {
INVALID_POINTS = 'invalid_points',
SESSION_NOT_ACTIVE = 'session_not_active',
ONLY_PARTICIPANTS_CAN_VOTE = 'only_participants_can_vote',
INVALID_VOTING_DURATION = 'invalid_voting_duration',
}

export class SessionController {
Expand Down Expand Up @@ -63,6 +64,7 @@ export class SessionController {
points,
isProtected,
calculateAverage,
votingDuration,
}: {
triggerId: string;
team: ITeam;
Expand All @@ -72,6 +74,7 @@ export class SessionController {
points: string[];
isProtected: boolean;
calculateAverage: boolean;
votingDuration: string;
}) {
const slackWebClient = new WebClient(team.access_token);

Expand Down Expand Up @@ -185,6 +188,29 @@ export class SessionController {
emoji: true,
},
},
{
type: 'input',
block_id: 'votingDuration',
element: {
type: 'plain_text_input',
placeholder: {
type: 'plain_text',
text: 'Enter a duration like: 3d 6h 30m',
emoji: true,
},
initial_value: votingDuration || '',
},
hint: {
type: 'plain_text',
text: 'After voting ends, points will be reveal automatically',
emoji: true,
},
label: {
type: 'plain_text',
text: 'Voting ends in',
emoji: true,
},
},
{
type: 'input',
block_id: 'other',
Expand Down Expand Up @@ -268,7 +294,7 @@ export class SessionController {
await SessionController.updateMessage(session, team); // do not send userId
await SessionStore.remove(session.id);
logger.info({
msg: `Auto revealing votes`,
msg: `Auto revealing votes, everyone voted`,
sessionId: session.id,
team: {
id: team.id,
Expand Down Expand Up @@ -385,6 +411,57 @@ export class SessionController {
}
}

/**
* Set a interval that auto-reveals ended sessions
*/
let autoRevealEndedSessionsTimeoutId: number = setTimeout(
autoRevealEndedSessions,
60000
) as any;
async function autoRevealEndedSessions() {
const now = Date.now();
const sessions = SessionStore.getAllSessions();

const endedSessions = Object.values(sessions).filter((session) => {
const remainingTTL = session.endsAt - now;
return remainingTTL <= 0;
});

const tasks = endedSessions.map(async (session) => {
try {
// If `teamId` doesn't exists in the session, just remove the session like before.
if (typeof session.teamId !== 'string') {
await SessionStore.remove(session.id);
return;
}

logger.info({
msg: `Auto revealing votes, session ended`,
sessionId: session.id,
});

const team = await TeamStore.findById(session.teamId);

session.state = 'revealed';
await SessionController.updateMessage(session, team);
await SessionStore.remove(session.id);
} catch (err) {
logger.info({
msg: `Cannot auto-reveal an ended session`,
sessionId: session.id,
err,
});
}
});

await Promise.all(tasks);

autoRevealEndedSessionsTimeoutId = setTimeout(
autoRevealEndedSessions,
60000
) as any;
}

export function buildMessageAttachments(session: ISession) {
const pointAttachments = chunk(session.points, 5).map((points) => {
return {
Expand Down
Loading

0 comments on commit 901dcd3

Please sign in to comment.