diff --git a/.eslintrc.json b/.eslintrc.json index 8f8515f..cd90eec 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -19,6 +19,10 @@ "plugins": ["@typescript-eslint", "import", "eslint-plugin-tsdoc"], "rules": { "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { "argsIgnorePattern": "^_" } + ], "@typescript-eslint/require-await": "off", "no-constant-condition": ["error", { "checkLoops": false }], "@typescript-eslint/no-unnecessary-condition": [ diff --git a/src/models/RoundModel.ts b/src/models/RoundModel.ts index 3ff95ed..fab8d04 100644 --- a/src/models/RoundModel.ts +++ b/src/models/RoundModel.ts @@ -103,6 +103,9 @@ const indexes: { [K in keyof Round]?: 1 }[] = [ // Used to get the most recent round in a channel, to determine the start date // when scheduling the next round. { channel: 1, matchingScheduledFor: 1 }, + + // Used to list rounds chronologically. + { summaryMessageScheduledFor: 1 }, ]; indexes.forEach((index) => RoundSchema.index(index)); diff --git a/src/services/round.ts b/src/services/round.ts index be6e109..c02d618 100644 --- a/src/services/round.ts +++ b/src/services/round.ts @@ -1,5 +1,7 @@ import { DateTime, Duration } from "luxon"; +import { Types } from "mongoose"; +import { GroupModel } from "../models/GroupModel"; import { Round, RoundDocument, RoundModel } from "../models/RoundModel"; import { Result } from "../util/result"; @@ -81,4 +83,29 @@ async function createRound( return Result.ok(round); } -export { createRound, repeatRound }; +async function listRounds(cutoff?: DateTime): Promise { + const filter = + cutoff === undefined + ? {} + : { + summaryMessageScheduledFor: { $gt: cutoff.toJSDate() }, + }; + + return RoundModel.find(filter).sort({ + summaryMessageScheduledFor: 1, + }); +} + +async function deleteRound(id: string): Promise { + const deleted = await RoundModel.findOneAndDelete({ + _id: new Types.ObjectId(id), + }); + + if (deleted !== null) { + await GroupModel.deleteMany({ round: deleted._id }); + } + + return deleted; +} + +export { createRound, repeatRound, listRounds, deleteRound }; diff --git a/src/shell/commands.ts b/src/shell/commands.ts index b46a449..3c03d5a 100644 --- a/src/shell/commands.ts +++ b/src/shell/commands.ts @@ -2,7 +2,12 @@ import { App, SlackEventMiddlewareArgs } from "@slack/bolt"; import { DateTime, Duration } from "luxon"; import { onReactionAddedToMessage } from "../handlers/reaction"; -import { createRound, repeatRound } from "../services/round"; +import { + createRound, + deleteRound, + listRounds, + repeatRound, +} from "../services/round"; import { addReactions, editMessage, @@ -12,6 +17,7 @@ import { } from "../services/slack"; import { formatChannel, + formatRound, parseChannel, parseDate, parseDuration, @@ -199,6 +205,42 @@ class ReactSimulateCommand extends Command { } } +class RoundDeleteCommand extends Command { + static readonly privileged = true; + static readonly id = "round_delete"; + static readonly help = ["ID", "delete a round"]; + + async run() { + if (this.args.length !== 1) { + return usageErr(RoundDeleteCommand); + } + + const roundId = this.args[0]; + const deleted = await deleteRound(roundId); + if (deleted === null) { + return Result.err(`no round with ID: ${roundId}`); + } else { + return Result.ok(`deleted: ${formatRound(deleted)}`); + } + } +} + +class RoundListCommand extends Command { + static readonly privileged = true; + static readonly id = "round_list"; + static readonly help = ["", "list all rounds"]; + + async run() { + if (this.args.length !== 0) { + return usageErr(RoundListCommand); + } + + return Result.ok( + (await listRounds()).map((round) => formatRound(round)).join("\n"), + ); + } +} + class RoundRepeatCommand extends Command { static readonly privileged = true; static readonly id = "round_repeat"; @@ -380,6 +422,8 @@ const commandClasses = [ LsCommand, ReactCommand, ReactSimulateCommand, + RoundDeleteCommand, + RoundListCommand, RoundRepeatCommand, RoundScheduleCommand, SendDirectMessageCommand, diff --git a/src/util/formatting.ts b/src/util/formatting.ts index 0c31d45..5444b7a 100644 --- a/src/util/formatting.ts +++ b/src/util/formatting.ts @@ -1,5 +1,7 @@ import { DateTime, Duration } from "luxon"; +import { RoundDocument } from "../models/RoundModel"; + import { Result } from "./result"; function formatChannel(channel: string): string { @@ -45,6 +47,15 @@ function parseInteger(value: string): Result { return Result.ok(parsed); } +function formatDuration(duration: Duration): string { + const units = ["weeks", "days", "hours", "minutes", "seconds"] as const; + const shifted = duration.shiftTo(...units); + const entries = units + .map((unit) => [unit, shifted[unit]] as const) + .filter(([_, value]) => value > 0); + return entries.map(([unit, value]) => `${value}${unit[0]}`).join(""); +} + function parseDuration(duration: string): Result { if (duration.length === 0) { return Result.err("duration string cannot be empty"); @@ -93,6 +104,25 @@ function parseDuration(duration: string): Result { return Result.ok(Duration.fromObject(durationObject)); } +function formatRound(round: RoundDocument): string { + const startDate = DateTime.fromJSDate(round.matchingScheduledFor); + return [ + round._id.toHexString(), + formatChannel(round.channel), + round.matchingScheduledFor.toISOString(), + formatDuration(Duration.fromObject({ seconds: round.durationSec })), + formatDuration( + DateTime.fromJSDate(round.reminderMessageScheduledFor).diff(startDate), + ), + formatDuration( + DateTime.fromJSDate(round.finalMessageScheduledFor).diff(startDate), + ), + formatDuration( + DateTime.fromJSDate(round.summaryMessageScheduledFor).diff(startDate), + ), + ].join(" "); +} + export { formatChannel, parseChannel, @@ -101,5 +131,7 @@ export { formatUser, parseUser, parseDate, + formatDuration, parseDuration, + formatRound, }; diff --git a/tests/util/formatting.test.ts b/tests/util/formatting.test.ts index dd842be..73fcc14 100644 --- a/tests/util/formatting.test.ts +++ b/tests/util/formatting.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "@jest/globals"; import { Duration } from "luxon"; -import { parseDuration } from "../../src/util/formatting"; +import { formatDuration, parseDuration } from "../../src/util/formatting"; import { Result } from "../../src/util/result"; describe("parseDuration", () => { @@ -11,31 +11,19 @@ describe("parseDuration", () => { ); }); - test("days only", () => { - const result = parseDuration("53d"); - expect(result.ok ? result.value.toISO() : null).toStrictEqual( - Duration.fromObject({ days: 53 }).toISO(), - ); - }); - - test("hours and minutes only", () => { - const result = parseDuration("3h4m"); - expect(result.ok ? result.value.toISO() : null).toStrictEqual( - Duration.fromObject({ hours: 3, minutes: 4 }).toISO(), - ); - }); - - test("all fields", () => { - const result = parseDuration("1w15d12h9m6s"); - expect(result.ok ? result.value.toISO() : null).toStrictEqual( - Duration.fromObject({ - weeks: 1, - days: 15, - hours: 12, - minutes: 9, - seconds: 6, - }).toISO(), + test.each([ + ["5d", { days: 5 }], + ["3h4m", { hours: 3, minutes: 4 }], + ["1w5d12h9m6s", { weeks: 1, days: 5, hours: 12, minutes: 9, seconds: 6 }], + ])("round-trip %s to %j", (str: string, obj: Record) => { + const parseResult = parseDuration(str); + if (!parseResult.ok) { + throw new Error("failed to parse"); + } + expect(parseResult.value.toISO()).toStrictEqual( + Duration.fromObject(obj).toISO(), ); + expect(formatDuration(parseResult.value)).toStrictEqual(str); }); test("wrong formats", () => {