diff --git a/config/example.json b/config/example.json index dac8dee..91a8eb5 100644 --- a/config/example.json +++ b/config/example.json @@ -16,6 +16,7 @@ "void": "CXXX", "approval": "DXXX", "certification_approval": "DXXX", + "violation": "CXXXX", "checkin": "CXXX" }, "groups": { @@ -25,6 +26,7 @@ "users": { "copres": ["UXXX"], "admins": ["UXXX"], + "reports": ["UXXX"], "devs": ["UXXX"] } }, diff --git a/dev/manifest.json b/dev/manifest.json index 9bbbc7a..45f35d2 100644 --- a/dev/manifest.json +++ b/dev/manifest.json @@ -65,6 +65,11 @@ "command": "/departments", "description": "Allows you to manage your department associations", "should_escape": false + }, + { + "command": "/report", + "description": "[Manager Only]", + "should_escape": false } ] }, diff --git a/prisma/migrations/20241029014205_add_violations/migration.sql b/prisma/migrations/20241029014205_add_violations/migration.sql new file mode 100644 index 0000000..085790a --- /dev/null +++ b/prisma/migrations/20241029014205_add_violations/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "Violations" ( + "id" SERIAL NOT NULL, + "member" TEXT NOT NULL, + "reporter" TEXT, + + CONSTRAINT "Violations_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "Violations" ADD CONSTRAINT "Violations_member_fkey" FOREIGN KEY ("member") REFERENCES "Members"("email") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Violations" ADD CONSTRAINT "Violations_reporter_fkey" FOREIGN KEY ("reporter") REFERENCES "Members"("email") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/migrations/20241029015815_reportings/migration.sql b/prisma/migrations/20241029015815_reportings/migration.sql new file mode 100644 index 0000000..75228f5 --- /dev/null +++ b/prisma/migrations/20241029015815_reportings/migration.sql @@ -0,0 +1,15 @@ +/* + Warnings: + + - You are about to drop the column `reporter` on the `Violations` table. All the data in the column will be lost. + - Added the required column `description` to the `Violations` table without a default value. This is not possible if the table is not empty. + - Added the required column `reporter_slack_id` to the `Violations` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "Violations" DROP CONSTRAINT "Violations_reporter_fkey"; + +-- AlterTable +ALTER TABLE "Violations" DROP COLUMN "reporter", +ADD COLUMN "description" TEXT NOT NULL, +ADD COLUMN "reporter_slack_id" TEXT NOT NULL; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f50151a..be76dad 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -133,6 +133,7 @@ model Member { MemberCertRequests MemberCertRequest[] @relation("MemberCertRecipient") MemberCertsRequested MemberCertRequest[] @relation("MemberCertRequester") Departments DepartmentAssociation[] + Violations Violation[] @@index([slack_id], map: "members_slack_id") @@index([full_name], map: "members_full_name") @@ -181,6 +182,17 @@ model FallbackPhoto { @@map("FallbackPhotos") } +model Violation { + id Int @id @default(autoincrement()) + member String + reporter_slack_id String + description String + + Member Member @relation(fields: [member], references: [email], onDelete: Cascade) + + @@map("Violations") +} + enum enum_HourLogs_state { complete pending diff --git a/src/slack/handlers/actions/report.ts b/src/slack/handlers/actions/report.ts new file mode 100644 index 0000000..1b5cac4 --- /dev/null +++ b/src/slack/handlers/actions/report.ts @@ -0,0 +1,118 @@ +import { getCertifyModal, getCertRequestMessage } from '~slack/blocks/certify' +import { createCertRequest } from '~lib/cert_operations' +import { ActionMiddleware, CommandMiddleware, ViewMiddleware } from '~slack/lib/types' +import prisma from '~lib/prisma' +import { ordinal, safeParseInt } from '~lib/util' +import { Blocks, ConfirmationDialog, Elements, Message, Modal, ModalBuilder } from 'slack-block-builder' +import { ActionIDs, ViewIDs } from '..' +import config from '~config' +import { slack_client } from '~slack' +import logger from '~lib/logger' + +async function createReportModal(user_id: string): Promise { + const manager = await prisma.member.findUnique({ + where: { slack_id: user_id }, + select: { MemberCerts: { where: { Cert: { isManager: true } } } } + }) + const slack_user = await slack_client.users.info({ user: user_id }) + if (slack_user.user?.is_admin) { + logger.info('Accepting report from admin: ' + slack_user.user?.name) + } else if (!manager) { + return Modal().title('Unauthorized').blocks(Blocks.Header().text("I don't know you ๐Ÿคจ")) + } else if (manager.MemberCerts.length == 0) { + return Modal().title('Unauthorized').blocks(Blocks.Header().text("You're not a manager. Please reach out to a manager or Kevin directly if you have concerns")) + } + + return Modal() + .title('Report a Violation') + .callbackId(ViewIDs.MODAL_REPORT) + .blocks( + Blocks.Input().label('Member').blockId('user').element(Elements.UserSelect().actionId('user').placeholder('Who are you reporting?')), + Blocks.Input().label('Description').blockId('description').element(Elements.TextInput().multiline().actionId('description').placeholder('What happened?')) + ) + .submit('Report') + .close('Cancel') +} + +export const handleReportCommand: CommandMiddleware = async ({ command, ack, client }) => { + await ack() + const modal = await createReportModal(command.user_id) + await client.views.open({ + view: modal.buildToObject(), + trigger_id: command.trigger_id + }) +} + +export const handleSubmitReportModal: ViewMiddleware = async ({ ack, body, view, client }) => { + // Get the hours and task from the modal + const slack_id = view.state.values.user.user.selected_user + const description = view.state.values.description.description.value! + const member = await prisma.member.findUnique({ where: { slack_id: slack_id ?? '' }, select: { email: true, slack_id: true, Violations: true } }) + if (slack_id == null || member == null) { + await ack({ + response_action: 'errors', + errors: { user: 'Unknown user' } + }) + return + } else { + await ack() + const violation = await prisma.violation.create({ + data: { + description: description, + member: member.email, + reporter_slack_id: body.user.id + } + }) + const log_message = Message() + .text('New Violation Reported') + .blocks( + Blocks.Header().text(`Violation Report #${violation.id}`), + Blocks.Section() + .text(`Report by <@${violation.reporter_slack_id}> for <@${member.slack_id}>'s conduct`) + .accessory( + Elements.Button() + .actionId(ActionIDs.DELETE_REPORT) + .value(violation.id.toString()) + .text('๐Ÿ—‘๏ธ') + .confirm(ConfirmationDialog().danger().confirm('Delete').title('Delete Report').text('Are you sure you want to delete this report?').deny('Cancel')) + .danger() + ), + Blocks.Context().elements('This is the ' + ordinal(member.Violations.length + 1) + ' violation for this member'), + Blocks.Section().text('>>> ' + description), + Blocks.Divider() + ) + .buildToObject() + await client.chat.postMessage({ channel: config.slack.channels.violation, text: log_message.text, blocks: log_message.blocks }) + + const discuss_message = Message() + .text('New Violation Reported') + .blocks( + Blocks.Header().text(`Violation Report #${violation.id}`), + Blocks.Context().elements(`For <@${member.slack_id}>`), + Blocks.Section().text('>>> ' + description), + Blocks.Divider() + ) + .buildToObject() + console.log([...config.slack.users.admins, body.user.id]) + const dm = await client.conversations.open({ users: [...config.slack.users.reports, body.user.id].join(',') }) + await client.chat.postMessage({ channel: dm.channel!.id!, text: discuss_message.text, blocks: discuss_message.blocks }) + } +} + +export const handleReportDelete: ActionMiddleware = async ({ ack, respond, action, client, body }) => { + await ack() + const report_id = safeParseInt(action.value) + if (report_id == null) { + return + } + + await prisma.violation.delete({ + where: { id: report_id } + }) + const log_message = Message() + .text('New Violation Reported') + .blocks(Blocks.Header().text(`Violation Report #${report_id}`), Blocks.Section().text(`Deleted by <@${body.user.id}> at ${new Date().toLocaleString()}`), Blocks.Divider()) + .buildToObject() + + await respond({ response_type: 'ephemeral', blocks: log_message.blocks! }) +} diff --git a/src/slack/handlers/index.ts b/src/slack/handlers/index.ts index 99f2f76..13eb8be 100644 --- a/src/slack/handlers/index.ts +++ b/src/slack/handlers/index.ts @@ -22,6 +22,7 @@ import { import { handleRunTask } from '~slack/handlers/actions/run_task' import { handleAppMentioned } from './actions/checkin' import { handleOpenEventlogModal, handleSubmitEventlogModal } from './views/eventlog' +import { handleReportCommand, handleReportDelete, handleSubmitReportModal } from './actions/report' export enum ActionIDs { ACCEPT = 'accept', @@ -39,7 +40,8 @@ export enum ActionIDs { CERT_APPROVE = 'cert_approve', CERT_REJECT = 'cert_reject', RUN_TASK = 'run_task', - SETUP_EVENT_LOG = 'setup_event_log' + SETUP_EVENT_LOG = 'setup_event_log', + DELETE_REPORT = 'delete_report' } export enum ViewIDs { @@ -47,6 +49,7 @@ export enum ViewIDs { MODAL_ACCEPT = 'accept_modal', MODAL_LOG = 'time_submission', MODAL_CERTIFY = 'certify_modal', + MODAL_REPORT = 'report_modal', MODAL_DEPARTMENTS = 'departments_modal', MODAL_ONBOARDING = 'onboarding_modal', MODAL_EVENTLOG = 'eventlog_modal' @@ -66,6 +69,7 @@ export function registerSlackHandlers(app: App) { app.command(cmd_prefix + 'hours', handleShowHoursCommand) app.command(cmd_prefix + 'certify', handleCertifyCommand) app.command(cmd_prefix + 'departments', handleDepartmentsCommand) + app.command(cmd_prefix + 'report', handleReportCommand) app.shortcut('log_hours', handleLogShortcut) // Buttons @@ -85,6 +89,7 @@ export function registerSlackHandlers(app: App) { app.action(ActionIDs.OPEN_ONBOARDING_MODAL, handleOpenOnboardingModal) app.action(ActionIDs.RUN_TASK, handleRunTask) app.action(ActionIDs.SETUP_EVENT_LOG, handleOpenEventlogModal) + app.action(ActionIDs.DELETE_REPORT, handleReportDelete) app.action('jump_url', async ({ ack }) => { await ack() }) @@ -97,6 +102,7 @@ export function registerSlackHandlers(app: App) { app.view(ViewIDs.MODAL_DEPARTMENTS, handleSubmitDepartmentsModal) app.view(ViewIDs.MODAL_ONBOARDING, handleSubmitOnboardingModal) app.view(ViewIDs.MODAL_EVENTLOG, handleSubmitEventlogModal) + app.view(ViewIDs.MODAL_REPORT, handleSubmitReportModal) // Events app.event('app_home_opened', handleAppHomeOpened) app.event('app_mention', handleAppMentioned) diff --git a/src/slack/lib/hours_submission.ts b/src/slack/lib/hours_submission.ts index edfa908..70c5d08 100644 --- a/src/slack/lib/hours_submission.ts +++ b/src/slack/lib/hours_submission.ts @@ -25,7 +25,7 @@ export async function handleHoursRequest(slack_id: string, hours: number, activi const entry = await prisma.hourLog.create({ data: request }) // Send request message to approvers - const message = getHourSubmissionMessage({ slack_id, activity, hours, request_id: entry.id.toString(), state: 'pending' }) + const message = getHourSubmissionMessage({ slack_id, activity, hours, request_id: entry.id.toString(), state: 'pending', createdAt: new Date() }) const msg = await slack_client.chat.postMessage({ channel: config.slack.channels.approval, text: message.text, blocks: message.blocks }) await slack_client.chat.postMessage({ @@ -71,7 +71,8 @@ export async function handleHoursResponse({ request_id: request_id.toString(), state: action == 'approve' ? 'approved' : 'rejected', response, - type + type, + createdAt: log.createdAt }) await slack_client.chat.update({ channel: config.slack.channels.approval,