diff --git a/README.md b/README.md index e59af80..7f7c515 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Google Groups offers a convient way to have mailing and distribution lists for t - Individual control over whether to sync parents and/or students - Automatic creation of missing Google Groups (including auto naming and setting of default permissions) - Automatic creation of missing student Google Accounts +- Automatic removal of past parents from Google Groups - Automatic updating of student emails in Blackbaud (matches a students email in Blackbaud with their email found in the Google domain) - Sync job reports via email - Built to be hosted using Azure Functions (free plan) @@ -136,6 +137,7 @@ _Note: The settings in bold are required. Refer to their corresponding configura | **BLACKBAUD_SUBSCRIPTION_KEY** | String | | The primary access key of your Blackbaud SKY API subscription | | BLACKBAUD_STUDENT_ROLE | String | Student | The Blackbaud role used when searching for students to sync to Google Groups | | BLACKBAUD_PARENT_ROLE | String | Parent | The Blackbaud role used when searching for parents to sync to Google Groups | +| BLACKBAUD_PAST_PARENT_ROLE | String | Parent of Past Student | An additional Blackbaud role used when searching for parents to remove from Google Groups | | **GOOGLE_DOMAIN** | String | | The FQDN of your Google Workspace organization | | GOOGLE_ACCOUNT_CREATION_PASSWORD | String | TEMP_PASSWORD_CHANGE_THIS! | The default password used when creating Google accounts | | GOOGLE_ACCOUNT_CREATION_ORG_UNIT_PATH | String | /Students | The default Organizational Unit (OU) path to search for OU's following the GOOGLE_STUDENT_GROUP_NAME naming convention. By default, accounts are put into this OU if a sub OU with the correct name cannot be found | diff --git a/azuredeploy.json b/azuredeploy.json index b4054b4..8a3d1ce 100644 --- a/azuredeploy.json +++ b/azuredeploy.json @@ -4,8 +4,8 @@ "metadata": { "_generator": { "name": "bicep", - "version": "0.17.1.54307", - "templateHash": "12708229036174630440" + "version": "0.21.1.54444", + "templateHash": "4048911328218616693" } }, "parameters": { @@ -80,6 +80,7 @@ "BLACKBAUD_SUBSCRIPTION_KEY": "MUST_CHANGE_THIS", "BLACKBAUD_STUDENT_ROLE": "Student", "BLACKBAUD_PARENT_ROLE": "Parent", + "BLACKBAUD_PAST_PARENT_ROLE": "Parent of Past Student", "GOOGLE_DOMAIN": "MUST_CHANGE_THIS", "GOOGLE_ACCOUNT_CREATION_PASSWORD": "TEMP_PASSWORD_CHANGE_THIS!", "GOOGLE_ACCOUNT_CREATION_ORG_UNIT_PATH": "/Students", diff --git a/default.settings.json b/default.settings.json index 1b74f3c..7bdd6cd 100644 --- a/default.settings.json +++ b/default.settings.json @@ -17,6 +17,7 @@ "BLACKBAUD_SUBSCRIPTION_KEY": "MUST_CHANGE_THIS", "BLACKBAUD_STUDENT_ROLE": "Student", "BLACKBAUD_PARENT_ROLE": "Parent", + "BLACKBAUD_PAST_PARENT_ROLE": "Parent of Past Student", "GOOGLE_DOMAIN": "MUST_CHANGE_THIS", "GOOGLE_ACCOUNT_CREATION_PASSWORD": "TEMP_PASSWORD_CHANGE_THIS!", "GOOGLE_ACCOUNT_CREATION_ORG_UNIT_PATH": "/Students", diff --git a/package-lock.json b/package-lock.json index 04040d8..b638032 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "bg-group-sync", - "version": "1.0.1", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/package.json b/package.json index 43aac89..793650d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "bg-group-sync", - "version": "1.0.1", + "version": "1.1.0", "description": "An automated tool to sync parents and students in Blackbaud to Google Groups", "main": "dist/functions/**/*.js", "scripts": { diff --git a/src/environment.ts b/src/environment.ts index 349e8c9..68ee5d2 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -24,7 +24,8 @@ export default { }, sync: { studentRole: process.env.BLACKBAUD_STUDENT_ROLE || "Student", - parentRole: process.env.BLACKBAUD_PARENT_ROLE || "Parent" + parentRole: process.env.BLACKBAUD_PARENT_ROLE || "Parent", + pastParentRole: process.env.BLACKBAUD_PAST_PARENT_ROLE || "Parent of Past Student" } }, google: { diff --git a/src/functions/blackbaud/blackbaudGetUsers.ts b/src/functions/blackbaud/blackbaudGetUsers.ts index a16a8be..a218a1b 100644 --- a/src/functions/blackbaud/blackbaudGetUsers.ts +++ b/src/functions/blackbaud/blackbaudGetUsers.ts @@ -32,7 +32,7 @@ export async function blackbaudGetUsers( const roles = (await blackbaud.getRoles()).value; - const userRole = roles.find(role => role.name == userRoleName); + const userRole = roles.find(role => role.name === userRoleName); if (!userRole) { logger.log(Severity.Error, `Unable to find role of name ${userRoleName}`); diff --git a/src/functions/google/googleFindGroups.ts b/src/functions/google/googleFindGroups.ts new file mode 100644 index 0000000..6a49ec1 --- /dev/null +++ b/src/functions/google/googleFindGroups.ts @@ -0,0 +1,43 @@ +import * as df from "durable-functions"; +import { InvocationContext } from "@azure/functions"; +import { admin_directory_v1 as adminDirectoryV1 } from "googleapis"; + +import { Logger, Severity } from "../../lib/Logger"; +import { GoogleAPI } from "../../lib/Google"; + +import environment from "../../environment"; + +export const FUNCTION_NAME = "googleFindGroups"; + +/** + * Finds Google Groups using the specified query + * @param params A {@link adminDirectoryV1.Params$Resource$Groups$List} object with query options + * @param context The invocation context for the function + * @returns A list of {@link adminDirectoryV1.Schema$Group} groups if found, otherwise undefined + */ +export async function googleFindGroups( + params: adminDirectoryV1.Params$Resource$Groups$List, + context: InvocationContext +): Promise { + const logger = new Logger(context, "Google"); + + const google = new GoogleAPI(); + + try { + const groupList = await google.listGroups({ + domain: environment.google.domain, + ...params + }); + + return groupList.groups; + } catch (err) { + logger.log(Severity.Error, err, "\nInput Parameters:", params); + + throw err; + } +} + +df.app.activity(FUNCTION_NAME, { + extraInputs: [df.input.durableClient()], + handler: googleFindGroups +}); diff --git a/src/functions/google/googleRemoveMemberFromGroup.ts b/src/functions/google/googleRemoveMemberFromGroup.ts new file mode 100644 index 0000000..4defabb --- /dev/null +++ b/src/functions/google/googleRemoveMemberFromGroup.ts @@ -0,0 +1,52 @@ +import * as df from "durable-functions"; +import { InvocationContext } from "@azure/functions"; + +import { Logger, Severity } from "../../lib/Logger"; +import { GoogleAPI } from "../../lib/Google"; + +export const FUNCTION_NAME = "googleRemoveMemberFromGroup"; + +/** + * Outlines the parameters needed for {@link googleRemoveMemberFromGroup} + */ +export interface GoogleRemoveMemberFromGroupParams { + /** The ID of the Google Group */ + groupId: string; + /** The email of the user to remove */ + email: string; +} + +/** + * Removes a member from the specified Google Group + * @param params A {@link GoogleRemoveMemberFromGroupParams} object containing the group ID and email of the member to remove + * @param context The invocation context for the function + * @returns True if successful + */ +export async function googleRemoveMemberFromGroup( + params: GoogleRemoveMemberFromGroupParams, + context: InvocationContext +): Promise { + const logger = new Logger(context, "Google"); + + const google = new GoogleAPI(); + + try { + await google.removeMemberFromGroup({ + groupKey: params.groupId, + memberKey: params.email + }); + + return true; + } catch (err) { + if (err?.includes("Resource Not Found: memberKey")) return true; + + logger.log(Severity.Error, err, "\nInput Parameters:", params); + + throw err; + } +} + +df.app.activity(FUNCTION_NAME, { + extraInputs: [df.input.durableClient()], + handler: googleRemoveMemberFromGroup +}); diff --git a/src/functions/orchestrators/orchestrator.ts b/src/functions/orchestrators/orchestrator.ts index 1fb6f89..2c11e79 100644 --- a/src/functions/orchestrators/orchestrator.ts +++ b/src/functions/orchestrators/orchestrator.ts @@ -43,6 +43,13 @@ export function* syncOrchestrationHandler(context: df.OrchestrationContext) { processor: processParent }) ); + + tasks.push( + context.df.callSubOrchestrator(syncUsers, { + blackbaudRole: environment.blackbaud.sync.pastParentRole, + processor: processParent + }) + ); } if (tasks.length > 0) { diff --git a/src/functions/orchestrators/processParent.ts b/src/functions/orchestrators/processParent.ts index 79ebd72..e856566 100644 --- a/src/functions/orchestrators/processParent.ts +++ b/src/functions/orchestrators/processParent.ts @@ -8,6 +8,8 @@ import { FUNCTION_NAME as blackbaudGetUser } from "../blackbaud/blackbaudGetUser import { FUNCTION_NAME as googleFindGroup } from "../google/googleFindGroup"; import { FUNCTION_NAME as googleCreateGroup } from "../google/googleCreateGroup"; import { FUNCTION_NAME as googleAddMemberToGroup } from "../google/googleAddMemberToGroup"; +import { FUNCTION_NAME as googleRemoveMemberFromGroup } from "../google/googleRemoveMemberFromGroup"; +import { FUNCTION_NAME as googleFindGroups } from "../google/googleFindGroups"; import { genGroupEmail } from "../../utils"; @@ -60,12 +62,56 @@ export function* processParentHandler( role => role.name === environment.blackbaud.sync.studentRole ); - if (roles.length > 0) { + if (roles.length < 1) continue; + + if (user.student_info.grad_year === null) { + logger.forceLog( + Severity.Warning, + `Graduation year missing for student ${user.first_name} ${user.last_name} with parent ${parent.first_name} ${parent.last_name}` + ); + } else { gradYears.add(user.student_info.grad_year); } } } + const groups: adminDirectoryV1.Schema$Group[] = yield context.df.callActivity( + googleFindGroups, + { + query: `name:${environment.google.parentGroupName.trim().replaceAll(" ", "+")}*` + } + ); + + const gradYearsArray = Array.from(gradYears); + + const removalTasks = []; + + for (const group of groups || []) { + if (gradYearsArray.some(year => group.name.includes(year))) continue; + + removalTasks.push( + context.df.callActivity(googleRemoveMemberFromGroup, { + groupId: group.id, + email: parent.email + }) + ); + } + + if (removalTasks.length > 0) { + try { + yield context.df.Task.all(removalTasks); + } catch (err) { + logger.log( + Severity.Warning, + `Unable to remove parent ${parent.email} from groups. Continuing with sync...` + ); + } + } + + if (!parent.roles.some(role => role.name === environment.blackbaud.sync.parentRole)) { + return { status: "success" }; + } + for (const gradYear of gradYears) { const parentGroupName = genGroupEmail( environment.google.parentGroupEmailPrefix, diff --git a/src/functions/orchestrators/syncUsers.ts b/src/functions/orchestrators/syncUsers.ts index e45e7d8..4dd53d6 100644 --- a/src/functions/orchestrators/syncUsers.ts +++ b/src/functions/orchestrators/syncUsers.ts @@ -56,10 +56,10 @@ export function* syncUsersHandler( const params: syncUsersHandlerParams = context.df.getInput(); - const roleName = params.blackbaudRole.toLowerCase(); + const roleName = params.blackbaudRole; try { - const users = yield context.df.callActivity(blackbaudGetUsers, params.blackbaudRole); + const users: any[] = yield context.df.callActivity(blackbaudGetUsers, params.blackbaudRole); if (users.length < 1) { logger.log(Severity.Warning, `No ${roleName}s found. Skipping sync to Google Groups`); @@ -80,7 +80,11 @@ export function* syncUsersHandler( logger.log(Severity.Info, `Syncing ${tasks.length} ${roleName}s with Google Groups...`); - const results: ProcessResults[] = yield context.df.Task.all(tasks); + let results: ProcessResults[] = []; + + if (tasks.length > 0) { + results = yield context.df.Task.all(tasks); + } let succeeded = 0; diff --git a/src/lib/Google.ts b/src/lib/Google.ts index 4e91829..eea65b2 100644 --- a/src/lib/Google.ts +++ b/src/lib/Google.ts @@ -201,6 +201,25 @@ export class GoogleAPI { } } + /** + * Removes a member from the specified group + * @param params The parameters containing the group ID and member to remove + * @param options Additional options + * @returns A message containing the status of the operation + */ + async removeMemberFromGroup( + params: adminDirectoryV1.Params$Resource$Members$Delete, + options?: MethodOptions + ): Promise { + try { + const res = await this.directory.members.delete(params, options); + + return res.data; + } catch (err) { + return this.apiErrorHandler(err, this.removeMemberFromGroup, params, options); + } + } + /** * Creates a new user * @param params The parameters containing the user's name, email, and password diff --git a/tsconfig.json b/tsconfig.json index 7068b5c..11945ab 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "outDir": "dist", "rootDir": "src", "sourceMap": true, - "strict": false + "strict": false, + "lib": ["es2021"] } }