diff --git a/packages/data-store/src/__tests__/contact.entities.test.ts b/packages/data-store/src/__tests__/contact.entities.test.ts index e2569bc6b..8aff5fe38 100644 --- a/packages/data-store/src/__tests__/contact.entities.test.ts +++ b/packages/data-store/src/__tests__/contact.entities.test.ts @@ -1410,7 +1410,7 @@ describe('Database entities tests', (): void => { const partyType2: NonPersistedPartyType = { type: PartyTypeEnum.NATURAL_PERSON, tenantId, - name, + name: `${name} + 1`, } const partyTypeEntity2: PartyTypeEntity = partyTypeEntityFrom(partyType2) diff --git a/packages/data-store/src/__tests__/contact.store.test.ts b/packages/data-store/src/__tests__/contact.store.test.ts index d3d2ddec5..1dff1f13e 100644 --- a/packages/data-store/src/__tests__/contact.store.test.ts +++ b/packages/data-store/src/__tests__/contact.store.test.ts @@ -49,6 +49,32 @@ describe('Contact store tests', (): void => { await (await dbConnection).destroy() }) + it('should get a party/student by id', async (): Promise => { + const party: NonPersistedParty = { + uri: 'example.com', + partyType: { + type: PartyTypeEnum.STUDENT, + tenantId: '0605761c-4113-4ce5-a6b2-9cbae2f9d289', + name: 'example_name', + }, + contact: { + firstName: 'example_first_name', + middleName: 'example_middle_name', + lastName: 'example_last_name', + grade: '5th', + dateOfBirth: new Date(2016, 0, 5), + displayName: 'example_display_name', + }, + } + + const savedParty: Party = await contactStore.addParty(party) + expect(savedParty).toBeDefined() + + const result: Party = await contactStore.getParty({ partyId: savedParty.id }) + + expect(result).toBeDefined() + }) + it('should get party by id', async (): Promise => { const party: NonPersistedParty = { uri: 'example.com', @@ -1766,6 +1792,26 @@ describe('Contact store tests', (): void => { await expect(contactStore.addParty(party)).rejects.toThrow(`Party type ${partyType}, does not match for provided contact`) }) + it('should throw error when adding person party with student contact type', async (): Promise => { + const partyType = PartyTypeEnum.STUDENT + const party: NonPersistedParty = { + uri: 'example.com', + partyType: { + type: partyType, + tenantId: '0605761c-4113-4ce5-a6b2-9cbae2f9d289', + name: 'example_name', + }, + contact: { + firstName: 'example_first_name', + middleName: 'example_middle_name', + lastName: 'example_last_name', + displayName: 'example_display_name', + }, + } + + await expect(contactStore.addParty(party)).rejects.toThrow(`Party type ${partyType}, does not match for provided contact`) + }) + it('should throw error when adding organization party with wrong contact type', async (): Promise => { const partyType = PartyTypeEnum.NATURAL_PERSON const party: NonPersistedParty = { @@ -1784,6 +1830,28 @@ describe('Contact store tests', (): void => { await expect(contactStore.addParty(party)).rejects.toThrow(`Party type ${partyType}, does not match for provided contact`) }) + it('should throw error when adding student party with wrong contact type', async (): Promise => { + const partyType = PartyTypeEnum.NATURAL_PERSON + const party: NonPersistedParty = { + uri: 'example.com', + partyType: { + type: partyType, + tenantId: '0605761c-4113-4ce5-a6b2-9cbae2f9d289', + name: 'example_name', + }, + contact: { + firstName: 'example_first_name', + middleName: 'example_middle_name', + lastName: 'example_last_name', + displayName: 'example_display_name', + grade: '3rd', + dateOfBirth: new Date(2016, 2, 15) + }, + } + + await expect(contactStore.addParty(party)).rejects.toThrow(`Party type ${partyType}, does not match for provided contact`) + }) + it('should get electronic address by id', async (): Promise => { const party: NonPersistedParty = { uri: 'example.com', diff --git a/packages/data-store/src/contact/ContactStore.ts b/packages/data-store/src/contact/ContactStore.ts index 1a50ed9c1..2ee20cc4b 100644 --- a/packages/data-store/src/contact/ContactStore.ts +++ b/packages/data-store/src/contact/ContactStore.ts @@ -21,7 +21,7 @@ import { isDidAuthConfig, isNaturalPerson, isOpenIdConfig, - isOrganization, + isOrganization, isStudent, partyEntityFrom, partyFrom, partyRelationshipEntityFrom, @@ -639,6 +639,8 @@ export class ContactStore extends AbstractContactStore { return isNaturalPerson(contact) case PartyTypeEnum.ORGANIZATION: return isOrganization(contact) + case PartyTypeEnum.STUDENT: + return isStudent(contact) default: throw new Error('Party type not supported') } diff --git a/packages/data-store/src/entities/contact/PartyTypeEntity.ts b/packages/data-store/src/entities/contact/PartyTypeEntity.ts index 8436b615d..c07efd874 100644 --- a/packages/data-store/src/entities/contact/PartyTypeEntity.ts +++ b/packages/data-store/src/entities/contact/PartyTypeEntity.ts @@ -11,18 +11,18 @@ export class PartyTypeEntity { @PrimaryGeneratedColumn('uuid') id!: string - @Column('simple-enum', { name: 'type', enum: PartyTypeEnum, nullable: false, unique: false }) + @Column('simple-enum', { name: 'type', enum: PartyTypeEnum, nullable: false }) type!: PartyTypeEnum @Column({ name: 'name', length: 255, nullable: false, unique: true }) @IsNotEmpty({ message: 'Blank names are not allowed' }) name!: string - @Column({ name: 'description', length: 255, nullable: true, unique: false }) + @Column({ name: 'description', length: 255, nullable: true }) @Validate(IsNonEmptyStringConstraint, { message: 'Blank descriptions are not allowed' }) description?: string - @Column({ name: 'tenant_id', length: 255, nullable: false, unique: false }) + @Column({ name: 'tenant_id', length: 255, nullable: true }) @IsNotEmpty({ message: "Blank tenant id's are not allowed" }) tenantId!: string diff --git a/packages/data-store/src/entities/contact/StudentEntity.ts b/packages/data-store/src/entities/contact/StudentEntity.ts new file mode 100644 index 000000000..50cdb54d2 --- /dev/null +++ b/packages/data-store/src/entities/contact/StudentEntity.ts @@ -0,0 +1,51 @@ +import {BaseContactEntity} from "./BaseContactEntity"; +import {BeforeInsert, BeforeUpdate, ChildEntity, Column} from "typeorm"; +import {IsNotEmpty, Validate, ValidationError, validate} from "class-validator"; +import {IsNonEmptyStringConstraint} from "../validators"; +import {ValidationConstraint} from "../../types"; +import {getConstraint} from "../../utils/ValidatorUtils"; + +@ChildEntity('Student') +export class StudentEntity extends BaseContactEntity { + @Column({ name: 'first_name', length: 255, nullable: false, unique: false }) + @IsNotEmpty({ message: 'Blank first names are not allowed' }) + firstName!: string + + @Column({ name: 'middle_name', length: 255, nullable: true, unique: false }) + @Validate(IsNonEmptyStringConstraint, { message: 'Blank middle names are not allowed' }) + middleName?: string + + @Column({ name: 'last_name', length: 255, nullable: false, unique: false }) + @IsNotEmpty({ message: 'Blank last names are not allowed' }) + lastName!: string + + @Column({ name: 'grade', length: 3, nullable: false}) + @IsNotEmpty({ message: 'Blank grade is not allowed'}) + grade!: string + + @Column({ name: 'date_of_birth', nullable: false}) + dateOfBirth!: Date + + @Column({name:'owner_id', nullable:true}) + ownerId?: string + + @Column({name:'tenant_id', nullable:true}) + tenantId?: string + + @Column({ name: 'display_name', length: 255, nullable: false, unique: false }) + @IsNotEmpty({ message: 'Blank display names are not allowed' }) + displayName!: string + + @BeforeInsert() + @BeforeUpdate() + async validate(): Promise { + const validation: Array = await validate(this) + if (validation.length > 0) { + const constraint: ValidationConstraint | undefined = getConstraint(validation[0]) + if (constraint) { + const message: string = Object.values(constraint!)[0] + return Promise.reject(Error(message)) + } + } + } +} \ No newline at end of file diff --git a/packages/data-store/src/migrations/postgres/1710438363001-CreateContacts.ts b/packages/data-store/src/migrations/postgres/1710438363001-CreateContacts.ts index 8d958055d..fa72d6913 100644 --- a/packages/data-store/src/migrations/postgres/1710438363001-CreateContacts.ts +++ b/packages/data-store/src/migrations/postgres/1710438363001-CreateContacts.ts @@ -4,14 +4,14 @@ export class CreateContacts1710438363001 implements MigrationInterface { name = 'CreateContacts1710438363001' public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TYPE "identity_origin_type" AS ENUM('INTERNAL', 'EXTERNAL')`) + await queryRunner.query(`CREATE TYPE "public"."IdentityOrigin_type" AS ENUM('internal', 'external')`) await queryRunner.query(`ALTER TABLE "Party" ADD COLUMN "owner_id" text`); await queryRunner.query(`ALTER TABLE "Party" ADD COLUMN "tenant_id" text`); await queryRunner.query(`ALTER TABLE "Identity" ADD COLUMN "owner_id" text`); await queryRunner.query(`ALTER TABLE "Identity" ADD COLUMN "tenant_id" text`); - await queryRunner.query(`ALTER TABLE "Identity" ADD COLUMN "origin" varchar CHECK( "identity_origin_type" IN ('INTERNAL', 'EXTERNAL') ) NOT NULL`); + await queryRunner.query(`ALTER TABLE "Identity" ADD COLUMN "origin" "public"."IdentityOrigin_type" NOT NULL`); await queryRunner.query(`ALTER TABLE "CorrelationIdentifier" ADD COLUMN "owner_id" text`); await queryRunner.query(`ALTER TABLE "CorrelationIdentifier" ADD COLUMN "tenant_id" text`); @@ -24,6 +24,8 @@ export class CreateContacts1710438363001 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "BaseContact" ADD COLUMN "owner_id" text`); await queryRunner.query(`ALTER TABLE "BaseContact" ADD COLUMN "tenant_id" text`); + await queryRunner.query(`ALTER TABLE "BaseContact" ADD COLUMN "grade" text`); + await queryRunner.query(`ALTER TABLE "BaseContact" ADD COLUMN "date_of_birth" TIMESTAMP`); await queryRunner.query(`ALTER TABLE "PartyRelationship" ADD COLUMN "owner_id" text`); await queryRunner.query(`ALTER TABLE "PartyRelationship" ADD COLUMN "tenant_id" text`); @@ -33,7 +35,7 @@ export class CreateContacts1710438363001 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "PhysicalAddress" ADD COLUMN "owner_id" text`); await queryRunner.query(`ALTER TABLE "PhysicalAddress" ADD COLUMN "tenant_id" text`); - + await queryRunner.query(`ALTER TYPE "public"."PartyType_type_enum" ADD VALUE 'student'`) } public async down(queryRunner: QueryRunner): Promise { diff --git a/packages/data-store/src/migrations/sqlite/1710438363002-CreateContacts.ts b/packages/data-store/src/migrations/sqlite/1710438363002-CreateContacts.ts index dababe69d..8a103c1eb 100644 --- a/packages/data-store/src/migrations/sqlite/1710438363002-CreateContacts.ts +++ b/packages/data-store/src/migrations/sqlite/1710438363002-CreateContacts.ts @@ -22,6 +22,8 @@ export class CreateContacts1710438363002 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "BaseContact" ADD COLUMN "owner_id" text`); await queryRunner.query(`ALTER TABLE "BaseContact" ADD COLUMN "tenant_id" text`); + await queryRunner.query(`ALTER TABLE "BaseContact" ADD COLUMN "grade" text`); + await queryRunner.query(`ALTER TABLE "BaseContact" ADD COLUMN "date_of_birth" DATETIME`); await queryRunner.query(`ALTER TABLE "PartyRelationship" ADD COLUMN "owner_id" text`); await queryRunner.query(`ALTER TABLE "PartyRelationship" ADD COLUMN "tenant_id" text`); @@ -32,6 +34,13 @@ export class CreateContacts1710438363002 implements MigrationInterface { await queryRunner.query(`ALTER TABLE "PhysicalAddress" ADD COLUMN "owner_id" text`); await queryRunner.query(`ALTER TABLE "PhysicalAddress" ADD COLUMN "tenant_id" text`); + await queryRunner.query( + `CREATE TABLE "PartyType_new" ("id" varchar PRIMARY KEY NOT NULL, "type" varchar CHECK( "type" IN ('naturalPerson','organization','student') ) NOT NULL, "name" varchar(255) NOT NULL, "description" varchar(255), "tenant_id" varchar(255) NOT NULL, "created_at" datetime NOT NULL DEFAULT (datetime('now')), "last_updated_at" datetime NOT NULL DEFAULT (datetime('now')), CONSTRAINT "UQ_PartyType_name" UNIQUE ("name"))`, + ) + await queryRunner.query(`INSERT INTO "PartyType_new" SELECT * FROM "PartyType"`) + await queryRunner.query(`DROP TABLE "PartyType"`) + await queryRunner.query(`ALTER TABLE "PartyType_new" RENAME TO "PartyType"`) + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_PartyType_type_tenant_id" ON "PartyType" ("type", "tenant_id")`) } public async down(queryRunner: QueryRunner): Promise { diff --git a/packages/data-store/src/types/contact/contact.ts b/packages/data-store/src/types/contact/contact.ts index 63c1872fd..703fe1fad 100644 --- a/packages/data-store/src/types/contact/contact.ts +++ b/packages/data-store/src/types/contact/contact.ts @@ -153,6 +153,23 @@ export type NaturalPerson = { export type NonPersistedNaturalPerson = Omit export type PartialNaturalPerson = Partial +export type Student = { + id: string + firstName: string + lastName: string + middleName?: string + grade: string + dateOfBirth: Date + displayName: string + ownerId?: string + tenantId?: string + createdAt: Date + lastUpdatedAt: Date +} + +export type NonPersistedStudent = Omit +export type PartialStudent = Partial + export type Organization = { id: string legalName: string @@ -165,9 +182,9 @@ export type Organization = { export type NonPersistedOrganization = Omit export type PartialOrganization = Partial -export type Contact = NaturalPerson | Organization -export type NonPersistedContact = NonPersistedNaturalPerson | NonPersistedOrganization -export type PartialContact = PartialNaturalPerson | PartialOrganization +export type Contact = NaturalPerson | Organization | Student +export type NonPersistedContact = NonPersistedNaturalPerson | NonPersistedOrganization | NonPersistedStudent +export type PartialContact = PartialNaturalPerson | PartialOrganization | PartialStudent export type PartyType = { id: string @@ -258,4 +275,5 @@ export enum CorrelationIdentifierEnum { export enum PartyTypeEnum { NATURAL_PERSON = 'naturalPerson', ORGANIZATION = 'organization', + STUDENT = 'student' } diff --git a/packages/data-store/src/utils/contact/MappingUtils.ts b/packages/data-store/src/utils/contact/MappingUtils.ts index 36f584803..f4ba35ff6 100644 --- a/packages/data-store/src/utils/contact/MappingUtils.ts +++ b/packages/data-store/src/utils/contact/MappingUtils.ts @@ -23,13 +23,13 @@ import { NonPersistedParty, NonPersistedPartyRelationship, NonPersistedPartyType, - NonPersistedPhysicalAddress, + NonPersistedPhysicalAddress, NonPersistedStudent, OpenIdConfig, Organization, Party, PartyRelationship, PartyType, - PhysicalAddress, + PhysicalAddress, Student, } from '../../types' import {PartyEntity} from '../../entities/contact/PartyEntity' import {IdentityEntity} from '../../entities/contact/IdentityEntity' @@ -46,6 +46,7 @@ import {IdentityMetadataItemEntity} from '../../entities/contact/IdentityMetadat import {OpenIdConfigEntity} from '../../entities/contact/OpenIdConfigEntity' import {PartyTypeEntity} from '../../entities/contact/PartyTypeEntity' import {PhysicalAddressEntity} from '../../entities/contact/PhysicalAddressEntity' +import {StudentEntity} from "../../entities/contact/StudentEntity"; export const partyEntityFrom = (party: NonPersistedParty): PartyEntity => { const partyEntity: PartyEntity = new PartyEntity() @@ -92,6 +93,8 @@ export const contactEntityFrom = (contact: NonPersistedContact): BaseContactEnti return naturalPersonEntityFrom(contact) } else if (isOrganization(contact)) { return organizationEntityFrom(contact) + } else if (isStudent(contact)) { + return studentEntityFrom(contact) } throw new Error('Contact not supported') @@ -102,17 +105,22 @@ export const contactFrom = (contact: BaseContactEntity): Contact => { return naturalPersonFrom(contact) } else if (isOrganization(contact)) { return organizationFrom(contact) + } else if (isStudent(contact)) { + return studentFrom(contact) } throw new Error(`Contact type not supported`) } export const isNaturalPerson = (contact: NonPersistedContact | BaseContactEntity): contact is NonPersistedNaturalPerson | NaturalPersonEntity => - 'firstName' in contact && 'lastName' in contact + 'firstName' in contact && 'lastName' in contact && !('grade' in contact) && !('dateOfBirth' in contact) export const isOrganization = (contact: NonPersistedContact | BaseContactEntity): contact is NonPersistedOrganization | OrganizationEntity => 'legalName' in contact +export const isStudent = (contact: NonPersistedContact | BaseContactEntity): contact is NonPersistedStudent | StudentEntity => + 'grade' in contact && 'dateOfBirth' in contact + export const connectionEntityFrom = (connection: NonPersistedConnection): ConnectionEntity => { const connectionEntity: ConnectionEntity = new ConnectionEntity() connectionEntity.type = connection.type @@ -326,6 +334,36 @@ export const organizationEntityFrom = (organization: NonPersistedOrganization): return organizationEntity } +export const studentEntityFrom = (student: NonPersistedStudent): StudentEntity => { + const studentEntity: StudentEntity = new StudentEntity() + studentEntity.displayName = student.displayName + studentEntity.firstName = student.firstName + studentEntity.middleName = student.middleName + studentEntity.lastName = student.lastName + studentEntity.grade = student.grade + studentEntity.dateOfBirth = student.dateOfBirth + studentEntity.ownerId = student.ownerId + studentEntity.tenantId = student.tenantId + + return studentEntity +} + +export const studentFrom = (student: StudentEntity): Student => { + return { + id: student.id, + displayName: student.displayName, + firstName: student.firstName, + middleName: student.middleName, + lastName: student.lastName, + grade: student.grade, + dateOfBirth: student.dateOfBirth, + ownerId: student.ownerId, + tenantId: student.tenantId, + createdAt: student.createdAt, + lastUpdatedAt: student.lastUpdatedAt, + } +} + export const organizationFrom = (organization: OrganizationEntity): Organization => { return { id: organization.id,