diff --git a/apps/unified-database-service/src/airtable/airtable.processor.ts b/apps/unified-database-service/src/airtable/airtable.processor.ts index 75994844..909de9df 100644 --- a/apps/unified-database-service/src/airtable/airtable.processor.ts +++ b/apps/unified-database-service/src/airtable/airtable.processor.ts @@ -7,16 +7,15 @@ import Airtable from "airtable"; import { ApplicationEntity, DemographicEntity, + DemographicEntryEntity, NurseryEntity, NurseryReportEntity, OrganisationEntity, ProjectEntity, ProjectReportEntity, - RestorationPartnerEntity, SiteEntity, SiteReportEntity, - TreeSpeciesEntity, - WorkdayEntity + TreeSpeciesEntity } from "./entities"; import * as Sentry from "@sentry/node"; import { SlackService } from "nestjs-slack"; @@ -24,16 +23,15 @@ import { SlackService } from "nestjs-slack"; export const AIRTABLE_ENTITIES = { application: ApplicationEntity, demographic: DemographicEntity, + "demographic-entry": DemographicEntryEntity, nursery: NurseryEntity, "nursery-report": NurseryReportEntity, organisation: OrganisationEntity, project: ProjectEntity, "project-report": ProjectReportEntity, - "restoration-partner": RestorationPartnerEntity, site: SiteEntity, "site-report": SiteReportEntity, - "tree-species": TreeSpeciesEntity, - workday: WorkdayEntity + "tree-species": TreeSpeciesEntity }; export type EntityType = keyof typeof AIRTABLE_ENTITIES; diff --git a/apps/unified-database-service/src/airtable/entities/airtable-entity.spec.ts b/apps/unified-database-service/src/airtable/entities/airtable-entity.spec.ts index a16b0e6d..7ec39682 100644 --- a/apps/unified-database-service/src/airtable/entities/airtable-entity.spec.ts +++ b/apps/unified-database-service/src/airtable/entities/airtable-entity.spec.ts @@ -2,21 +2,21 @@ import { airtableColumnName, AirtableEntity, ColumnMapping } from "./airtable-en import { faker } from "@faker-js/faker"; import { Application, - Demographic, + DemographicEntry, + Framework, Nursery, NurseryReport, Organisation, Project, ProjectReport, - RestorationPartner, Site, SiteReport, TreeSpecies, - Workday + Demographic } from "@terramatch-microservices/database/entities"; import { ApplicationFactory, - DemographicFactory, + DemographicEntryFactory, FormSubmissionFactory, FundingProgrammeFactory, NurseryFactory, @@ -24,32 +24,30 @@ import { OrganisationFactory, ProjectFactory, ProjectReportFactory, - RestorationPartnerFactory, SeedingFactory, SiteFactory, SitePolygonFactory, SiteReportFactory, TreeSpeciesFactory, - WorkdayFactory + DemographicFactory } from "@terramatch-microservices/database/factories"; import Airtable from "airtable"; import { ApplicationEntity, DemographicEntity, + DemographicEntryEntity, NurseryEntity, NurseryReportEntity, OrganisationEntity, ProjectEntity, ProjectReportEntity, - RestorationPartnerEntity, SiteEntity, SiteReportEntity, - TreeSpeciesEntity, - WorkdayEntity + TreeSpeciesEntity } from "./"; import { orderBy, sortBy } from "lodash"; import { Model } from "sequelize-typescript"; -import { FRAMEWORK_NAMES, FrameworkKey } from "@terramatch-microservices/database/constants/framework"; +import { FrameworkKey } from "@terramatch-microservices/database/constants/framework"; import { FindOptions, Op } from "sequelize"; import { DateTime } from "luxon"; @@ -277,14 +275,86 @@ describe("AirtableEntity", () => { await Demographic.truncate(); associationUuids = {}; - const workday = await WorkdayFactory.forProjectReport.create(); - associationUuids[Workday.LARAVEL_TYPE] = workday.uuid; - const partner = await RestorationPartnerFactory.forProjectReport.create(); - associationUuids[RestorationPartner.LARAVEL_TYPE] = partner.uuid; + const projectReport = await ProjectReportFactory.create(); + associationUuids[ProjectReport.LARAVEL_TYPE] = projectReport.uuid; + const siteReport = await SiteReportFactory.create(); + associationUuids[SiteReport.LARAVEL_TYPE] = siteReport.uuid; + + const factories = [ + () => DemographicFactory.forProjectReportWorkday.create({ demographicalId: projectReport.id }), + () => DemographicFactory.forSiteReportWorkday.create({ demographicalId: siteReport.id }), + () => DemographicFactory.forProjectReportRestorationPartner.create({ demographicalId: projectReport.id }) + ]; + + const allDemographics: Demographic[] = []; + for (const factory of factories) { + // make sure we have at least one of each type + allDemographics.push(await factory()); + } + for (let ii = 0; ii < 35; ii++) { + // create a whole bunch mor at random + allDemographics.push(await faker.helpers.arrayElement(factories)()); + } + const toDeleteOrHide = faker.helpers.uniqueArray(() => faker.number.int(allDemographics.length - 1), 10); + let hide = true; + for (const ii of toDeleteOrHide) { + if (hide) { + await allDemographics[ii].update({ hidden: true }); + } else { + await allDemographics[ii].destroy(); + } + hide = !hide; + } + + // create one with a bogus association type for testing + allDemographics.push( + await DemographicFactory.forProjectReportWorkday.create({ demographicalType: "foo", demographicalId: 1 }) + ); + allDemographics.push(await DemographicFactory.forSiteReportWorkday.create({ demographicalId: 0 })); + + demographics = allDemographics.filter(workday => !workday.isSoftDeleted() && workday.hidden === false); + }); + + it("sends all records to airtable", async () => { + await testAirtableUpdates( + new DemographicEntity(), + demographics, + ({ uuid, collection, demographicalType, demographicalId }) => ({ + fields: { + uuid, + collection, + projectReportUuid: + demographicalType === ProjectReport.LARAVEL_TYPE ? associationUuids[demographicalType] : undefined, + siteReportUuid: + demographicalType === SiteReport.LARAVEL_TYPE && demographicalId > 0 + ? associationUuids[demographicalType] + : undefined + } + }) + ); + }); + }); + + describe("DemographicEntryEntity", () => { + let demographicUuids: Record; + let entries: DemographicEntry[]; + + beforeAll(async () => { + await DemographicEntry.truncate(); + + const projectWorkday = await DemographicFactory.forProjectReportWorkday.create(); + const siteWorkday = await DemographicFactory.forSiteReportWorkday.create(); + const projectPartner = await DemographicFactory.forProjectReportRestorationPartner.create(); + demographicUuids = { + [projectWorkday.id]: projectWorkday.uuid, + [siteWorkday.id]: siteWorkday.uuid, + [projectPartner.id]: projectPartner.uuid + }; const factories = [ - () => DemographicFactory.forWorkday.create({ demographicalId: workday.id }), - () => DemographicFactory.forRestorationPartner.create({ demographicalId: partner.id }) + () => DemographicEntryFactory.create({ demographicId: projectWorkday.id }), + () => DemographicEntryFactory.create({ demographicId: siteWorkday.id }), + () => DemographicEntryFactory.create({ demographicId: projectPartner.id }) ]; const allDemographics = []; @@ -300,23 +370,21 @@ describe("AirtableEntity", () => { await allDemographics[ii].destroy(); } - demographics = allDemographics.filter(demographic => !demographic.isSoftDeleted()); + entries = allDemographics.filter(demographic => !demographic.isSoftDeleted()); }); it("sends all records to airtable", async () => { await testAirtableUpdates( - new DemographicEntity(), - demographics, - ({ id, type, subtype, name, amount, demographicalType }) => ({ + new DemographicEntryEntity(), + entries, + ({ id, type, subtype, name, amount, demographicId }) => ({ fields: { id, type, subtype, name, amount, - workdayUuid: demographicalType === Workday.LARAVEL_TYPE ? associationUuids[demographicalType] : undefined, - restorationPartnerUuid: - demographicalType === RestorationPartner.LARAVEL_TYPE ? associationUuids[demographicalType] : undefined + demographicUuid: demographicUuids[demographicId] } }) ); @@ -417,9 +485,21 @@ describe("AirtableEntity", () => { let projects: Project[]; let calculatedValues: Record>; + const FRAMEWORK_NAMES = { + ppc: "PPC", + terrafund: "TerraFund Top 100" + }; + beforeAll(async () => { await Project.truncate(); + for (const framework of await Framework.findAll()) { + await framework.destroy(); + } + for (const [slug, name] of Object.entries(FRAMEWORK_NAMES)) { + await Framework.create({ uuid: faker.string.uuid(), slug, name }); + } + const orgs = await OrganisationFactory.createMany(3); organisationUuids = orgs.reduce((uuids, { id, uuid }) => ({ ...uuids, [id]: uuid }), {}); const orgIds = orgs.reduce((ids, { id }) => [...ids, id], [] as number[]); @@ -444,6 +524,9 @@ describe("AirtableEntity", () => { }) ); + // make sure test projects are not included + await ProjectFactory.create({ organisationId: orgId(), isTest: true }); + projects = allProjects.filter(project => !project.isSoftDeleted()); applicationUuids = ( await Application.findAll({ @@ -487,7 +570,7 @@ describe("AirtableEntity", () => { fields: { uuid, name, - cohort: FRAMEWORK_NAMES[frameworkKey] ?? frameworkKey, + framework: FRAMEWORK_NAMES[frameworkKey] ?? frameworkKey, organisationUuid: organisationUuids[organisationId], applicationUuid: applicationUuids[applicationId], hectaresRestoredToDate: calculatedValues[uuid]?.hectaresRestoredToDate ?? 0 @@ -573,66 +656,6 @@ describe("AirtableEntity", () => { }); }); - describe("RestorationPartnerEntity", () => { - let associationUuids: Record; - let partners: RestorationPartner[]; - - beforeAll(async () => { - await RestorationPartner.truncate(); - - associationUuids = {}; - const projectReport = await ProjectReportFactory.create(); - associationUuids[ProjectReport.LARAVEL_TYPE] = projectReport.uuid; - - const factories = [() => RestorationPartnerFactory.forProjectReport.create({ partnerableId: projectReport.id })]; - - const allPartners: RestorationPartner[] = []; - for (const factory of factories) { - // make sure we have at least one of each type - allPartners.push(await factory()); - } - for (let ii = 0; ii < 35; ii++) { - // create a whole bunch mor at random - allPartners.push(await faker.helpers.arrayElement(factories)()); - } - const toDeleteOrHide = faker.helpers.uniqueArray(() => faker.number.int(allPartners.length - 1), 10); - let hide = true; - for (const ii of toDeleteOrHide) { - if (hide) { - await allPartners[ii].update({ hidden: true }); - } else { - await allPartners[ii].destroy(); - } - hide = !hide; - } - - // create one with a bogus association type for testing - allPartners.push( - await RestorationPartnerFactory.forProjectReport.create({ partnerableType: "foo", partnerableId: 1 }) - ); - allPartners.push(await RestorationPartnerFactory.forProjectReport.create({ partnerableId: 0 })); - - partners = allPartners.filter(partner => !partner.isSoftDeleted() && partner.hidden === false); - }); - - it("sends all records to airtable", async () => { - await testAirtableUpdates( - new RestorationPartnerEntity(), - partners, - ({ uuid, collection, partnerableType, partnerableId }) => ({ - fields: { - uuid, - collection, - projectReportUuid: - partnerableType === ProjectReport.LARAVEL_TYPE && partnerableId > 0 - ? associationUuids[partnerableType] - : undefined - } - }) - ); - }); - }); - describe("SiteEntity", () => { let projectUuids: Record; let sites: Site[]; @@ -798,69 +821,4 @@ describe("AirtableEntity", () => { expect(result.where[Op.or]?.[Op.and]?.updatedAt?.[Op.gte]).toBe(deletedSince); }); }); - - describe("WorkdayEntity", () => { - let associationUuids: Record; - let workdays: Workday[]; - - beforeAll(async () => { - await Workday.truncate(); - - associationUuids = {}; - const projectReport = await ProjectReportFactory.create(); - associationUuids[ProjectReport.LARAVEL_TYPE] = projectReport.uuid; - const siteReport = await SiteReportFactory.create(); - associationUuids[SiteReport.LARAVEL_TYPE] = siteReport.uuid; - - const factories = [ - () => WorkdayFactory.forProjectReport.create({ workdayableId: projectReport.id }), - () => WorkdayFactory.forSiteReport.create({ workdayableId: siteReport.id }) - ]; - - const allWorkdays: Workday[] = []; - for (const factory of factories) { - // make sure we have at least one of each type - allWorkdays.push(await factory()); - } - for (let ii = 0; ii < 35; ii++) { - // create a whole bunch mor at random - allWorkdays.push(await faker.helpers.arrayElement(factories)()); - } - const toDeleteOrHide = faker.helpers.uniqueArray(() => faker.number.int(allWorkdays.length - 1), 10); - let hide = true; - for (const ii of toDeleteOrHide) { - if (hide) { - await allWorkdays[ii].update({ hidden: true }); - } else { - await allWorkdays[ii].destroy(); - } - hide = !hide; - } - - // create one with a bogus assocation type for testing - allWorkdays.push(await WorkdayFactory.forProjectReport.create({ workdayableType: "foo", workdayableId: 1 })); - allWorkdays.push(await WorkdayFactory.forSiteReport.create({ workdayableId: 0 })); - - workdays = allWorkdays.filter(workday => !workday.isSoftDeleted() && workday.hidden === false); - }); - - it("sends all records to airtable", async () => { - await testAirtableUpdates( - new WorkdayEntity(), - workdays, - ({ uuid, collection, workdayableType, workdayableId }) => ({ - fields: { - uuid, - collection, - projectReportUuid: - workdayableType === ProjectReport.LARAVEL_TYPE ? associationUuids[workdayableType] : undefined, - siteReportUuid: - workdayableType === SiteReport.LARAVEL_TYPE && workdayableId > 0 - ? associationUuids[workdayableType] - : undefined - } - }) - ); - }); - }); }); diff --git a/apps/unified-database-service/src/airtable/entities/airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/airtable-entity.ts index 19392179..c73a11f4 100644 --- a/apps/unified-database-service/src/airtable/entities/airtable-entity.ts +++ b/apps/unified-database-service/src/airtable/entities/airtable-entity.ts @@ -1,5 +1,5 @@ import { Model, ModelCtor, ModelType } from "sequelize-typescript"; -import { cloneDeep, flatten, groupBy, isObject, uniq } from "lodash"; +import { cloneDeep, flatten, groupBy, isEmpty, isObject, uniq } from "lodash"; import { Attributes, FindOptions, Op, WhereOptions } from "sequelize"; import { TMLogService } from "@terramatch-microservices/common/util/tm-log.service"; import { LoggerService } from "@nestjs/common"; @@ -16,7 +16,7 @@ export abstract class AirtableEntity, Associa abstract readonly MODEL: ModelCtor; readonly IDENTITY_COLUMN: string = "uuid"; readonly SUPPORTS_UPDATED_SINCE: boolean = true; - readonly HAS_HIDDEN_FLAG: boolean = false; + readonly FILTER_FLAGS: string[] = []; protected readonly logger: LoggerService = new TMLogService(AirtableEntity.name); @@ -69,13 +69,16 @@ export abstract class AirtableEntity, Associa if (this.SUPPORTS_UPDATED_SINCE && updatedSince != null) { where["updatedAt"] = { [Op.gte]: updatedSince }; } - if (this.HAS_HIDDEN_FLAG) { - where["hidden"] = false; + if (!isEmpty(this.FILTER_FLAGS)) { + for (const flag of this.FILTER_FLAGS) { + where[flag] = false; + } } return { attributes: selectAttributes(this.COLUMNS), include: selectIncludes(this.COLUMNS), + order: ["id"], limit: AIRTABLE_PAGE_SIZE, offset: page * AIRTABLE_PAGE_SIZE, where @@ -86,25 +89,26 @@ export abstract class AirtableEntity, Associa const where = {} as WhereOptions; const deletedAtCondition = { [Op.gte]: deletedSince }; - if (this.HAS_HIDDEN_FLAG) { + if (isEmpty(this.FILTER_FLAGS)) { + where["deletedAt"] = deletedAtCondition; + } else { where[Op.or] = { deletedAt: deletedAtCondition, // include records that have been hidden since the timestamp as well [Op.and]: { updatedAt: { ...deletedAtCondition }, - hidden: true + [Op.or]: this.FILTER_FLAGS.reduce((flags, flag) => ({ ...flags, [flag]: true }), {}) } }; - } else { - where["deletedAt"] = deletedAtCondition; } return { attributes: [this.IDENTITY_COLUMN], paranoid: false, - where, + order: ["id"], limit: AIRTABLE_PAGE_SIZE, - offset: page * AIRTABLE_PAGE_SIZE + offset: page * AIRTABLE_PAGE_SIZE, + where } as FindOptions; } diff --git a/apps/unified-database-service/src/airtable/entities/demographic-entry.airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/demographic-entry.airtable-entity.ts new file mode 100644 index 00000000..00268763 --- /dev/null +++ b/apps/unified-database-service/src/airtable/entities/demographic-entry.airtable-entity.ts @@ -0,0 +1,39 @@ +import { Demographic, DemographicEntry } from "@terramatch-microservices/database/entities"; +import { AirtableEntity, associatedValueColumn, ColumnMapping } from "./airtable-entity"; +import { uniq } from "lodash"; + +type DemographicEntryAssociations = { + demographicUuid?: string; +}; + +const COLUMNS: ColumnMapping[] = [ + "id", + "type", + "subtype", + "name", + "amount", + associatedValueColumn("demographicUuid", "demographicId") +]; + +export class DemographicEntryEntity extends AirtableEntity { + readonly TABLE_NAME = "Demographic Entries"; + readonly COLUMNS = COLUMNS; + readonly MODEL = DemographicEntry; + readonly IDENTITY_COLUMN = "id"; + + protected async loadAssociations(entries: DemographicEntry[]) { + const demographicIds = uniq(entries.map(({ demographicId }) => demographicId)); + const demographics = await Demographic.findAll({ + where: { id: demographicIds }, + attributes: ["id", "uuid"] + }); + + return entries.reduce( + (associations, { id, demographicId }) => ({ + ...associations, + [id]: { demographicUuid: demographics.find(({ id }) => id === demographicId)?.uuid } + }), + {} as Record + ); + } +} diff --git a/apps/unified-database-service/src/airtable/entities/demographic.airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/demographic.airtable-entity.ts index 94108366..ea32318c 100644 --- a/apps/unified-database-service/src/airtable/entities/demographic.airtable-entity.ts +++ b/apps/unified-database-service/src/airtable/entities/demographic.airtable-entity.ts @@ -1,37 +1,36 @@ -import { Demographic, RestorationPartner, Workday } from "@terramatch-microservices/database/entities"; import { AirtableEntity, associatedValueColumn, ColumnMapping, PolymorphicUuidAssociation } from "./airtable-entity"; +import { ProjectReport, SiteReport, Demographic } from "@terramatch-microservices/database/entities"; const LARAVEL_TYPE_MAPPINGS: Record> = { - [Workday.LARAVEL_TYPE]: { - association: "workdayUuid", - model: Workday + [ProjectReport.LARAVEL_TYPE]: { + association: "projectReportUuid", + model: ProjectReport }, - [RestorationPartner.LARAVEL_TYPE]: { - association: "restorationPartnerUuid", - model: RestorationPartner + [SiteReport.LARAVEL_TYPE]: { + association: "siteReportUuid", + model: SiteReport } }; type DemographicAssociations = { - workdayUuid?: string; - restorationPartnerUuid?: string; + projectReportUuid?: string; + siteReportUuid?: string; }; const COLUMNS: ColumnMapping[] = [ - "id", + "uuid", "type", - "subtype", - "name", - "amount", - associatedValueColumn("workdayUuid", ["demographicalType", "demographicalId"]), - associatedValueColumn("restorationPartnerUuid", ["demographicalType", "demographicalId"]) + "collection", + "description", + associatedValueColumn("projectReportUuid", ["demographicalId", "demographicalType"]), + associatedValueColumn("siteReportUuid", ["demographicalId", "demographicalType"]) ]; export class DemographicEntity extends AirtableEntity { readonly TABLE_NAME = "Demographics"; readonly COLUMNS = COLUMNS; readonly MODEL = Demographic; - readonly IDENTITY_COLUMN = "id"; + readonly FILTER_FLAGS = ["hidden"]; protected async loadAssociations(demographics: Demographic[]) { return this.loadPolymorphicUuidAssociations( diff --git a/apps/unified-database-service/src/airtable/entities/index.ts b/apps/unified-database-service/src/airtable/entities/index.ts index 39a4d18f..f190214f 100644 --- a/apps/unified-database-service/src/airtable/entities/index.ts +++ b/apps/unified-database-service/src/airtable/entities/index.ts @@ -1,12 +1,11 @@ export * from "./application.airtable-entity"; export * from "./demographic.airtable-entity"; +export * from "./demographic-entry.airtable-entity"; export * from "./nursery.airtable-entity"; export * from "./nursery-report.airtable-entity"; export * from "./organisation.airtable-entity"; export * from "./project.airtable-entity"; export * from "./project-report.airtable-entity"; -export * from "./restoration-partner.airtable-entity"; export * from "./site.airtable-entity"; export * from "./site-report.airtable-entity"; export * from "./tree-species.airtable-entity"; -export * from "./workday.airtable-entity"; diff --git a/apps/unified-database-service/src/airtable/entities/project-report.airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/project-report.airtable-entity.ts index 3ee5807b..58ca9227 100644 --- a/apps/unified-database-service/src/airtable/entities/project-report.airtable-entity.ts +++ b/apps/unified-database-service/src/airtable/entities/project-report.airtable-entity.ts @@ -90,7 +90,8 @@ const COLUMNS: ColumnMapping[] = [ }, "technicalNarrative", "publicNarrative", - "totalUniqueRestorationPartners" + "totalUniqueRestorationPartners", + "businessMilestones" ]; export class ProjectReportEntity extends AirtableEntity { diff --git a/apps/unified-database-service/src/airtable/entities/project.airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/project.airtable-entity.ts index 39468bdb..22567551 100644 --- a/apps/unified-database-service/src/airtable/entities/project.airtable-entity.ts +++ b/apps/unified-database-service/src/airtable/entities/project.airtable-entity.ts @@ -1,7 +1,13 @@ -import { Application, Organisation, Project, Site, SitePolygon } from "@terramatch-microservices/database/entities"; +import { + Application, + Framework, + Organisation, + Project, + Site, + SitePolygon +} from "@terramatch-microservices/database/entities"; import { AirtableEntity, ColumnMapping, commonEntityColumns } from "./airtable-entity"; import { flatten, groupBy } from "lodash"; -import { FRAMEWORK_NAMES } from "@terramatch-microservices/database/constants/framework"; const loadApprovedSites = async (projectIds: number[]) => groupBy( @@ -29,9 +35,10 @@ const COLUMNS: ColumnMapping[] = [ ...commonEntityColumns("project"), "name", { + airtableColumn: "framework", dbColumn: "frameworkKey", - airtableColumn: "cohort", - valueMap: async ({ frameworkKey }) => FRAMEWORK_NAMES[frameworkKey] ?? frameworkKey + include: [{ model: Framework, attributes: ["name"] }], + valueMap: async ({ framework, frameworkKey }) => framework?.name ?? frameworkKey }, { airtableColumn: "applicationUuid", @@ -101,6 +108,7 @@ export class ProjectEntity extends AirtableEntity readonly COLUMNS = COLUMNS; readonly MODEL = Project; readonly SUPPORTS_UPDATED_SINCE = false; + readonly FILTER_FLAGS = ["isTest"]; async loadAssociations(projects: Project[]) { const projectIds = projects.map(({ id }) => id); diff --git a/apps/unified-database-service/src/airtable/entities/restoration-partner.airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/restoration-partner.airtable-entity.ts deleted file mode 100644 index 892d3ce2..00000000 --- a/apps/unified-database-service/src/airtable/entities/restoration-partner.airtable-entity.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { AirtableEntity, associatedValueColumn, ColumnMapping, PolymorphicUuidAssociation } from "./airtable-entity"; -import { ProjectReport, RestorationPartner } from "@terramatch-microservices/database/entities"; - -const LARAVEL_TYPE_MAPPINGS: Record> = { - [ProjectReport.LARAVEL_TYPE]: { - association: "projectReportUuid", - model: ProjectReport - } -}; - -type RestorationPartnerAssociations = { - projectReportUuid?: string; -}; - -const COLUMNS: ColumnMapping[] = [ - "uuid", - "collection", - "description", - associatedValueColumn("projectReportUuid", ["partnerableId", "partnerableType"]) -]; - -export class RestorationPartnerEntity extends AirtableEntity { - readonly TABLE_NAME = "Restoration Partners"; - readonly COLUMNS = COLUMNS; - readonly MODEL = RestorationPartner; - readonly HAS_HIDDEN_FLAG = true; - - protected async loadAssociations(partners: RestorationPartner[]) { - return this.loadPolymorphicUuidAssociations(LARAVEL_TYPE_MAPPINGS, "partnerableType", "partnerableId", partners); - } -} diff --git a/apps/unified-database-service/src/airtable/entities/tree-species.airtable-entity.ts b/apps/unified-database-service/src/airtable/entities/tree-species.airtable-entity.ts index b5390e2d..cb55f96f 100644 --- a/apps/unified-database-service/src/airtable/entities/tree-species.airtable-entity.ts +++ b/apps/unified-database-service/src/airtable/entities/tree-species.airtable-entity.ts @@ -77,7 +77,7 @@ export class TreeSpeciesEntity extends AirtableEntity> = { - [ProjectReport.LARAVEL_TYPE]: { - association: "projectReportUuid", - model: ProjectReport - }, - [SiteReport.LARAVEL_TYPE]: { - association: "siteReportUuid", - model: SiteReport - } -}; - -type WorkdayAssociations = { - projectReportUuid?: string; - siteReportUuid?: string; -}; - -const COLUMNS: ColumnMapping[] = [ - "uuid", - "collection", - "description", - associatedValueColumn("projectReportUuid", ["workdayableId", "workdayableType"]), - associatedValueColumn("siteReportUuid", ["workdayableId", "workdayableType"]) -]; - -export class WorkdayEntity extends AirtableEntity { - readonly TABLE_NAME = "Workdays"; - readonly COLUMNS = COLUMNS; - readonly MODEL = Workday; - readonly HAS_HIDDEN_FLAG = true; - - protected async loadAssociations(workdays: Workday[]) { - return this.loadPolymorphicUuidAssociations(LARAVEL_TYPE_MAPPINGS, "workdayableType", "workdayableId", workdays); - } -} diff --git a/apps/user-service/jest.config.ts b/apps/user-service/jest.config.ts index 6ea34375..919cf8b4 100644 --- a/apps/user-service/jest.config.ts +++ b/apps/user-service/jest.config.ts @@ -1,11 +1,12 @@ /* eslint-disable */ export default { - displayName: 'user-service', - preset: '../../jest.preset.js', - testEnvironment: 'node', + displayName: "user-service", + preset: "../../jest.preset.js", + testEnvironment: "node", transform: { - '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + "^.+\\.[tj]s$": ["ts-jest", { tsconfig: "/tsconfig.spec.json" }] }, - moduleFileExtensions: ['ts', 'js', 'html'], - coverageDirectory: '../../coverage/apps/user-service', + moduleFileExtensions: ["ts", "js", "html"], + coveragePathIgnorePatterns: [".dto.ts"], + coverageDirectory: "../../coverage/apps/user-service" }; diff --git a/apps/user-service/src/users/dto/user-update.dto.ts b/apps/user-service/src/users/dto/user-update.dto.ts new file mode 100644 index 00000000..d8f95a74 --- /dev/null +++ b/apps/user-service/src/users/dto/user-update.dto.ts @@ -0,0 +1,33 @@ +import { Equals, IsEnum, IsUUID, ValidateNested } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; +import { Type } from "class-transformer"; + +const VALID_LOCALES = ["en-US", "es-MX", "fr-FR", "pt-BR"]; + +class UserUpdateAttributes { + @IsEnum(VALID_LOCALES) + @ApiProperty({ description: "New default locale for the given user", nullable: true, enum: VALID_LOCALES }) + locale?: string | null; +} + +class UserUpdate { + @Equals("users") + @ApiProperty({ enum: ["users"] }) + type: string; + + @IsUUID() + @ApiProperty({ format: "uuid" }) + id: string; + + @ValidateNested() + @Type(() => UserUpdateAttributes) + @ApiProperty({ type: () => UserUpdateAttributes }) + attributes: UserUpdateAttributes; +} + +export class UserUpdateBodyDto { + @ValidateNested() + @Type(() => UserUpdate) + @ApiProperty({ type: () => UserUpdate }) + data: UserUpdate; +} diff --git a/apps/user-service/src/users/users.controller.spec.ts b/apps/user-service/src/users/users.controller.spec.ts index d19a9a66..632bcdf2 100644 --- a/apps/user-service/src/users/users.controller.spec.ts +++ b/apps/user-service/src/users/users.controller.spec.ts @@ -2,7 +2,7 @@ import { Test, TestingModule } from "@nestjs/testing"; import { UsersController } from "./users.controller"; import { PolicyService } from "@terramatch-microservices/common"; import { createMock, DeepMocked } from "@golevelup/ts-jest"; -import { NotFoundException, UnauthorizedException } from "@nestjs/common"; +import { BadRequestException, NotFoundException, UnauthorizedException } from "@nestjs/common"; import { OrganisationFactory, UserFactory } from "@terramatch-microservices/database/factories"; import { Relationship, Resource } from "@terramatch-microservices/common/util"; @@ -23,58 +23,106 @@ describe("UsersController", () => { jest.restoreAllMocks(); }); - it("should throw not found if the user is not found", async () => { - await expect(controller.findOne("0", { authenticatedUserId: 1 })).rejects.toThrow(NotFoundException); - }); + describe("findOne", () => { + it("should throw not found if the user is not found", async () => { + await expect(controller.findOne("0", { authenticatedUserId: 1 })).rejects.toThrow(NotFoundException); + }); - it("should throw an error if the policy does not authorize", async () => { - policyService.authorize.mockRejectedValue(new UnauthorizedException()); - const { id } = await UserFactory.create(); - await expect(controller.findOne(`${id}`, { authenticatedUserId: 1 })).rejects.toThrow(UnauthorizedException); - }); + it("should throw an error if the policy does not authorize", async () => { + policyService.authorize.mockRejectedValue(new UnauthorizedException()); + const { uuid } = await UserFactory.create(); + await expect(controller.findOne(uuid, { authenticatedUserId: 1 })).rejects.toThrow(UnauthorizedException); + }); - it('should return the currently logged in user if the id is "me"', async () => { - const { id, uuid } = await UserFactory.create(); - const result = await controller.findOne("me", { authenticatedUserId: id }); - expect((result.data as Resource).id).toBe(uuid); - }); + it('should return the currently logged in user if the id is "me"', async () => { + const { id, uuid } = await UserFactory.create(); + const result = await controller.findOne("me", { authenticatedUserId: id }); + expect((result.data as Resource).id).toBe(uuid); + }); - it("should return the indicated user if the logged in user is allowed to access", async () => { - policyService.authorize.mockResolvedValue(undefined); - const { id, uuid } = await UserFactory.create(); - const result = await controller.findOne(`${id}`, { authenticatedUserId: id + 1 }); - expect((result.data as Resource).id).toBe(uuid); - }); + it("should return the indicated user if the logged in user is allowed to access", async () => { + policyService.authorize.mockResolvedValue(undefined); + const { id, uuid } = await UserFactory.create(); + const result = await controller.findOne(uuid, { authenticatedUserId: id + 1 }); + expect((result.data as Resource).id).toBe(uuid); + }); - it("should return a document without includes if there is no org", async () => { - const { id } = await UserFactory.create(); - const result = await controller.findOne("me", { authenticatedUserId: id }); - expect(result.included).not.toBeDefined(); - }); + it("should return a document without includes if there is no org", async () => { + const { id } = await UserFactory.create(); + const result = await controller.findOne("me", { authenticatedUserId: id }); + expect(result.included).not.toBeDefined(); + }); + + it("should include the primary org for the user", async () => { + const user = await UserFactory.create(); + const org = await OrganisationFactory.create(); + await user.$add("organisationsConfirmed", org); + const result = await controller.findOne("me", { authenticatedUserId: user.id }); + expect(result.included).toHaveLength(1); + expect(result.included[0]).toMatchObject({ type: "organisations", id: org.uuid }); + const data = result.data as Resource; + expect(data.relationships.org).toBeDefined(); + const relationship = data.relationships.org.data as Relationship; + expect(relationship).toMatchObject({ + type: "organisations", + id: org.uuid, + meta: { userStatus: "approved" } + }); + }); - it("should include the primary org for the user", async () => { - const user = await UserFactory.create(); - const org = await OrganisationFactory.create(); - await user.$add("organisationsConfirmed", org); - const result = await controller.findOne("me", { authenticatedUserId: user.id }); - expect(result.included).toHaveLength(1); - expect(result.included[0]).toMatchObject({ type: "organisations", id: org.uuid }); - const data = result.data as Resource; - expect(data.relationships.org).toBeDefined(); - const relationship = data.relationships.org.data as Relationship; - expect(relationship).toMatchObject({ type: "organisations", id: org.uuid, meta: { userStatus: "approved" } }); + it('should return "na" for userStatus if there is no many to many relationship', async () => { + const user = await UserFactory.create(); + const org = await OrganisationFactory.create(); + await user.$set("organisation", org); + const result = await controller.findOne("me", { authenticatedUserId: user.id }); + expect(result.included).toHaveLength(1); + expect(result.included[0]).toMatchObject({ type: "organisations", id: org.uuid }); + const data = result.data as Resource; + expect(data.relationships.org).toBeDefined(); + const relationship = data.relationships.org.data as Relationship; + expect(relationship).toMatchObject({ + type: "organisations", + id: org.uuid, + meta: { userStatus: "na" } + }); + }); }); - it('should return "na" for userStatus if there is no many to many relationship', async () => { - const user = await UserFactory.create(); - const org = await OrganisationFactory.create(); - await user.$set("organisation", org); - const result = await controller.findOne("me", { authenticatedUserId: user.id }); - expect(result.included).toHaveLength(1); - expect(result.included[0]).toMatchObject({ type: "organisations", id: org.uuid }); - const data = result.data as Resource; - expect(data.relationships.org).toBeDefined(); - const relationship = data.relationships.org.data as Relationship; - expect(relationship).toMatchObject({ type: "organisations", id: org.uuid, meta: { userStatus: "na" } }); + describe("update", () => { + const makeValidBody = (uuid: string, locale?: string) => ({ + data: { + id: uuid, + type: "users", + attributes: { locale } + } + }); + + beforeEach(async () => { + policyService.authorize.mockResolvedValue(undefined); + }); + + it("should throw if the body and path UUIDs don't match", async () => { + await expect(controller.update("foo", makeValidBody("bar"))).rejects.toThrow(BadRequestException); + }); + + it("should throw if the user is not found", async () => { + await expect(controller.update("foo", makeValidBody("foo"))).rejects.toThrow(NotFoundException); + }); + + it("update the user with a new locale", async () => { + const user = await UserFactory.create(); + const result = await controller.update(user.uuid, makeValidBody(user.uuid, "es-MX")); + expect((result.data as Resource).attributes.locale).toEqual("es-MX"); + await user.reload(); + expect(user.locale).toEqual("es-MX"); + }); + + it("does not change the locale if it's missing in the update request", async () => { + const user = await UserFactory.create({ locale: "es-MX" }); + const result = await controller.update(user.uuid, makeValidBody(user.uuid)); + expect((result.data as Resource).attributes.locale).toEqual("es-MX"); + await user.reload(); + expect(user.locale).toEqual("es-MX"); + }); }); }); diff --git a/apps/user-service/src/users/users.controller.ts b/apps/user-service/src/users/users.controller.ts index 2cc64ced..da21850c 100644 --- a/apps/user-service/src/users/users.controller.ts +++ b/apps/user-service/src/users/users.controller.ts @@ -1,76 +1,113 @@ import { + BadRequestException, + Body, Controller, Get, NotFoundException, Param, + Patch, Request, - UnauthorizedException, -} from '@nestjs/common'; -import { User } from '@terramatch-microservices/database/entities'; -import { PolicyService } from '@terramatch-microservices/common'; -import { ApiOperation, ApiParam } from '@nestjs/swagger'; -import { OrganisationDto, UserDto } from '@terramatch-microservices/common/dto'; -import { ApiException } from '@nanogiants/nestjs-swagger-api-exception-decorator'; -import { JsonApiResponse } from '@terramatch-microservices/common/decorators'; -import { - buildJsonApi, - JsonApiDocument, -} from '@terramatch-microservices/common/util'; + UnauthorizedException +} from "@nestjs/common"; +import { User } from "@terramatch-microservices/database/entities"; +import { PolicyService } from "@terramatch-microservices/common"; +import { ApiOperation, ApiParam } from "@nestjs/swagger"; +import { OrganisationDto, UserDto } from "@terramatch-microservices/common/dto"; +import { ApiException } from "@nanogiants/nestjs-swagger-api-exception-decorator"; +import { JsonApiResponse } from "@terramatch-microservices/common/decorators"; +import { buildJsonApi, DocumentBuilder } from "@terramatch-microservices/common/util"; +import { UserUpdateBodyDto } from "./dto/user-update.dto"; + +const USER_RESPONSE_SHAPE = { + data: { + type: UserDto, + relationships: [ + { + name: "org", + type: OrganisationDto, + meta: { + userStatus: { + type: "string", + enum: ["approved", "requested", "rejected", "na"] + } + } + } + ] + }, + included: [{ type: OrganisationDto }] +}; -@Controller('users/v3/users') +@Controller("users/v3/users") export class UsersController { constructor(private readonly policyService: PolicyService) {} - @Get(':id') - @ApiOperation({ operationId: 'usersFind', description: "Fetch a user by ID, or with the 'me' identifier" }) - @ApiParam({ name: 'id', example: 'me', description: 'A valid user id or "me"' }) - @JsonApiResponse({ - data: { - type: UserDto, - relationships: [ - { - name: 'org', - type: OrganisationDto, - meta: { - userStatus: { - type: 'string', - enum: ['approved', 'requested', 'rejected', 'na'], - }, - }, - }, - ], - }, - included: [{ type: OrganisationDto }], + @Get(":uuid") + @ApiOperation({ operationId: "usersFind", description: "Fetch a user by ID, or with the 'me' identifier" }) + @ApiParam({ name: "uuid", example: "me", description: 'A valid user uuid or "me"' }) + @JsonApiResponse(USER_RESPONSE_SHAPE) + @ApiException(() => UnauthorizedException, { + description: "Authorization failed" + }) + @ApiException(() => NotFoundException, { + description: "User with that ID not found" }) + async findOne(@Param("uuid") pathId: string, @Request() { authenticatedUserId }) { + const userWhere = pathId === "me" ? { id: authenticatedUserId } : { uuid: pathId }; + const user = await User.findOne({ + include: ["roles", "organisation", "frameworks"], + where: userWhere + }); + if (user == null) throw new NotFoundException(); + + await this.policyService.authorize("read", user); + + return (await this.addUserResource(buildJsonApi(), user)).serialize(); + } + + @Patch(":uuid") + @ApiOperation({ operationId: "userUpdate", description: "Update a user by ID" }) + @ApiParam({ name: "uuid", description: "A valid user uuid" }) + @JsonApiResponse(USER_RESPONSE_SHAPE) @ApiException(() => UnauthorizedException, { - description: 'Authorization failed', + description: "Authorization failed" }) @ApiException(() => NotFoundException, { - description: 'User with that ID not found', + description: "User with that ID not found" }) - async findOne( - @Param('id') pathId: string, - @Request() { authenticatedUserId } - ): Promise { - const userId = pathId === 'me' ? authenticatedUserId : parseInt(pathId); + @ApiException(() => BadRequestException, { description: "Something is malformed about the request" }) + async update(@Param("uuid") uuid: string, @Body() updatePayload: UserUpdateBodyDto) { + if (uuid !== updatePayload.data.id) { + throw new BadRequestException(`Path uuid and payload id do not match`); + } + const user = await User.findOne({ - include: ['roles', 'organisation', 'frameworks'], - where: { id: userId }, + include: ["roles", "organisation", "frameworks"], + where: { uuid } }); if (user == null) throw new NotFoundException(); - await this.policyService.authorize('read', user); + await this.policyService.authorize("update", user); + + // The only thing allowed to update for now is the locale + const { locale } = updatePayload.data.attributes; + if (locale != null) { + user.locale = locale; + await user.save(); + } + + return (await this.addUserResource(buildJsonApi(), user)).serialize(); + } - const document = buildJsonApi(); + private async addUserResource(document: DocumentBuilder, user: User) { const userResource = document.addData(user.uuid, new UserDto(user, await user.myFrameworks())); const org = await user.primaryOrganisation(); if (org != null) { const orgResource = document.addIncluded(org.uuid, new OrganisationDto(org)); - const userStatus = org.OrganisationUser?.status ?? 'na'; - userResource.relateTo('org', orgResource, { userStatus }); + const userStatus = org.OrganisationUser?.status ?? "na"; + userResource.relateTo("org", orgResource, { userStatus }); } - return document.serialize(); + return document; } } diff --git a/libs/common/src/lib/dto/user.dto.ts b/libs/common/src/lib/dto/user.dto.ts index e45da5c7..4bfcc980 100644 --- a/libs/common/src/lib/dto/user.dto.ts +++ b/libs/common/src/lib/dto/user.dto.ts @@ -47,5 +47,4 @@ export class UserDto extends JsonApiAttributes { @ApiProperty({ type: () => UserFramework, isArray: true }) frameworks: UserFramework[]; - } diff --git a/libs/common/src/lib/policies/user.policy.spec.ts b/libs/common/src/lib/policies/user.policy.spec.ts index 3ad48896..88354117 100644 --- a/libs/common/src/lib/policies/user.policy.spec.ts +++ b/libs/common/src/lib/policies/user.policy.spec.ts @@ -37,4 +37,22 @@ describe("UserPolicy", () => { mockPermissions(); await expect(service.authorize("read", await UserFactory.build({ id: 123 }))).resolves.toBeUndefined(); }); + + it("allows updating any user as admin", async () => { + mockUserId(123); + mockPermissions("users-manage"); + await expect(service.authorize("update", new User())).resolves.toBeUndefined(); + }); + + it("disallows updating other users as non-admin", async () => { + mockUserId(123); + mockPermissions(); + await expect(service.authorize("update", new User())).rejects.toThrow(UnauthorizedException); + }); + + it("allows updating own user as non-admin", async () => { + mockUserId(123); + mockPermissions(); + await expect(service.authorize("update", await UserFactory.build({ id: 123 }))).resolves.toBeUndefined(); + }); }); diff --git a/libs/common/src/lib/policies/user.policy.ts b/libs/common/src/lib/policies/user.policy.ts index 4fd8ccee..fb30b93d 100644 --- a/libs/common/src/lib/policies/user.policy.ts +++ b/libs/common/src/lib/policies/user.policy.ts @@ -1,12 +1,14 @@ -import { User } from '@terramatch-microservices/database/entities'; -import { EntityPolicy } from './entity.policy'; +import { User } from "@terramatch-microservices/database/entities"; +import { EntityPolicy } from "./entity.policy"; export class UserPolicy extends EntityPolicy { async addRules() { - if (this.permissions.includes('users-manage')) { - this.builder.can('read', User); + if (this.permissions.includes("users-manage")) { + this.builder.can("read", User); + this.builder.can("update", User); } else { - this.builder.can('read', User, { id: this.userId }); + this.builder.can("read", User, { id: this.userId }); + this.builder.can("update", User, { id: this.userId }); } } } diff --git a/libs/common/src/lib/util/json-api-builder.ts b/libs/common/src/lib/util/json-api-builder.ts index e52e3ae9..856fb555 100644 --- a/libs/common/src/lib/util/json-api-builder.ts +++ b/libs/common/src/lib/util/json-api-builder.ts @@ -113,7 +113,7 @@ type DocumentBuilderOptions = { forceDataArray?: boolean; }; -class DocumentBuilder { +export class DocumentBuilder { data: ResourceBuilder[] = []; included: ResourceBuilder[] = []; diff --git a/libs/database/src/lib/constants/framework.ts b/libs/database/src/lib/constants/framework.ts index 5b48037c..1115ca31 100644 --- a/libs/database/src/lib/constants/framework.ts +++ b/libs/database/src/lib/constants/framework.ts @@ -1,11 +1,9 @@ -export const FRAMEWORK_NAMES = { - terrafund: "TerraFund Top 100", - "terrafund-landscapes": "TerraFund Landscapes", - ppc: "Priceless Planet Coalition (PPC)", - enterprises: "TerraFund Enterprises", - hbf: "Harit Bharat Fund", - "epa-ghana-pilot": "EPA-Ghana Pilot" -} as const; - -export const FRAMEWORK_KEYS = Object.keys(FRAMEWORK_NAMES); -export type FrameworkKey = keyof typeof FRAMEWORK_NAMES; +export const FRAMEWORK_KEYS = [ + "terrafund", + "terrafund-landscapes", + "ppc", + "enterprises", + "hbf", + "epa-ghana-pilot" +] as const; +export type FrameworkKey = (typeof FRAMEWORK_KEYS)[number]; diff --git a/libs/database/src/lib/entities/demographic-entry.entity.ts b/libs/database/src/lib/entities/demographic-entry.entity.ts new file mode 100644 index 00000000..dd179c7a --- /dev/null +++ b/libs/database/src/lib/entities/demographic-entry.entity.ts @@ -0,0 +1,33 @@ +import { AllowNull, AutoIncrement, Column, ForeignKey, Model, PrimaryKey, Table } from "sequelize-typescript"; +import { BIGINT, INTEGER, STRING } from "sequelize"; +import { Demographic } from "./demographic.entity"; + +@Table({ + tableName: "demographic_entries", + underscored: true, + paranoid: true +}) +export class DemographicEntry extends Model { + @PrimaryKey + @AutoIncrement + @Column(BIGINT.UNSIGNED) + override id: number; + + @Column(STRING) + type: string; + + @AllowNull + @Column(STRING) + subtype: string; + + @AllowNull + @Column(STRING) + name: string; + + @Column(INTEGER({ length: 10 })) + amount: number; + + @ForeignKey(() => Demographic) + @Column(BIGINT.UNSIGNED) + demographicId: number; +} diff --git a/libs/database/src/lib/entities/demographic.entity.ts b/libs/database/src/lib/entities/demographic.entity.ts index 5368d6f8..281f3f7b 100644 --- a/libs/database/src/lib/entities/demographic.entity.ts +++ b/libs/database/src/lib/entities/demographic.entity.ts @@ -1,5 +1,6 @@ -import { AllowNull, AutoIncrement, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, INTEGER, STRING } from "sequelize"; +import { AllowNull, AutoIncrement, Column, HasMany, Model, PrimaryKey, Table, Unique } from "sequelize-typescript"; +import { BIGINT, BOOLEAN, STRING, TEXT, UUID } from "sequelize"; +import { DemographicEntry } from "./demographic-entry.entity"; @Table({ tableName: "demographics", @@ -16,23 +17,30 @@ export class Demographic extends Model { @Column(BIGINT.UNSIGNED) override id: number; - @Column(STRING) - type: string; + @Unique + @Column(UUID) + uuid: string; - @AllowNull @Column(STRING) - subtype: string; + type: string; @AllowNull @Column(STRING) - name: string; - - @Column(INTEGER({ length: 10 })) - amount: number; + collection: string | null; @Column(STRING) demographicalType: string; @Column(BIGINT.UNSIGNED) demographicalId: number; + + @AllowNull + @Column(TEXT) + description: string; + + @Column({ type: BOOLEAN, defaultValue: false }) + hidden: boolean; + + @HasMany(() => DemographicEntry, { constraints: false }) + entries: DemographicEntry[] | null; } diff --git a/libs/database/src/lib/entities/framework.entity.ts b/libs/database/src/lib/entities/framework.entity.ts index ad122ff9..163d676c 100644 --- a/libs/database/src/lib/entities/framework.entity.ts +++ b/libs/database/src/lib/entities/framework.entity.ts @@ -1,7 +1,7 @@ -import { AutoIncrement, Column, Model, PrimaryKey, Table } from "sequelize-typescript"; -import { BIGINT, STRING } from "sequelize"; +import { AutoIncrement, Column, Index, Model, PrimaryKey, Table } from "sequelize-typescript"; +import { BIGINT, STRING, UUID } from "sequelize"; -// A quick stub to get the information needed for users/me +// Incomplete stub @Table({ tableName: "frameworks", underscored: true }) export class Framework extends Model { @PrimaryKey @@ -9,6 +9,11 @@ export class Framework extends Model { @Column(BIGINT.UNSIGNED) override id: number; + @Index + @Column(UUID) + uuid: string; + + @Index @Column(STRING(20)) slug: string; diff --git a/libs/database/src/lib/entities/index.ts b/libs/database/src/lib/entities/index.ts index a46b745d..627fdb3e 100644 --- a/libs/database/src/lib/entities/index.ts +++ b/libs/database/src/lib/entities/index.ts @@ -1,6 +1,7 @@ export * from "./application.entity"; export * from "./delayed-job.entity"; export * from "./demographic.entity"; +export * from "./demographic-entry.entity"; export * from "./form-submission.entity"; export * from "./framework.entity"; export * from "./framework-user.entity"; @@ -23,7 +24,6 @@ export * from "./project.entity"; export * from "./project-pitch.entity"; export * from "./project-report.entity"; export * from "./project-user.entity"; -export * from "./restoration-partner.entity"; export * from "./role.entity"; export * from "./seeding.entity"; export * from "./site.entity"; @@ -32,4 +32,3 @@ export * from "./site-report.entity"; export * from "./tree-species.entity"; export * from "./tree-species-research.entity"; export * from "./user.entity"; -export * from "./workday.entity"; diff --git a/libs/database/src/lib/entities/project-report.entity.ts b/libs/database/src/lib/entities/project-report.entity.ts index 6951d612..71347468 100644 --- a/libs/database/src/lib/entities/project-report.entity.ts +++ b/libs/database/src/lib/entities/project-report.entity.ts @@ -360,6 +360,10 @@ export class ProjectReport extends Model { @Column(INTEGER.UNSIGNED) totalUniqueRestorationPartners: number | null; + @AllowNull + @Column(TEXT) + businessMilestones: string | null; + @HasMany(() => TreeSpecies, { foreignKey: "speciesableId", constraints: false, diff --git a/libs/database/src/lib/entities/project.entity.ts b/libs/database/src/lib/entities/project.entity.ts index 2c6b4abf..b3fa5912 100644 --- a/libs/database/src/lib/entities/project.entity.ts +++ b/libs/database/src/lib/entities/project.entity.ts @@ -20,6 +20,7 @@ import { Site } from "./site.entity"; import { Nursery } from "./nursery.entity"; import { JsonColumn } from "../decorators/json-column.decorator"; import { FrameworkKey } from "../constants/framework"; +import { Framework } from "./framework.entity"; @Table({ tableName: "v2_projects", underscored: true, paranoid: true }) export class Project extends Model { @@ -39,6 +40,9 @@ export class Project extends Model { @Column(STRING) frameworkKey: FrameworkKey | null; + @BelongsTo(() => Framework, { foreignKey: "frameworkKey", targetKey: "slug", constraints: false }) + framework: Framework | null; + @Default(false) @Column(BOOLEAN) isTest: boolean; diff --git a/libs/database/src/lib/entities/restoration-partner.entity.ts b/libs/database/src/lib/entities/restoration-partner.entity.ts deleted file mode 100644 index 61710887..00000000 --- a/libs/database/src/lib/entities/restoration-partner.entity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { AllowNull, AutoIncrement, Column, HasMany, Model, PrimaryKey, Table, Unique } from "sequelize-typescript"; -import { Demographic } from "./demographic.entity"; -import { BIGINT, BOOLEAN, STRING, TEXT, UUID } from "sequelize"; - -@Table({ - tableName: "restoration_partners", - underscored: true, - paranoid: true, - indexes: [ - // @Index doesn't work with underscored column names - { name: "partner_morph_index", fields: ["partnerable_id", "partnerable_type"] } - ] -}) -export class RestorationPartner extends Model { - static readonly LARAVEL_TYPE = "App\\Models\\V2\\RestorationPartners\\RestorationPartner"; - - @PrimaryKey - @AutoIncrement - @Column(BIGINT.UNSIGNED) - override id: number; - - @Unique - @Column(UUID) - uuid: string; - - @AllowNull - @Column(STRING) - collection: string | null; - - @Column(STRING) - partnerableType: string; - - @Column(BIGINT.UNSIGNED) - partnerableId: number; - - @AllowNull - @Column(TEXT) - description: string; - - @Column({ type: BOOLEAN, defaultValue: false }) - hidden: boolean; - - @HasMany(() => Demographic, { - foreignKey: "demographicalId", - constraints: false, - scope: { demographicalType: RestorationPartner.LARAVEL_TYPE } - }) - demographics: Demographic[] | null; -} diff --git a/libs/database/src/lib/entities/workday.entity.ts b/libs/database/src/lib/entities/workday.entity.ts deleted file mode 100644 index 9562abb5..00000000 --- a/libs/database/src/lib/entities/workday.entity.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { AllowNull, AutoIncrement, Column, HasMany, Model, PrimaryKey, Table, Unique } from "sequelize-typescript"; -import { Demographic } from "./demographic.entity"; -import { BIGINT, BOOLEAN, STRING, TEXT, UUID } from "sequelize"; - -@Table({ - tableName: "v2_workdays", - underscored: true, - paranoid: true, - indexes: [ - // @Index doesn't work with underscored column names - { name: "v2_workdays_morph_index", fields: ["workdayable_id", "workdayable_type"] } - ] -}) -export class Workday extends Model { - static readonly LARAVEL_TYPE = "App\\Models\\V2\\Workdays\\Workday"; - - @PrimaryKey - @AutoIncrement - @Column(BIGINT.UNSIGNED) - override id: number; - - @Unique - @Column(UUID) - uuid: string; - - @AllowNull - @Column(STRING) - collection: string | null; - - @Column(STRING) - workdayableType: string; - - @Column(BIGINT.UNSIGNED) - workdayableId: number; - - @AllowNull - @Column(TEXT) - description: string; - - @Column({ type: BOOLEAN, defaultValue: false }) - hidden: boolean; - - @HasMany(() => Demographic, { - foreignKey: "demographicalId", - constraints: false, - scope: { demographicalType: Workday.LARAVEL_TYPE } - }) - demographics: Demographic[] | null; -} diff --git a/libs/database/src/lib/factories/demographic-entry.factory.ts b/libs/database/src/lib/factories/demographic-entry.factory.ts new file mode 100644 index 00000000..fbe8d946 --- /dev/null +++ b/libs/database/src/lib/factories/demographic-entry.factory.ts @@ -0,0 +1,30 @@ +import { FactoryGirl } from "factory-girl-ts"; +import { faker } from "@faker-js/faker"; +import { DemographicEntry } from "../entities"; +import { DemographicFactory } from "./demographic.factory"; + +const TYPES = ["gender", "age", "ethnicity", "caste"]; +const SUBTYPES: Record = { + gender: ["male", "female", "non-binary"], + age: ["youth", "adult", "elder"], + ethnicity: ["indigenous", "unknown", "other"], + caste: ["marginalized"] +}; +const NAMES: Record = { + gender: [null], + age: [null], + ethnicity: ["Cuyono", "Tupiniquim", "Visaya"], + caste: [null] +}; + +export const DemographicEntryFactory = FactoryGirl.define(DemographicEntry, async () => { + const type = faker.helpers.arrayElement(TYPES); + return { + uuid: crypto.randomUUID(), + demographicId: DemographicFactory.forProjectReportWorkday.associate("id"), + type, + subtype: faker.helpers.arrayElement(SUBTYPES[type] ?? [null]), + name: faker.helpers.arrayElement(NAMES[type] ?? [null]), + amount: faker.number.int({ min: 10, max: 100 }) + }; +}); diff --git a/libs/database/src/lib/factories/demographic.factory.ts b/libs/database/src/lib/factories/demographic.factory.ts index c60150d2..ea807f78 100644 --- a/libs/database/src/lib/factories/demographic.factory.ts +++ b/libs/database/src/lib/factories/demographic.factory.ts @@ -1,44 +1,37 @@ import { FactoryGirl } from "factory-girl-ts"; -import { Demographic, RestorationPartner, Workday } from "../entities"; -import { WorkdayFactory } from "./workday.factory"; +import { ProjectReport, SiteReport, Demographic } from "../entities"; +import { ProjectReportFactory } from "./project-report.factory"; +import { SiteReportFactory } from "./site-report.factory"; import { faker } from "@faker-js/faker"; -import { RestorationPartnerFactory } from "./restoration-partner.factory"; -const TYPES = ["gender", "age", "ethnicity", "caste"]; -const NAMES: Record = { - gender: ["male", "female", "non-binary"], - age: ["youth", "adult", "elder"], - ethnicity: ["Cuyono", "Tupiniquim", "Visaya"], - caste: ["marginalized"] -}; -const SUBTYPES: Record = { - gender: [null], - age: [null], - ethnicity: ["indigenous", "unknown", "other"], - caste: [null] -}; - -const defaultAttributesFactory = async () => { - const type = faker.helpers.arrayElement(TYPES); - return { - uuid: crypto.randomUUID(), - amount: faker.number.int({ min: 10, max: 100 }), - type, - subtype: faker.helpers.arrayElement(SUBTYPES[type] ?? [null]), - name: faker.helpers.arrayElement(NAMES[type] ?? [null]) - }; -}; +const defaultAttributesFactory = async () => ({ + uuid: crypto.randomUUID(), + description: null, + hidden: false +}); export const DemographicFactory = { - forWorkday: FactoryGirl.define(Demographic, async () => ({ + forProjectReportWorkday: FactoryGirl.define(Demographic, async () => ({ + ...(await defaultAttributesFactory()), + demographicalType: ProjectReport.LARAVEL_TYPE, + demographicalId: ProjectReportFactory.associate("id"), + type: "workdays", + collection: faker.helpers.arrayElement(ProjectReport.WORKDAY_COLLECTIONS) + })), + + forSiteReportWorkday: FactoryGirl.define(Demographic, async () => ({ ...(await defaultAttributesFactory()), - demographicalType: Workday.LARAVEL_TYPE, - demographicalId: WorkdayFactory.forProjectReport.associate("id") + demographicalType: SiteReport.LARAVEL_TYPE, + demographicalId: SiteReportFactory.associate("id"), + type: "workdays", + collection: faker.helpers.arrayElement(SiteReport.WORKDAY_COLLECTIONS) })), - forRestorationPartner: FactoryGirl.define(Demographic, async () => ({ + forProjectReportRestorationPartner: FactoryGirl.define(Demographic, async () => ({ ...(await defaultAttributesFactory()), - demographicalType: RestorationPartner.LARAVEL_TYPE, - demographicalId: RestorationPartnerFactory.forProjectReport.associate("id") + demographicalType: ProjectReport.LARAVEL_TYPE, + demographicalId: ProjectReportFactory.associate("id"), + type: "restoration-partners", + collection: faker.helpers.arrayElement(ProjectReport.RESTORATION_PARTNER_COLLECTIONS) })) }; diff --git a/libs/database/src/lib/factories/index.ts b/libs/database/src/lib/factories/index.ts index 3b9252b2..ec63a958 100644 --- a/libs/database/src/lib/factories/index.ts +++ b/libs/database/src/lib/factories/index.ts @@ -1,6 +1,7 @@ export * from "./application.factory"; export * from "./delayed-job.factory"; export * from "./demographic.factory"; +export * from "./demographic-entry.factory"; export * from "./form-submission.factory"; export * from "./funding-programme.factory"; export * from "./indicator-output-field-monitoring.factory"; @@ -15,7 +16,6 @@ export * from "./organisation.factory"; export * from "./polygon-geometry.factory"; export * from "./project.factory"; export * from "./project-report.factory"; -export * from "./restoration-partner.factory"; export * from "./seeding.factory"; export * from "./site.factory"; export * from "./site-polygon.factory"; @@ -23,4 +23,3 @@ export * from "./site-report.factory"; export * from "./tree-species.factory"; export * from "./tree-species-research.factory"; export * from "./user.factory"; -export * from "./workday.factory"; diff --git a/libs/database/src/lib/factories/restoration-partner.factory.ts b/libs/database/src/lib/factories/restoration-partner.factory.ts deleted file mode 100644 index 44b23cda..00000000 --- a/libs/database/src/lib/factories/restoration-partner.factory.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { FactoryGirl } from "factory-girl-ts"; -import { ProjectReport, RestorationPartner } from "../entities"; -import { ProjectReportFactory } from "./project-report.factory"; -import { faker } from "@faker-js/faker"; - -const defaultAttributesFactory = async () => ({ - uuid: crypto.randomUUID(), - description: null, - hidden: false -}); - -export const RestorationPartnerFactory = { - forProjectReport: FactoryGirl.define(RestorationPartner, async () => ({ - ...(await defaultAttributesFactory()), - partnerableType: ProjectReport.LARAVEL_TYPE, - partnerableId: ProjectReportFactory.associate("id"), - collection: faker.helpers.arrayElement(ProjectReport.RESTORATION_PARTNER_COLLECTIONS) - })) -}; diff --git a/libs/database/src/lib/factories/workday.factory.ts b/libs/database/src/lib/factories/workday.factory.ts deleted file mode 100644 index 52017086..00000000 --- a/libs/database/src/lib/factories/workday.factory.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { FactoryGirl } from "factory-girl-ts"; -import { ProjectReport, SiteReport, Workday } from "../entities"; -import { ProjectReportFactory } from "./project-report.factory"; -import { SiteReportFactory } from "./site-report.factory"; -import { faker } from "@faker-js/faker"; - -const defaultAttributesFactory = async () => ({ - uuid: crypto.randomUUID(), - description: null, - hidden: false -}); - -export const WorkdayFactory = { - forProjectReport: FactoryGirl.define(Workday, async () => ({ - ...(await defaultAttributesFactory()), - workdayableType: ProjectReport.LARAVEL_TYPE, - workdayableId: ProjectReportFactory.associate("id"), - collection: faker.helpers.arrayElement(ProjectReport.WORKDAY_COLLECTIONS) - })), - - forSiteReport: FactoryGirl.define(Workday, async () => ({ - ...(await defaultAttributesFactory()), - workdayableType: SiteReport.LARAVEL_TYPE, - workdayableId: SiteReportFactory.associate("id"), - collection: faker.helpers.arrayElement(SiteReport.WORKDAY_COLLECTIONS) - })) -};