diff --git a/package.json b/package.json index caad1f1..167bea9 100644 --- a/package.json +++ b/package.json @@ -11,21 +11,23 @@ "image-push": "docker push gcr.io/contenta-ai/slack-poll:latest" }, "dependencies": { + "@slack/bolt": "^3.8.1", + "dotenv": "^10.0.0", + "moment": "^2.29.1", + "moment-timezone": "^0.5.34", + "reflect-metadata": "^0.1.13", "sqlite3": "^5.0.2", "typeorm": "^0.2.41", - "reflect-metadata": "^0.1.13", - "yargs": "^17.2.1", - "@slack/bolt": "^3.8.1", "uuid": "^8.3.2", - "dotenv": "^10.0.0" + "yargs": "^17.2.1" }, "devDependencies": { - "typescript": "^4.5.2", "@types/node": "^16.11.10", "@types/sqlite3": "^3.1.7", - "ts-node": "^10.4.0", - "nodemon": "^2.0.15", + "@types/uuid": "^8.3.3", "@types/yargs": "^17.0.7", - "@types/uuid": "^8.3.3" + "nodemon": "^2.0.15", + "ts-node": "^10.4.0", + "typescript": "^4.5.2" } -} \ No newline at end of file +} diff --git a/src/bot/create.ts b/src/bot/create.ts index 7168f22..182ec86 100644 --- a/src/bot/create.ts +++ b/src/bot/create.ts @@ -1,4 +1,6 @@ import { SectionBlock } from "@slack/types" +import moment from "moment" +import { Moment } from "moment" import { v4 } from "uuid" import { OptionEntity } from "../entities/option" import { PollEntity } from "../entities/poll" @@ -10,10 +12,12 @@ export async function createPoll( channelId, optionTexts, userId, + deadlineUTC, }: { channelId: string, optionTexts: string[], - userId, + userId: string, + deadlineUTC: Moment, } ) { const app = getSlackApp() @@ -39,8 +43,13 @@ export async function createPoll( userId, options, voteRights, + deadline: deadlineUTC.toDate(), }) await poll.save() + + const expirationDateUTCStr = deadlineUTC.format("ddd, MMM DD YYYY, hh:mm A [GMT]") + const expirationTimeToNow = moment.utc().to(deadlineUTC) + const expirationDateUTCStrWithToNow = `${expirationDateUTCStr} (${expirationTimeToNow})` await app.client.chat.postMessage({ channel: channelId, @@ -50,7 +59,7 @@ export async function createPoll( type: "section", text: { type: "mrkdwn", - text: `<@${userId}> has started a new poll!` + text: `<@${userId}> has started a new poll!\nEnds on ${expirationDateUTCStrWithToNow}`, } }, ...options.map( diff --git a/src/bot/form.ts b/src/bot/form.ts index cb7e875..0d33289 100644 --- a/src/bot/form.ts +++ b/src/bot/form.ts @@ -1,5 +1,6 @@ import { getSlackApp } from "../slack" import { createPoll } from "./create" +import moment from "moment-timezone" const app = getSlackApp() @@ -15,15 +16,29 @@ app.view(/.*/, async ({ view, ack, body: { user } }) => { .filter(line => line.length > 0) const channel = view.state.values.channel.channel.selected_conversation + + const userInfo = await app.client.users.info({user: user.id}) + if(!userInfo.ok) { + return + } + + const deadlineDate = view.state.values.deadlineDate.deadlineDate.selected_date + const deadlineTime = view.state.values.deadlineTime.deadlineTime.selected_time + const deadlineUTC = moment.tz(`${deadlineDate} ${deadlineTime}`, userInfo?.user.tz).utc() if (optionTexts.length < 2) { return } + if(moment.utc() > deadlineUTC) { + return + } + await createPoll({ channelId: channel, optionTexts, userId: user.id, + deadlineUTC, }) await ack() diff --git a/src/bot/global-shortcut.ts b/src/bot/global-shortcut.ts index 7d48478..4c64f78 100644 --- a/src/bot/global-shortcut.ts +++ b/src/bot/global-shortcut.ts @@ -1,12 +1,22 @@ import { getSlackApp } from "../slack" +import moment from "moment-timezone" const app = getSlackApp() -app.shortcut("poll/create-poll-form", async ({ shortcut, ack }) => { +app.shortcut("poll/create-poll-form", async ({ shortcut, ack, body: { user } }) => { if (shortcut.type !== 'shortcut') { return } + const userInfo = await app.client.users.info({user: user.id}) + if(!userInfo.ok) { + return + } + + const momentTomorrowNextHourUTC = moment.tz(userInfo?.user.tz).add(1, "day").add(1, "hour").startOf('hour') + const initialDateStr = momentTomorrowNextHourUTC.format("YYYY-MM-DD") + const initialTimeStr = momentTomorrowNextHourUTC.format("HH:mm") + await app.client.views.open({ trigger_id: shortcut.trigger_id, view: { @@ -51,6 +61,42 @@ app.shortcut("poll/create-poll-form", async ({ shortcut, ack }) => { initial_value: "Option 1\nOption 2", }, }, + { + block_id: 'deadlineDate', + type: "input", + element: { + type: "datepicker", + initial_date: initialDateStr, + placeholder: { + type: "plain_text", + text: "Select a date", + emoji: true, + }, + action_id: "deadlineDate", + }, + label: { + type: "plain_text", + text: "Deadline date", + }, + }, + { + block_id: 'deadlineTime', + type: "input", + element: { + type: "timepicker", + initial_time: initialTimeStr, + placeholder: { + type: "plain_text", + text: "Select time", + emoji: true, + }, + action_id: "deadlineTime", + }, + label: { + type: "plain_text", + text: "Deadline time", + }, + } ], }, }) diff --git a/src/bot/message-shortcut.ts b/src/bot/message-shortcut.ts index f1a291d..d53fa34 100644 --- a/src/bot/message-shortcut.ts +++ b/src/bot/message-shortcut.ts @@ -1,5 +1,6 @@ import { getSlackApp } from "../slack" import { createPoll } from "./create" +import moment from "moment" const app = getSlackApp() @@ -7,7 +8,7 @@ app.shortcut("poll/create-poll", async ({ shortcut, ack }) => { if (shortcut.type !== 'message_action') { return } - + const message = shortcut.message if (message.type !== 'message') { return @@ -44,6 +45,7 @@ app.shortcut("poll/create-poll", async ({ shortcut, ack }) => { channelId: shortcut.channel.id, optionTexts, userId: shortcut.user.id, + deadlineUTC: moment.utc(), }) await ack() diff --git a/src/bot/send.ts b/src/bot/send.ts index bb9b31b..555d654 100644 --- a/src/bot/send.ts +++ b/src/bot/send.ts @@ -9,7 +9,7 @@ app.action(/^poll\/.*\/send$/, async ({ action, ack, body }) => { if (action.type !== 'button' || !('message' in body)) { return } - + const optionId = action.value const userId = body.user.id @@ -20,6 +20,31 @@ app.action(/^poll\/.*\/send$/, async ({ action, ack, body }) => { const poll = option.poll + if(new Date() > poll.deadline) { + body.message.blocks = [ + body.message.blocks[0], + { + "type": "context", + "elements": [ + { + "type": "plain_text", + "text": "The poll has expired.", + "emoji": true, + }, + ], + }, + body.message.blocks[body.message.blocks.length - 1], + ] + + await app.client.chat.update({ + channel: body.channel.id, + ...body.message, + }) + + await ack() + return + } + const voteRight = await VoteRightEntity.findOne({ where: { poll, diff --git a/src/entities/poll.ts b/src/entities/poll.ts index 7740cf9..9b0ccc7 100644 --- a/src/entities/poll.ts +++ b/src/entities/poll.ts @@ -13,6 +13,12 @@ export class PollEntity extends BaseEntity { }) userId: string + @Column('datetime', { + name: 'deadline', + nullable: false, + }) + deadline: Date + @OneToMany(() => OptionEntity, option => option.poll, { cascade: true }) options: OptionEntity[] diff --git a/src/migrations/1639772969440-AddDeadlineColumnInPollTable.ts b/src/migrations/1639772969440-AddDeadlineColumnInPollTable.ts new file mode 100644 index 0000000..ccb0ebf --- /dev/null +++ b/src/migrations/1639772969440-AddDeadlineColumnInPollTable.ts @@ -0,0 +1,52 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class AddDeadlineColumnInPollTable1639772969440 implements MigrationInterface { + name = 'AddDeadlineColumnInPollTable1639772969440' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "temporary_vote" ("option_id" varchar NOT NULL, "vote_right_id" varchar PRIMARY KEY NOT NULL, CONSTRAINT "FK_ce69ed4c96964be74d3d57e89cb" FOREIGN KEY ("vote_right_id") REFERENCES "vote_right" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT, CONSTRAINT "FK_d17980c91005358383b7ad59ab0" FOREIGN KEY ("option_id") REFERENCES "option" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT)`); + await queryRunner.query(`INSERT INTO "temporary_vote"("option_id", "vote_right_id") SELECT "option_id", "vote_right_id" FROM "vote"`); + await queryRunner.query(`DROP TABLE "vote"`); + await queryRunner.query(`ALTER TABLE "temporary_vote" RENAME TO "vote"`); + await queryRunner.query(`CREATE TABLE "temporary_poll" ("id" varchar PRIMARY KEY NOT NULL, "user_id" varchar NOT NULL, "deadline" datetime NOT NULL)`); + await queryRunner.query(`INSERT INTO "temporary_poll"("id", "user_id") SELECT "id", "user_id" FROM "poll"`); + await queryRunner.query(`DROP TABLE "poll"`); + await queryRunner.query(`ALTER TABLE "temporary_poll" RENAME TO "poll"`); + await queryRunner.query(`CREATE TABLE "temporary_vote" ("option_id" varchar NOT NULL, "vote_right_id" varchar PRIMARY KEY NOT NULL)`); + await queryRunner.query(`INSERT INTO "temporary_vote"("option_id", "vote_right_id") SELECT "option_id", "vote_right_id" FROM "vote"`); + await queryRunner.query(`DROP TABLE "vote"`); + await queryRunner.query(`ALTER TABLE "temporary_vote" RENAME TO "vote"`); + await queryRunner.query(`CREATE TABLE "temporary_vote" ("option_id" varchar NOT NULL, "vote_right_id" varchar PRIMARY KEY NOT NULL, CONSTRAINT "UQ_1f4ba94e11f0f6afec9cb013d4e" UNIQUE ("vote_right_id"))`); + await queryRunner.query(`INSERT INTO "temporary_vote"("option_id", "vote_right_id") SELECT "option_id", "vote_right_id" FROM "vote"`); + await queryRunner.query(`DROP TABLE "vote"`); + await queryRunner.query(`ALTER TABLE "temporary_vote" RENAME TO "vote"`); + await queryRunner.query(`CREATE TABLE "temporary_vote" ("option_id" varchar NOT NULL, "vote_right_id" varchar PRIMARY KEY NOT NULL, CONSTRAINT "UQ_1f4ba94e11f0f6afec9cb013d4e" UNIQUE ("vote_right_id"), CONSTRAINT "FK_d17980c91005358383b7ad59ab0" FOREIGN KEY ("option_id") REFERENCES "option" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT, CONSTRAINT "FK_ce69ed4c96964be74d3d57e89cb" FOREIGN KEY ("vote_right_id") REFERENCES "vote_right" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT)`); + await queryRunner.query(`INSERT INTO "temporary_vote"("option_id", "vote_right_id") SELECT "option_id", "vote_right_id" FROM "vote"`); + await queryRunner.query(`DROP TABLE "vote"`); + await queryRunner.query(`ALTER TABLE "temporary_vote" RENAME TO "vote"`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "vote" RENAME TO "temporary_vote"`); + await queryRunner.query(`CREATE TABLE "vote" ("option_id" varchar NOT NULL, "vote_right_id" varchar PRIMARY KEY NOT NULL, CONSTRAINT "UQ_1f4ba94e11f0f6afec9cb013d4e" UNIQUE ("vote_right_id"))`); + await queryRunner.query(`INSERT INTO "vote"("option_id", "vote_right_id") SELECT "option_id", "vote_right_id" FROM "temporary_vote"`); + await queryRunner.query(`DROP TABLE "temporary_vote"`); + await queryRunner.query(`ALTER TABLE "vote" RENAME TO "temporary_vote"`); + await queryRunner.query(`CREATE TABLE "vote" ("option_id" varchar NOT NULL, "vote_right_id" varchar PRIMARY KEY NOT NULL)`); + await queryRunner.query(`INSERT INTO "vote"("option_id", "vote_right_id") SELECT "option_id", "vote_right_id" FROM "temporary_vote"`); + await queryRunner.query(`DROP TABLE "temporary_vote"`); + await queryRunner.query(`ALTER TABLE "vote" RENAME TO "temporary_vote"`); + await queryRunner.query(`CREATE TABLE "vote" ("option_id" varchar NOT NULL, "vote_right_id" varchar PRIMARY KEY NOT NULL, CONSTRAINT "FK_ce69ed4c96964be74d3d57e89cb" FOREIGN KEY ("vote_right_id") REFERENCES "vote_right" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT)`); + await queryRunner.query(`INSERT INTO "vote"("option_id", "vote_right_id") SELECT "option_id", "vote_right_id" FROM "temporary_vote"`); + await queryRunner.query(`DROP TABLE "temporary_vote"`); + await queryRunner.query(`ALTER TABLE "poll" RENAME TO "temporary_poll"`); + await queryRunner.query(`CREATE TABLE "poll" ("id" varchar PRIMARY KEY NOT NULL, "user_id" varchar NOT NULL)`); + await queryRunner.query(`INSERT INTO "poll"("id", "user_id") SELECT "id", "user_id" FROM "temporary_poll"`); + await queryRunner.query(`DROP TABLE "temporary_poll"`); + await queryRunner.query(`ALTER TABLE "vote" RENAME TO "temporary_vote"`); + await queryRunner.query(`CREATE TABLE "vote" ("option_id" varchar NOT NULL, "vote_right_id" varchar PRIMARY KEY NOT NULL, CONSTRAINT "FK_ce69ed4c96964be74d3d57e89cb" FOREIGN KEY ("vote_right_id") REFERENCES "vote_right" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT, CONSTRAINT "FK_d17980c91005358383b7ad59ab0" FOREIGN KEY ("option_id") REFERENCES "option" ("id") ON DELETE RESTRICT ON UPDATE RESTRICT)`); + await queryRunner.query(`INSERT INTO "vote"("option_id", "vote_right_id") SELECT "option_id", "vote_right_id" FROM "temporary_vote"`); + await queryRunner.query(`DROP TABLE "temporary_vote"`); + } + +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 4039c02..4a1a8aa 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -1,7 +1,9 @@ import { Initial1638093041896 } from "./1638093041896-Initial"; import { AddUserIdColumnInPollTable1638098141565 } from "./1638098141565-AddUserIdColumnInPollTable"; +import { AddDeadlineColumnInPollTable1639772969440 } from "./1639772969440-AddDeadlineColumnInPollTable"; export const migrations = [ Initial1638093041896, AddUserIdColumnInPollTable1638098141565, + AddDeadlineColumnInPollTable1639772969440, ] diff --git a/yarn.lock b/yarn.lock index 7114446..f612873 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1808,6 +1808,18 @@ mkdirp@^1.0.4: resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== +moment-timezone@^0.5.34: + version "0.5.34" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.34.tgz#a75938f7476b88f155d3504a9343f7519d9a405c" + integrity sha512-3zAEHh2hKUs3EXLESx/wsgw6IQdusOT8Bxm3D9UrHPQR7zlMmzwybC8zHEM1tQ4LJwP7fcxrWr8tuBg05fFCbg== + dependencies: + moment ">= 2.9.0" + +"moment@>= 2.9.0", moment@^2.29.1: + version "2.29.1" + resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" + integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ== + ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"