Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat/organization changelog #433

Merged
merged 7 commits into from
Nov 16, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 67 additions & 22 deletions src/back-end/lib/db/affiliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -32,6 +34,14 @@ interface RawAffiliation {
updatedAt: Date;
}

export interface RawHistoryRecord
extends Omit<AffiliationHistoryRecord, "createdBy" | "member" | "type"> {
id: Id;
createdBy: Id;
event: AffiliationEvent;
affiliation: Id;
}

async function rawAffiliationToAffiliation(
connection: Connection,
params: RawAffiliation
Expand Down Expand Up @@ -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<RawAffiliation>("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<RawAffiliation>("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<RawHistoryRecord>(
"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));
});
}
);

Expand Down
93 changes: 91 additions & 2 deletions src/back-end/lib/db/organization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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<
Expand Down Expand Up @@ -60,6 +64,18 @@ interface RawOrganizationSlim
numTeamMembers?: string;
}

interface RawHistoryRecord
extends Omit<OrganizationHistoryRecord, "createdBy" | "type" | "member"> {
id: Id;
createdBy: Id;
event: OrganizationEvent;
organization: Id;
}

type RawHistoryRecords =
| ADT<"organization", RawHistoryRecord>
| ADT<"affiliation", RawAffiliationHistoryRecord>;

async function rawOrganizationToOrganization(
connection: Connection,
raw: RawOrganization
Expand Down Expand Up @@ -136,6 +152,52 @@ async function rawOrganizationSlimToOrganizationSlim(
};
}

async function rawHistoryRecordToHistoryRecord(
connection: Connection,
raw: RawHistoryRecords
): Promise<OrganizationHistoryRecord> {
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
Expand Down Expand Up @@ -281,6 +343,33 @@ export const readOneOrganization = tryDb<
result
);
result.possessOneServiceArea = result.serviceAreas.length > 0;

const rawAffiliationHistory =
await connection<RawAffiliationHistoryRecord>("affiliationEvents")
.select<RawAffiliationHistoryRecord[]>("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(
Expand Down
32 changes: 24 additions & 8 deletions src/back-end/lib/resources/affiliation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;

Expand Down Expand Up @@ -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
Expand All @@ -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."]
Expand Down Expand Up @@ -383,17 +394,21 @@ 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) });
}
},
respond: wrapRespond({
valid: async (request) => {
const { body, session } = request.body;
const id = request.params.id;
let dbResult: Validation<Affiliation, null>;
switch (request.body.tag) {
switch (body.tag) {
case "approve":
dbResult = await db.approveAffiliation(connection, id);
if (isValid(dbResult)) {
Expand All @@ -407,7 +422,8 @@ const update: crud.Update<
dbResult = await db.updateAdminStatus(
connection,
id,
request.body.value
body.value,
session
);
break;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ export interface Values
| "deactivatedBy"
| "serviceAreas"
| "viewerIsOrgAdmin"
| "history"
> {
newLogoImage?: File;
}
Expand Down
32 changes: 32 additions & 0 deletions src/migrations/tasks/20231108161232_affiliation-events.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await connection.schema.dropTable("affiliationEvents");
logger.info("Completed dropping affiliationEvents table.");
}
Loading