diff --git a/libs/gql-schema/organization-membership.ts b/libs/gql-schema/organization-membership.ts index 8b63afde1..439e5c2f6 100644 --- a/libs/gql-schema/organization-membership.ts +++ b/libs/gql-schema/organization-membership.ts @@ -27,6 +27,7 @@ export const schema = ` organization: Organization! role: UserRole! requestAutoApprove: RequestAutoApprove! + assignmentRequestNotifications: Boolean! } type OrganizationMembershipEdge { diff --git a/libs/gql-schema/schema.ts b/libs/gql-schema/schema.ts index 0b1070778..eac82cce7 100644 --- a/libs/gql-schema/schema.ts +++ b/libs/gql-schema/schema.ts @@ -300,7 +300,7 @@ const rootSchema = ` createOrganization(name: String!, userId: String!, inviteId: String!): Organization editOrganization(id: String! input: EditOrganizationInput!): Organization! joinOrganization(organizationUuid: String!): Organization! - editOrganizationMembership(id: String!, level: RequestAutoApprove, role: String): OrganizationMembership! + editOrganizationMembership(id: String!, level: RequestAutoApprove, role: String, assignmentRequestNotifications: Boolean): OrganizationMembership! editOrganizationSettings(id: String!, input: OrganizationSettingsInput!): OrganizationSettings! purgeOrganizationUsers(organizationId: String!): Int! editUser(organizationId: String!, userId: String!, userData:UserInput): User diff --git a/migrations/20260902090000_add_assignment_request_notifications.js b/migrations/20260902090000_add_assignment_request_notifications.js new file mode 100644 index 000000000..242cf5396 --- /dev/null +++ b/migrations/20260902090000_add_assignment_request_notifications.js @@ -0,0 +1,14 @@ +exports.up = function up(knex) { + return knex.schema.alterTable("user_organization", (table) => { + table + .boolean("assignment_request_notifications") + .notNullable() + .defaultTo(false); + }); +}; + +exports.down = function down(knex) { + return knex.schema.alterTable("user_organization", (table) => { + table.dropColumn("assignment_request_notifications"); + }); +}; diff --git a/schema-dump.sql b/schema-dump.sql index 5d6ad2704..18d27a5ab 100644 --- a/schema-dump.sql +++ b/schema-dump.sql @@ -3370,6 +3370,7 @@ CREATE TABLE public.user_organization ( role text NOT NULL, updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP, request_status public.texter_status DEFAULT 'approval_required'::public.texter_status NOT NULL, + assignment_request_notifications boolean DEFAULT false NOT NULL, CONSTRAINT user_organization_role_check CHECK ((role = ANY (ARRAY['OWNER'::text, 'ADMIN'::text, 'SUPERVOLUNTEER'::text, 'TEXTER'::text, 'SUSPENDED'::text]))) ); diff --git a/src/containers/AdminAssignmentRequest/index.jsx b/src/containers/AdminAssignmentRequest/index.jsx index 16c0538f2..b4b878dcd 100644 --- a/src/containers/AdminAssignmentRequest/index.jsx +++ b/src/containers/AdminAssignmentRequest/index.jsx @@ -5,6 +5,8 @@ import DialogActions from "@material-ui/core/DialogActions"; import DialogContent from "@material-ui/core/DialogContent"; import DialogContentText from "@material-ui/core/DialogContentText"; import DialogTitle from "@material-ui/core/DialogTitle"; +import FormControlLabel from "@material-ui/core/FormControlLabel"; +import Switch from "@material-ui/core/Switch"; import isEqual from "lodash/isEqual"; import PropTypes from "prop-types"; import React, { Component } from "react"; @@ -84,6 +86,13 @@ class AdminAssignmentRequest extends Component { handleResolveRequest = (approved) => (requestId) => this.resolveRequest(requestId, approved); + handleNotificationSubscription = ({ target: { checked } }) => { + const { + node + } = this.props.pendingAssignmentRequests.currentUser.memberships.edges[0]; + this.props.mutations.editOrganizationMembership(node.id, checked); + }; + resolveRequest = async (requestId, approved, autoApprove = false) => { const { resolveAssignmentRequest } = this.props.mutations; this.setRequestStatus(requestId, RowWorkStatus.Working); @@ -110,6 +119,8 @@ class AdminAssignmentRequest extends Component { render() { const { currentUser } = this.props.pendingAssignmentRequests; const { assignmentRequests, autoApproveReqId } = this.state; + const isAdmin = hasRole("ADMIN", currentUser.roles); + const membership = currentUser.memberships.edges[0].node; const autoApproveRequest = autoApproveReqId && assignmentRequests.find(({ id }) => id === autoApproveReqId); @@ -129,8 +140,20 @@ class AdminAssignmentRequest extends Component { return (
+ {isAdmin && ( + + } + label="Email me when assignment requests need approval" + /> + )} (id, enabled) => ({ + mutation: gql` + mutation editOrganizationMembership($id: String!, $enabled: Boolean!) { + editOrganizationMembership( + id: $id + assignmentRequestNotifications: $enabled + ) { + id + assignmentRequestNotifications + } + } + `, + variables: { id, enabled } + }), resolveAssignmentRequest: () => ( assignmentRequestId, approved, diff --git a/src/schema.graphql b/src/schema.graphql index 43f37b2ca..c6a3bf930 100644 --- a/src/schema.graphql +++ b/src/schema.graphql @@ -266,7 +266,7 @@ type RootMutation { createOrganization(name: String!, userId: String!, inviteId: String!): Organization editOrganization(id: String! input: EditOrganizationInput!): Organization! joinOrganization(organizationUuid: String!): Organization! - editOrganizationMembership(id: String!, level: RequestAutoApprove, role: String): OrganizationMembership! + editOrganizationMembership(id: String!, level: RequestAutoApprove, role: String, assignmentRequestNotifications: Boolean): OrganizationMembership! editOrganizationSettings(id: String!, input: OrganizationSettingsInput!): OrganizationSettings! purgeOrganizationUsers(organizationId: String!): Int! editUser(organizationId: String!, userId: String!, userData:UserInput): User @@ -552,6 +552,7 @@ type OrganizationMembership { organization: Organization! role: UserRole! requestAutoApprove: RequestAutoApprove! + assignmentRequestNotifications: Boolean! } type OrganizationMembershipEdge { diff --git a/src/server/api/organization-membership.ts b/src/server/api/organization-membership.ts index 6b355f9dc..b63ac67cd 100644 --- a/src/server/api/organization-membership.ts +++ b/src/server/api/organization-membership.ts @@ -13,7 +13,7 @@ interface IOrganizationMembership { export const resolvers = { OrganizationMembership: { - ...sqlResolvers(["id"]), + ...sqlResolvers(["id", "assignmentRequestNotifications"]), user: async (membership: IOrganizationMembership) => membership.user ? membership.user diff --git a/src/server/api/root-mutations.ts b/src/server/api/root-mutations.ts index ccaa07016..b09c4a1d2 100644 --- a/src/server/api/root-mutations.ts +++ b/src/server/api/root-mutations.ts @@ -353,7 +353,7 @@ const rootMutations = { editOrganizationMembership: async ( _root, - { id, level, role }, + { id, level, role, assignmentRequestNotifications }, { user: authUser } ) => { const membership = await r @@ -361,6 +361,14 @@ const rootMutations = { .where({ id: parseInt(id, 10) }) .first(); if (!membership) throw new Error("No such org membership"); + if ( + _.isBoolean(assignmentRequestNotifications) && + membership.user_id !== authUser.id + ) { + throw new ForbiddenError( + "You can only edit your own notification settings." + ); + } let roleRequired = UserRoleType.ADMIN; if ( @@ -396,6 +404,11 @@ const rootMutations = { .returning("*"); if (level) updateQuery.update({ request_status: level.toLowerCase() }); + if (_.isBoolean(assignmentRequestNotifications)) { + updateQuery.update({ + assignment_request_notifications: assignmentRequestNotifications + }); + } if (role) { // update both tables if role change userUpdateQuery.update( @@ -2164,6 +2177,26 @@ const rootMutations = { throw new Error(`Could not submit external requst: ${message}`); } }); + + const subscribers = await trx("user_organization") + .join("user", "user_organization.user_id", "user.id") + .where({ + organization_id: organizationId, + assignment_request_notifications: true + }) + .whereIn("role", ["ADMIN", "OWNER"]) + .pluck("email"); + await Promise.all( + subscribers.map((to) => + sendEmail({ + to, + subject: "Assignment request needs approval", + html: `A request for ${count} texts needs approval. Review request.` + }) + ) + ).catch((err) => + logger.error("Error sending assignment request email: ", err) + ); } if (config.AUTO_HANDLE_REQUESTS) {