From 1975ae535dd50e1c5f178fb2d81ae6051874eb16 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Fri, 10 Nov 2023 16:58:46 -0800 Subject: [PATCH 1/7] feat: affiliation events migration --- .../20231108161232_affiliation-events.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 src/migrations/tasks/20231108161232_affiliation-events.ts diff --git a/src/migrations/tasks/20231108161232_affiliation-events.ts b/src/migrations/tasks/20231108161232_affiliation-events.ts new file mode 100644 index 000000000..7c21b7482 --- /dev/null +++ b/src/migrations/tasks/20231108161232_affiliation-events.ts @@ -0,0 +1,32 @@ +import { makeDomainLogger } from "back-end/lib/logger"; +import { console as consoleAdapter } from "back-end/lib/logger/adapters"; +import Knex from "knex"; + +const logger = makeDomainLogger(consoleAdapter, "migrations"); + +export enum AffiliationEvent { + AdminStatusGranted = "ADMIN_STATUS_GRANTED", + AdminStatusRevoked = "ADMIN_STATUS_REVOKED" +} + +export async function up(connection: Knex): Promise { + await connection.schema.createTable("affiliationEvents", (table) => { + table.uuid("id").primary().unique().notNullable(); + table.timestamp("createdAt").notNullable(); + table.uuid("createdBy").references("id").inTable("users").notNullable(); + table + .uuid("affiliation") + .references("id") + .inTable("affiliations") + .notNullable() + .onDelete("CASCADE"); + table.enu("event", Object.values(AffiliationEvent)).notNullable(); + }); + + logger.info("Created affiliationEvents table."); +} + +export async function down(connection: Knex): Promise { + await connection.schema.dropTable("affiliationEvents"); + logger.info("Completed dropping affiliationEvents table."); +} From 4c9aae84d23c1a2315ae61be3dc7fef02175a2ad Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 15 Nov 2023 13:29:08 -0800 Subject: [PATCH 2/7] feat: add affiliation history types --- src/shared/lib/resources/affiliation.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/shared/lib/resources/affiliation.ts b/src/shared/lib/resources/affiliation.ts index 3813afd27..1b685c714 100644 --- a/src/shared/lib/resources/affiliation.ts +++ b/src/shared/lib/resources/affiliation.ts @@ -2,7 +2,7 @@ import { Organization, OrganizationSlim } from "shared/lib/resources/organization"; -import { User, usersHaveCapability } from "shared/lib/resources/user"; +import { User, UserSlim, usersHaveCapability } from "shared/lib/resources/user"; import { ADT, BodyWithErrors, Id } from "shared/lib/types"; import { ErrorTypeFrom } from "shared/lib/validation"; @@ -18,6 +18,18 @@ export enum MembershipStatus { Pending = "PENDING" } +export enum AffiliationEvent { + AdminStatusGranted = "ADMIN_STATUS_GRANTED", + AdminStatusRevoked = "ADMIN_STATUS_REVOKED" +} + +export interface AffiliationHistoryRecord { + createdAt: Date; + createdBy: UserSlim; + type: AffiliationEvent; + member: UserSlim; +} + export interface Affiliation { id: Id; createdAt: Date; From e3edbb5a5c21b8eb19789308b7ea10714190e1d0 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 15 Nov 2023 13:32:04 -0800 Subject: [PATCH 3/7] feat: record affiliation event on admin status update --- src/back-end/lib/db/affiliation.ts | 89 +++++++++++++++++------ src/back-end/lib/resources/affiliation.ts | 32 ++++++-- 2 files changed, 91 insertions(+), 30 deletions(-) diff --git a/src/back-end/lib/db/affiliation.ts b/src/back-end/lib/db/affiliation.ts index d031efbdf..64a32752b 100644 --- a/src/back-end/lib/db/affiliation.ts +++ b/src/back-end/lib/db/affiliation.ts @@ -8,12 +8,14 @@ import { readOneUser } from "back-end/lib/db/user"; import { valid } from "shared/lib/http"; import { Affiliation, + AffiliationEvent, + AffiliationHistoryRecord, AffiliationMember, AffiliationSlim, MembershipStatus, MembershipType } from "shared/lib/resources/affiliation"; -import { Session } from "shared/lib/resources/session"; +import { AuthenticatedSession, Session } from "shared/lib/resources/session"; import { User } from "shared/lib/resources/user"; import { Id } from "shared/lib/types"; import { getValidValue } from "shared/lib/validation"; @@ -32,6 +34,14 @@ interface RawAffiliation { updatedAt: Date; } +export interface RawHistoryRecord + extends Omit { + id: Id; + createdBy: Id; + event: AffiliationEvent; + affiliation: Id; +} + async function rawAffiliationToAffiliation( connection: Connection, params: RawAffiliation @@ -248,29 +258,64 @@ export const approveAffiliation = tryDb<[Id], Affiliation>( } ); -export const updateAdminStatus = tryDb<[Id, MembershipType], Affiliation>( - async (connection, id, membershipType: MembershipType) => { +export const updateAdminStatus = tryDb< + [Id, MembershipType, AuthenticatedSession], + Affiliation +>( + async ( + connection, + id, + membershipType: MembershipType, + session: AuthenticatedSession + ) => { const now = new Date(); - const [result] = await connection("affiliations") - .update( - { - membershipType, - updatedAt: now - } as RawAffiliation, - "*" - ) - .where({ - id - }) - .whereIn("organization", function () { - this.select("id").from("organizations").where({ - active: true + return await connection.transaction(async (trx) => { + const [affiliation] = await connection("affiliations") + .transacting(trx) + .update( + { + membershipType, + updatedAt: now + } as RawAffiliation, + "*" + ) + .where({ + id + }) + .whereIn("organization", function () { + this.select("id").from("organizations").where({ + active: true + }); }); - }); - if (!result) { - throw new Error("unable to update admin status"); - } - return valid(await rawAffiliationToAffiliation(connection, result)); + + if (!affiliation) { + throw new Error("unable to update admin status"); + } + + const [affiliationEvent] = await connection( + "affiliationEvents" + ) + .transacting(trx) + .insert( + { + id: generateUuid(), + affiliation: affiliation.id, + event: + membershipType === MembershipType.Admin + ? AffiliationEvent.AdminStatusGranted + : AffiliationEvent.AdminStatusRevoked, + createdAt: now, + createdBy: session.user.id + }, + "*" + ); + + if (!affiliationEvent) { + throw new Error("unable to create affiliation event"); + } + + return valid(await rawAffiliationToAffiliation(connection, affiliation)); + }); } ); diff --git a/src/back-end/lib/resources/affiliation.ts b/src/back-end/lib/resources/affiliation.ts index d6f95d3ed..1d8731841 100644 --- a/src/back-end/lib/resources/affiliation.ts +++ b/src/back-end/lib/resources/affiliation.ts @@ -31,7 +31,7 @@ import { memberIsOwner } from "shared/lib/resources/affiliation"; import { Organization } from "shared/lib/resources/organization"; -import { Session } from "shared/lib/resources/session"; +import { AuthenticatedSession, Session } from "shared/lib/resources/session"; import { UserStatus, UserType } from "shared/lib/resources/user"; import { ADT, adt, Id } from "shared/lib/types"; import { @@ -55,9 +55,10 @@ export interface ValidatedCreateRequestBody { export type UpdateRequestBody = SharedUpdateRequestBody | null; -type ValidatedUpdateRequestBody = - | ADT<"approve"> - | ADT<"updateAdminStatus", MembershipType>; +type ValidatedUpdateRequestBody = { + session: AuthenticatedSession; + body: ADT<"approve"> | ADT<"updateAdminStatus", MembershipType>; +}; type ValidatedDeleteRequestBody = Id; @@ -319,6 +320,13 @@ const update: crud.Update< if (!request.body) { return invalid({ affiliation: adt("parseFailure" as const) }); } + + if (!permissions.isSignedIn(request.session)) { + return invalid({ + permissions: [permissions.ERROR_MESSAGE] + }); + } + const validatedAffiliation = await validateAffiliationId( connection, request.params.id @@ -344,7 +352,10 @@ const update: crud.Update< permissions: [permissions.ERROR_MESSAGE] }); } - return valid(adt("approve" as const)); + return valid({ + session: request.session, + body: adt("approve" as const) + }); } return invalid({ affiliation: ["Membership is not pending."] @@ -383,7 +394,10 @@ const update: crud.Update< const membershipType = adminStatusToAffiliationMembershipType( request.body.value ); - return valid(adt("updateAdminStatus" as const, membershipType)); + return valid({ + session: request.session, + body: adt("updateAdminStatus" as const, membershipType) + }); } default: return invalid({ affiliation: adt("parseFailure" as const) }); @@ -391,9 +405,10 @@ const update: crud.Update< }, respond: wrapRespond({ valid: async (request) => { + const { body, session } = request.body; const id = request.params.id; let dbResult: Validation; - switch (request.body.tag) { + switch (body.tag) { case "approve": dbResult = await db.approveAffiliation(connection, id); if (isValid(dbResult)) { @@ -407,7 +422,8 @@ const update: crud.Update< dbResult = await db.updateAdminStatus( connection, id, - request.body.value + body.value, + session ); break; } From fe681e4b57e1e0823d4371ba3193e7ac2a5e3935 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 15 Nov 2023 13:34:06 -0800 Subject: [PATCH 4/7] feat: add organization history record type --- .../lib/pages/organization/lib/components/form.tsx | 1 + src/shared/lib/resources/organization.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/src/front-end/typescript/lib/pages/organization/lib/components/form.tsx b/src/front-end/typescript/lib/pages/organization/lib/components/form.tsx index ee55abeba..f7321f0fb 100644 --- a/src/front-end/typescript/lib/pages/organization/lib/components/form.tsx +++ b/src/front-end/typescript/lib/pages/organization/lib/components/form.tsx @@ -73,6 +73,7 @@ export interface Values | "deactivatedBy" | "serviceAreas" | "viewerIsOrgAdmin" + | "history" > { newLogoImage?: File; } diff --git a/src/shared/lib/resources/organization.ts b/src/shared/lib/resources/organization.ts index cf95f6dbf..d715098ae 100644 --- a/src/shared/lib/resources/organization.ts +++ b/src/shared/lib/resources/organization.ts @@ -9,6 +9,7 @@ import { } from "shared/lib/types"; import { ErrorTypeFrom } from "shared/lib/validation/index"; import { TWUServiceArea } from "shared/lib/resources/opportunity/team-with-us"; +import { AffiliationEvent } from "shared/lib/resources/affiliation"; export { ReadManyResponseValidationErrors } from "shared/lib/types"; @@ -25,6 +26,16 @@ export interface OrganizationAdmin { viewerIsOrgAdmin?: boolean; } +// Populate this as needed +export enum OrganizationEvent {} + +export interface OrganizationHistoryRecord { + createdAt: Date; + createdBy: UserSlim; + type: OrganizationEvent | AffiliationEvent; + member?: UserSlim; +} + export interface Organization extends OrganizationAdmin { id: Id; createdAt: Date; @@ -45,6 +56,7 @@ export interface Organization extends OrganizationAdmin { active: boolean; deactivatedOn?: Date; deactivatedBy?: Id; + history?: OrganizationHistoryRecord[]; } export interface OrganizationSlim extends OrganizationAdmin { From 20e024672f4598e143d5fff8674c2626bb9f8935 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 15 Nov 2023 13:36:43 -0800 Subject: [PATCH 5/7] feat: populate organization history --- src/back-end/lib/db/organization.ts | 93 ++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/src/back-end/lib/db/organization.ts b/src/back-end/lib/db/organization.ts index bce9e1534..b482f477c 100644 --- a/src/back-end/lib/db/organization.ts +++ b/src/back-end/lib/db/organization.ts @@ -2,7 +2,9 @@ import { generateUuid } from "back-end/lib"; import { Connection, tryDb } from "back-end/lib/db"; import { createAffiliation, - readManyAffiliationsForOrganization + readManyAffiliationsForOrganization, + RawHistoryRecord as RawAffiliationHistoryRecord, + readOneAffiliationById } from "back-end/lib/db/affiliation"; import { readOneFileById } from "back-end/lib/db/file"; import { RawUser, rawUserToUser, readOneUserSlim } from "back-end/lib/db/user"; @@ -17,13 +19,15 @@ import { import { FileRecord } from "shared/lib/resources/file"; import { Organization, + OrganizationEvent, + OrganizationHistoryRecord, OrganizationSlim, ReadManyResponseBody } from "shared/lib/resources/organization"; import { ServiceAreaId } from "shared/lib/resources/service-area"; import { Session } from "shared/lib/resources/session"; import { User } from "shared/lib/resources/user"; -import { Id } from "shared/lib/types"; +import { ADT, Id, adt } from "shared/lib/types"; import { getValidValue, isInvalid } from "shared/lib/validation"; export type CreateOrganizationParams = Partial< @@ -60,6 +64,18 @@ interface RawOrganizationSlim numTeamMembers?: string; } +interface RawHistoryRecord + extends Omit { + id: Id; + createdBy: Id; + event: OrganizationEvent; + organization: Id; +} + +type RawHistoryRecords = + | ADT<"organization", RawHistoryRecord> + | ADT<"affiliation", RawAffiliationHistoryRecord>; + async function rawOrganizationToOrganization( connection: Connection, raw: RawOrganization @@ -136,6 +152,52 @@ async function rawOrganizationSlimToOrganizationSlim( }; } +async function rawHistoryRecordToHistoryRecord( + connection: Connection, + raw: RawHistoryRecords +): Promise { + const errorMsg = "unable to process organization history record"; + const { tag, value } = raw; + + const createdBy = getValidValue( + await readOneUserSlim(connection, raw.value.createdBy), + null + ); + + if (!createdBy) { + throw new Error(errorMsg); + } + + // Get member information for affiliation events + if (tag === "affiliation") { + const { affiliation: affilationId, event: type, ...restOfRaw } = value; + + const affiliation = getValidValue( + await readOneAffiliationById(connection, affilationId), + null + ); + + const member = + affiliation && + getValidValue( + await readOneUserSlim(connection, affiliation.user.id), + null + ); + + if (!member) { + throw new Error(errorMsg); + } + + return { ...restOfRaw, type, member, createdBy }; + } + + return (({ createdAt, event: type }) => ({ + createdAt, + createdBy, + type + }))(value); +} + async function doesOrganizationMeetAllCapabilities( connection: Connection, organization: RawOrganization | RawOrganizationSlim @@ -281,6 +343,33 @@ export const readOneOrganization = tryDb< result ); result.possessOneServiceArea = result.serviceAreas.length > 0; + + const rawAffiliationHistory = + await connection("affiliationEvents") + .select("affiliationEvents.*") + .join( + "affiliations", + "affiliationEvents.affiliation", + "=", + "affiliations.id" + ) + .where({ "affiliations.organization": result.id }) + .orderBy("createdAt", "desc"); + + if (!rawAffiliationHistory) { + throw new Error("unable to read affiliation events"); + } + + // Merge affiliation and organization history records when they exist + result.history = await Promise.all( + rawAffiliationHistory.map( + async (raw) => + await rawHistoryRecordToHistoryRecord( + connection, + adt("affiliation", raw) + ) + ) + ); } } return valid( From 4bb6297bdf3fddf8a13c6004b6ee79904b8c4113 Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 15 Nov 2023 14:51:46 -0800 Subject: [PATCH 6/7] fix: hide history in cwu proposal test --- .../integration/resources/proposals/code-with-us.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/back-end/integration/resources/proposals/code-with-us.test.ts b/tests/back-end/integration/resources/proposals/code-with-us.test.ts index f684c8bc5..916975d0a 100644 --- a/tests/back-end/integration/resources/proposals/code-with-us.test.ts +++ b/tests/back-end/integration/resources/proposals/code-with-us.test.ts @@ -165,7 +165,8 @@ test("code-with-us proposal crud", async () => { "numTeamMembers", "owner", "possessAllCapabilities", - "possessOneServiceArea" + "possessOneServiceArea", + "history" ]), createdAt: expect.any(String), updatedAt: expect.any(String) From 8a528b4afac85a3ad07191db54177ed40d8f663c Mon Sep 17 00:00:00 2001 From: IanFonzie Date: Wed, 15 Nov 2023 14:52:36 -0800 Subject: [PATCH 7/7] fix: select all org slim properties --- .../integration/resources/proposals/sprint-with-us.test.ts | 3 ++- .../integration/resources/proposals/team-with-us.test.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/back-end/integration/resources/proposals/sprint-with-us.test.ts b/tests/back-end/integration/resources/proposals/sprint-with-us.test.ts index d09f22d66..e13ffeed6 100644 --- a/tests/back-end/integration/resources/proposals/sprint-with-us.test.ts +++ b/tests/back-end/integration/resources/proposals/sprint-with-us.test.ts @@ -123,7 +123,8 @@ test("sprint-with-us proposal crud", async () => { "id", "legalName", "logoImageFile", - "active" + "active", + "viewerIsOrgAdmin" ]) ); diff --git a/tests/back-end/integration/resources/proposals/team-with-us.test.ts b/tests/back-end/integration/resources/proposals/team-with-us.test.ts index 69ab065c6..06ad4107e 100644 --- a/tests/back-end/integration/resources/proposals/team-with-us.test.ts +++ b/tests/back-end/integration/resources/proposals/team-with-us.test.ts @@ -143,7 +143,8 @@ test("team-with-us proposal crud", async () => { "id", "legalName", "logoImageFile", - "active" + "active", + "viewerIsOrgAdmin" ]) );