diff --git a/config/example.json b/config/example.json index a5511ed..424036a 100644 --- a/config/example.json +++ b/config/example.json @@ -15,12 +15,14 @@ "celebration": "CXXXX", "void": "CXXX", "approval": "DXXX", - "certification_approval": "DXXX" + "certification_approval": "DXXX", + "checkin": "CXXX" }, "groups": { "students": "SXXX" }, "users": { + "copres": ["UXXX"], "admins": ["UXXX"], "devs": ["UXXX"] } diff --git a/dev/manifest.json b/dev/manifest.json index efc3bc6..9bbbc7a 100644 --- a/dev/manifest.json +++ b/dev/manifest.json @@ -70,7 +70,7 @@ }, "oauth_config": { "scopes": { - "bot": ["app_mentions:read", "channels:history", "channels:join", "channels:read", "chat:write", "chat:write.public", "commands", "files:write", "groups:read", "im:history", "im:read", "im:write", "mpim:read", "usergroups:read", "users:read", "users.profile:read", "users:read.email"] + "bot": ["app_mentions:read", "channels:history", "channels:join", "channels:read", "chat:write", "chat:write.public", "commands", "files:write", "groups:read", "im:history", "im:read", "im:write", "mpim:read", "mpim:write", "reactions:write", "usergroups:read", "users.profile:read", "users:read", "users:read.email", "mpim:write.topic"] } }, "settings": { diff --git a/eslint.config.js b/eslint.config.js index dd0e989..fbaee2e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -20,8 +20,8 @@ export default tseslint.config( { argsIgnorePattern: '^_', varsIgnorePattern: '^_', - caughtErrorsIgnorePattern: '^_', - }, + caughtErrorsIgnorePattern: '^_' + } ], 'prefer-const': 'warn', '@typescript-eslint/ban-ts-comment': ['warn', { 'ts-ignore': 'allow-with-description' }] diff --git a/src/lib/logger.ts b/src/lib/logger.ts index f01d3b4..b953109 100644 --- a/src/lib/logger.ts +++ b/src/lib/logger.ts @@ -15,7 +15,7 @@ const transport = pino.transport({ ] }) -const logger = pino({ level: 'trace' }, transport) +const logger = pino({ level: 'debug' }, transport) const pinoToBoltLevel: Record = { fatal: BoltLogLevel.ERROR, diff --git a/src/lib/sockets.ts b/src/lib/sockets.ts index 6795e6b..a56a898 100644 --- a/src/lib/sockets.ts +++ b/src/lib/sockets.ts @@ -13,13 +13,6 @@ export function startWS(server: HttpServer) { path: '/ws' }) logger.info('Websocket server started') - - io.on('connection', (socket) => { - socket.emit('hello', 'world') - socket.on('hello', (data) => { - socket.broadcast.emit('hello', data) - }) - }) } export function emitCluckChange(data: WSCluckChange) { diff --git a/src/routes/api/index.ts b/src/routes/api/index.ts index 18f9de4..ccd3ba2 100644 --- a/src/routes/api/index.ts +++ b/src/routes/api/index.ts @@ -22,7 +22,17 @@ router.get('/members', requireReadAPI, async (c) => { use_slack_photo: true, slack_photo: true, slack_photo_small: true, - fallback_photo: true + fallback_photo: true, + MemberCerts: { + where: { + Cert: { + isManager: true + } + }, + select: { + cert_id: true + } + } }, where: { active: true @@ -34,7 +44,8 @@ router.get('/members', requireReadAPI, async (c) => { first_name: member.first_name, full_name: member.full_name, photo: getMemberPhotoOrDefault(member, false), - photo_small: getMemberPhotoOrDefault(member, true) + photo_small: getMemberPhotoOrDefault(member, true), + isManager: member.MemberCerts.length > 0 })) return c.json(resp) }) diff --git a/src/slack/blocks/responses.ts b/src/slack/blocks/responses.ts index bcb0575..2da78e4 100644 --- a/src/slack/blocks/responses.ts +++ b/src/slack/blocks/responses.ts @@ -63,7 +63,7 @@ export default { autoSignoutDM(v: { slack_id: string; time_in: Date }) { return Message() .text( - `Hey <@${v.slack_id}>! You signed into the lab today at ${v.time_in.toLocaleTimeString()} but forgot to sign out, so we didn't log your hours for today :( Make sure you always sign out before you leave. Hope you had fun and excited to see you in the lab again!` + `Hey <@${v.slack_id}>! You signed into the lab today at ${v.time_in.toLocaleTimeString('en-us', { hour: 'numeric', hour12: true, minute: '2-digit' })} but forgot to sign out, so we didn't log your hours for today :( Make sure you always sign out before you leave. Hope you had fun and excited to see you in the lab again!` ) .buildToObject() } diff --git a/src/slack/handlers/actions/checkin.ts b/src/slack/handlers/actions/checkin.ts new file mode 100644 index 0000000..94e482b --- /dev/null +++ b/src/slack/handlers/actions/checkin.ts @@ -0,0 +1,44 @@ +import config from '~lib/config' +import logger from '~lib/logger' +import prisma from '~lib/prisma' +import { EventMiddleware } from '~slack/lib/types' + +export const handleAppMentioned: EventMiddleware<'app_mention'> = async ({ event, client }) => { + if (event.channel == config.slack.channels.checkin) { + await client.reactions.add({ + channel: event.channel, + timestamp: event.ts, + name: 'stopwatch' + }) + const user = await client.auth.test() + const managers = await prisma.member.findMany({ where: { MemberCerts: { some: { Cert: { isManager: true } } } } }) + const copres_string = config.slack.users.copres.join(',') + for (const manager of managers) { + if (!manager.slack_id) { + logger.warn('No slack id for manager ' + manager.email) + continue + } + const dm = await client.conversations.open({ + users: copres_string + ',' + manager.slack_id + }) + if (dm.channel?.id == null) { + logger.warn('No group dm for manager ' + manager.email) + continue + } + await client.chat.postMessage({ + channel: dm.channel!.id!, + text: event.text.replace(user.user_id!, manager.slack_id) + }) + } + await client.reactions.remove({ + channel: event.channel, + timestamp: event.ts, + name: 'stopwatch' + }) + await client.reactions.add({ + channel: event.channel, + timestamp: event.ts, + name: 'white_check_mark' + }) + } +} diff --git a/src/slack/handlers/index.ts b/src/slack/handlers/index.ts index ea4aa6e..fcdff01 100644 --- a/src/slack/handlers/index.ts +++ b/src/slack/handlers/index.ts @@ -20,6 +20,7 @@ import { handleSubmitHoursRejectModal } from './actions/hours_response' import { handleRunTask } from '~slack/handlers/actions/run_task' +import { handleAppMentioned } from './actions/checkin' export enum ActionIDs { ACCEPT = 'accept', @@ -91,6 +92,7 @@ export function registerSlackHandlers(app: App) { app.view(ViewIDs.MODAL_ONBOARDING, handleSubmitOnboardingModal) // Events app.event('app_home_opened', handleAppHomeOpened) + app.event('app_mention', handleAppMentioned) app.action(/./, async ({ body, logger, action }) => { const details: Record = { type: body?.type, diff --git a/src/tasks/calendar.ts b/src/tasks/calendar.ts new file mode 100644 index 0000000..9a357e0 --- /dev/null +++ b/src/tasks/calendar.ts @@ -0,0 +1,49 @@ +import { Prisma } from '@prisma/client' +import config from '~lib/config' +import logger from '~lib/logger' +import prisma from '~lib/prisma' +import { emitCluckChange } from '~lib/sockets' +import { slack_client } from '~slack' +import responses from '~slack/blocks/responses' + +export async function promptCheckinMessage() { + await slack_client.chat.postMessage({ + channel: config.slack.channels.checkin, + text: " it's that time again! Make a checkin post" + }) +} + +export async function logoutAll() { + const loggedIn = await prisma.hourLog.findMany({ + where: { + state: 'pending', + type: 'lab' + }, + select: { time_in: true, Member: { select: { slack_id: true, email: true } } } + }) + for (const log of loggedIn) { + try { + const slack_id = log.Member.slack_id + emitCluckChange({ email: log.Member.email, logging_in: false }) + if (slack_id) { + await slack_client.chat.postMessage({ + ...responses.autoSignoutDM({ slack_id, time_in: log.time_in }), + channel: slack_id + }) + } + } catch (e) { + logger.warn(e) + } + } + await prisma.hourLog.updateMany({ + where: { + state: 'pending', + type: 'lab' + }, + data: { + state: 'cancelled', + time_out: new Date(), + duration: new Prisma.Decimal(0) + } + }) +} diff --git a/src/tasks/index.ts b/src/tasks/index.ts index c2ba69b..27f553a 100644 --- a/src/tasks/index.ts +++ b/src/tasks/index.ts @@ -4,16 +4,17 @@ import { syncSlackMembers } from '~tasks/slack' import { announceNewCerts, updateProfileCerts } from '~tasks/certs' import { updateSheet } from '~spreadsheet' import { syncFallbackPhotos } from './photos' -import { setupAutoLogout } from './midnight' +import schedule from 'node-schedule' +import { logoutAll, promptCheckinMessage } from './calendar' -type TaskFunc = (reason: string) => Promise +type TaskFunc = ((reason: string) => Promise) & { label: string } type Func = (() => void) | (() => Promise) const tasks: Record = {} function createTaskFunc(task: Func): TaskFunc { const label = 'task/' + task.name - return async (reason: string) => { + const func = async (reason: string) => { try { await task() } catch (e) { @@ -23,6 +24,8 @@ function createTaskFunc(task: Func): TaskFunc { logger.info({ name: label }, 'Task ran successfully') return } + func.label = label + return func } function scheduleTask(task: Func, interval_seconds: number, runOnInit: boolean, offset_seconds: number): TaskFunc { const cb = createTaskFunc(task) @@ -39,6 +42,11 @@ function scheduleTask(task: Func, interval_seconds: number, runOnInit: boolean, return cb } +function scheduleCronTask(task: TaskFunc, cron_exp: string) { + schedule.scheduleJob(task.label, cron_exp, (_date) => task('scheduled run')) + return task +} + export function scheduleTasks() { // Offset is to combat Slack's rate limits const isProd = process.env.NODE_ENV === 'prod' @@ -46,11 +54,13 @@ export function scheduleTasks() { tasks['Sync Sheet'] = scheduleTask(updateSheet, 60 * 5, isProd, 0) tasks['Announce Certs'] = scheduleTask(announceNewCerts, 60 * 60, isProd, 60) // Just in case the cert announcement isn't automatically run on changes tasks['Sync Usergroups'] = scheduleTask(updateSlackUsergroups, 60 * 60, isProd, 2 * 60) - tasks['Update Profile Certs'] = scheduleTask(updateProfileCerts, 60 * 60 * 24, isProd, 5 * 60) tasks['Link Fallback Photos'] = createTaskFunc(syncFallbackPhotos) - setupAutoLogout() + tasks['Logout All'] = scheduleCronTask(createTaskFunc(logoutAll), '0 0 * * *') + // Slack is silly and can only handle 5 items in the overflow menu + scheduleCronTask(createTaskFunc(promptCheckinMessage), '0 9 * * SAT') scheduleTask(syncSlackMembers, 60 * 60, isProd, 0) // can be run from the admin members page + scheduleTask(updateProfileCerts, 60 * 60 * 24, isProd, 5 * 60) } export async function runTask(key: string) { diff --git a/src/tasks/midnight.ts b/src/tasks/midnight.ts deleted file mode 100644 index 81b888c..0000000 --- a/src/tasks/midnight.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Prisma } from '@prisma/client' -import schedule from 'node-schedule' -import logger from '~lib/logger' -import prisma from '~lib/prisma' -import { slack_client } from '~slack' -import responses from '~slack/blocks/responses' - -export function setupAutoLogout() { - schedule.scheduleJob('Midnight Logout', '0 0 * * *', async (date) => { - const loggedIn = await prisma.hourLog.findMany({ - where: { - state: 'pending', - type: 'lab' - }, - select: { time_in: true, Member: { select: { slack_id: true } } } - }) - for (const log of loggedIn) { - try { - const slack_id = log.Member.slack_id - if (slack_id) { - await slack_client.chat.postMessage({ - ...responses.autoSignoutDM({ slack_id, time_in: log.time_in }), - channel: log.Member.slack_id - }) - } - } catch (e) { - logger.warn(e) - } - } - await prisma.hourLog.updateMany({ - where: { - state: 'pending', - type: 'lab' - }, - data: { - state: 'cancelled', - time_out: new Date(), - duration: new Prisma.Decimal(0) - } - }) - }) -} diff --git a/src/types/index.ts b/src/types/index.ts index 9303ee3..93580d1 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,7 @@ export type APIMember = { full_name: string photo: string photo_small: string + isManager: boolean } export type APIClockLabRequest = { diff --git a/src/views/grid/index.ts b/src/views/grid/index.ts index 5cb8ea9..7236c89 100644 --- a/src/views/grid/index.ts +++ b/src/views/grid/index.ts @@ -25,6 +25,9 @@ export async function buildGrid() { // Init button const memberButton = document.createElement('div') memberButton.classList.add('memberButton') + if (member.isManager) { + memberButton.classList.add('manager') + } memberButton.id = member.email // Set click toggle diff --git a/src/views/grid/style.scss b/src/views/grid/style.scss index ac80eb9..c427efd 100644 --- a/src/views/grid/style.scss +++ b/src/views/grid/style.scss @@ -107,6 +107,12 @@ box-shadow: inset 0 0 0 1000px rgba(0, 255, 85, 0.2); */ } } +.memberButton.manager[data-loggedin='true'] { + box-shadow: + inset 0 0 0 1000px rgba(255, 255, 255, 0), + 0 0 15px 7px rgb(251, 0, 255); +} + body[data-gridstyle='void'] { .memberButton { .buttonText { @@ -124,6 +130,10 @@ body[data-gridstyle='void'] { } } +.manager .buttonText { + box-shadow: inset 0 0 0 1000px rgba(255, 70, 237, 0.6); +} + .buttonText { user-select: none; box-shadow: inset 0 0 0 1000px rgba(70, 255, 169, 0.6); diff --git a/src/views/grid/style.ts b/src/views/grid/style.ts index 8634886..065be8d 100644 --- a/src/views/grid/style.ts +++ b/src/views/grid/style.ts @@ -24,7 +24,7 @@ if (gridstyle == 'void') { export function applyRandomStyles(e: HTMLDivElement) { e.classList.add(randomChoice('labelLeft', 'labelRight', 'labelCenter')) - e.classList.add(randomChoice('labelTop', 'labelBottom')) + // e.classList.add(randomChoice('labelTop', 'labelBottom')) e.style.fontFamily = randomChoice('gilroy', 'cocogoose', 'tcm') }