diff --git a/.env.example b/.env.example index 7f9770aa3..3595585e7 100644 --- a/.env.example +++ b/.env.example @@ -60,3 +60,9 @@ FILE_STORAGE= AWS_S3_BUCKET_NAME= AWS_S3_DOMAIN= + +// no of days delay the follow up for interaction +INTERACTION_FOLLOWUP_DELAY_IN_DAYS= + +// no of milliseconds delay in between interacitons +INTERACTION_INTERVAL_DELAY_IN_MILLISECONDS= diff --git a/.forestadmin-schema.json b/.forestadmin-schema.json index 633b047a2..10094ba49 100644 --- a/.forestadmin-schema.json +++ b/.forestadmin-schema.json @@ -1674,6 +1674,74 @@ "type": "String", "validations": [] }, + { + "defaultValue": null, + "enums": null, + "field": "MemberFeedbacks", + "integration": null, + "inverseOf": "Member", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": "MemberFeedback.uid", + "relationship": "HasMany", + "type": ["String"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "MemberFollowUps", + "integration": null, + "inverseOf": "Member", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": "MemberFollowUp.uid", + "relationship": "HasMany", + "type": ["String"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "MemberInteractions_through_Member_sourceMemberUid", + "integration": null, + "inverseOf": "Member_through_sourceMemberUid", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": "MemberInteraction.uid", + "relationship": "HasMany", + "type": ["String"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "MemberInteractions_through_Member_targetMemberUid", + "integration": null, + "inverseOf": "Member_through_targetMemberUid", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": "MemberInteraction.uid", + "relationship": "HasMany", + "type": ["String"], + "validations": [] + }, { "defaultValue": null, "enums": null, @@ -1752,84 +1820,580 @@ "isPrimaryKey": false, "isReadOnly": false, "isRequired": false, - "isSortable": false, + "isSortable": false, + "isVirtual": false, + "reference": "TeamMemberRole.uid", + "relationship": "HasMany", + "type": ["String"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "Teams", + "integration": null, + "inverseOf": "Member", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": "Team.uid", + "relationship": "HasMany", + "type": ["String"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "_MemberToMemberRoles", + "integration": null, + "inverseOf": "Member", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": "_MemberToMemberRole.id", + "relationship": "HasMany", + "type": ["Number"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "_MemberToSkills", + "integration": null, + "inverseOf": "Member", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": "_MemberToSkill.id", + "relationship": "HasMany", + "type": ["Number"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "airtableRecId", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "approvedAt", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Date", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "createdAt", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Date", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "discordHandler", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "email", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "externalId", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "githubHandler", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Number", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "linkedinHandler", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "moreDetails", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "name", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "officeHours", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": false, + "enums": null, + "field": "openToWork", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Boolean", + "validations": [] + }, + { + "defaultValue": false, + "enums": null, + "field": "plnFriend", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Boolean", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "plnStartDate", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Date", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "preferences", + "integration": null, + "inverseOf": null, + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Json", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "telegramHandler", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "twitterHandler", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "uid", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "updatedAt", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Date", + "validations": [] + } + ], + "icon": null, + "integration": null, + "isReadOnly": false, + "isSearchable": true, + "isVirtual": false, + "name": "Member", + "onlyForRelationships": false, + "paginationType": "page", + "segments": [] + }, + { + "actions": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "Member", + "integration": null, + "inverseOf": "MemberFeedbacks", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "Member.uid", + "relationship": "BelongsTo", + "type": "String", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "MemberFollowUp", + "integration": null, + "inverseOf": "MemberFeedback", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "MemberFollowUp.uid", + "relationship": "BelongsTo", + "type": "String", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "comments", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": ["String"], + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "createdAt", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Date", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "data", + "integration": null, + "inverseOf": null, + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Json", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, + "isRequired": false, + "isSortable": true, "isVirtual": false, - "reference": "TeamMemberRole.uid", - "relationship": "HasMany", - "type": ["String"], + "reference": null, + "type": "Number", "validations": [] }, { "defaultValue": null, "enums": null, - "field": "Teams", + "field": "rating", "integration": null, - "inverseOf": "Member", - "isFilterable": false, + "inverseOf": null, + "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, "isRequired": false, - "isSortable": false, + "isSortable": true, "isVirtual": false, - "reference": "Team.uid", - "relationship": "HasMany", - "type": ["String"], + "reference": null, + "type": "Number", "validations": [] }, { "defaultValue": null, - "enums": null, - "field": "_MemberToMemberRoles", + "enums": ["POSITIVE", "NEGATIVE", "NEUTRAL"], + "field": "response", "integration": null, - "inverseOf": "Member", - "isFilterable": false, + "inverseOf": null, + "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, - "isRequired": false, - "isSortable": false, + "isRequired": true, + "isSortable": true, "isVirtual": false, - "reference": "_MemberToMemberRole.id", - "relationship": "HasMany", - "type": ["Number"], - "validations": [] + "reference": null, + "type": "Enum", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] }, { "defaultValue": null, "enums": null, - "field": "_MemberToSkills", + "field": "type", "integration": null, - "inverseOf": "Member", - "isFilterable": false, + "inverseOf": null, + "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, - "isRequired": false, - "isSortable": false, + "isRequired": true, + "isSortable": true, "isVirtual": false, - "reference": "_MemberToSkill.id", - "relationship": "HasMany", - "type": ["Number"], - "validations": [] + "reference": null, + "type": "String", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] }, { "defaultValue": null, "enums": null, - "field": "airtableRecId", + "field": "uid", "integration": null, "inverseOf": null, "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, - "isRequired": false, + "isRequired": true, "isSortable": true, "isVirtual": false, "reference": null, "type": "String", - "validations": [] + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] }, { "defaultValue": null, "enums": null, - "field": "createdAt", + "field": "updatedAt", "integration": null, "inverseOf": null, "isFilterable": true, @@ -1841,43 +2405,78 @@ "reference": null, "type": "Date", "validations": [] + } + ], + "icon": null, + "integration": null, + "isReadOnly": false, + "isSearchable": true, + "isVirtual": false, + "name": "MemberFeedback", + "onlyForRelationships": false, + "paginationType": "page", + "segments": [] + }, + { + "actions": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "Member", + "integration": null, + "inverseOf": "MemberFollowUps", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": "Member.uid", + "relationship": "BelongsTo", + "type": "String", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] }, { "defaultValue": null, "enums": null, - "field": "discordHandler", + "field": "MemberFeedback", "integration": null, - "inverseOf": null, + "inverseOf": "MemberFollowUp", "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, "isRequired": false, "isSortable": true, "isVirtual": false, - "reference": null, + "reference": "MemberFeedback.uid", + "relationship": "HasOne", "type": "String", "validations": [] }, { "defaultValue": null, "enums": null, - "field": "email", + "field": "MemberInteraction", "integration": null, - "inverseOf": null, + "inverseOf": "MemberFollowUps", "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, "isRequired": false, "isSortable": true, "isVirtual": false, - "reference": null, + "reference": "MemberInteraction.uid", + "relationship": "BelongsTo", "type": "String", "validations": [] }, { "defaultValue": null, "enums": null, - "field": "externalId", + "field": "createdAt", "integration": null, "inverseOf": null, "isFilterable": true, @@ -1887,23 +2486,23 @@ "isSortable": true, "isVirtual": false, "reference": null, - "type": "String", + "type": "Date", "validations": [] }, { "defaultValue": null, "enums": null, - "field": "githubHandler", + "field": "data", "integration": null, "inverseOf": null, - "isFilterable": true, + "isFilterable": false, "isPrimaryKey": false, "isReadOnly": false, "isRequired": false, "isSortable": true, "isVirtual": false, "reference": null, - "type": "String", + "type": "Json", "validations": [] }, { @@ -1923,41 +2522,45 @@ "validations": [] }, { - "defaultValue": null, + "defaultValue": false, "enums": null, - "field": "linkedinHandler", + "field": "isDelayed", "integration": null, "inverseOf": null, "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, - "isRequired": false, + "isRequired": true, "isSortable": true, "isVirtual": false, "reference": null, - "type": "String", - "validations": [] + "type": "Boolean", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] }, { "defaultValue": null, - "enums": null, - "field": "moreDetails", + "enums": ["PENDING", "COMPLETED", "CLOSED"], + "field": "status", "integration": null, "inverseOf": null, "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, - "isRequired": false, + "isRequired": true, "isSortable": true, "isVirtual": false, "reference": null, - "type": "String", - "validations": [] + "type": "Enum", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] }, { "defaultValue": null, "enums": null, - "field": "name", + "field": "type", "integration": null, "inverseOf": null, "isFilterable": true, @@ -1975,23 +2578,25 @@ { "defaultValue": null, "enums": null, - "field": "officeHours", + "field": "uid", "integration": null, "inverseOf": null, "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, - "isRequired": false, + "isRequired": true, "isSortable": true, "isVirtual": false, "reference": null, "type": "String", - "validations": [] + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] }, { - "defaultValue": false, + "defaultValue": null, "enums": null, - "field": "openToWork", + "field": "updatedAt", "integration": null, "inverseOf": null, "isFilterable": true, @@ -2001,23 +2606,55 @@ "isSortable": true, "isVirtual": false, "reference": null, - "type": "Boolean", + "type": "Date", + "validations": [] + } + ], + "icon": null, + "integration": null, + "isReadOnly": false, + "isSearchable": true, + "isVirtual": false, + "name": "MemberFollowUp", + "onlyForRelationships": false, + "paginationType": "page", + "segments": [] + }, + { + "actions": [], + "fields": [ + { + "defaultValue": null, + "enums": null, + "field": "MemberFollowUps", + "integration": null, + "inverseOf": "MemberInteraction", + "isFilterable": false, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": false, + "isVirtual": false, + "reference": "MemberFollowUp.uid", + "relationship": "HasMany", + "type": ["String"], "validations": [] }, { - "defaultValue": false, + "defaultValue": null, "enums": null, - "field": "plnFriend", + "field": "Member_through_sourceMemberUid", "integration": null, - "inverseOf": null, + "inverseOf": "MemberInteractions_through_Member_sourceMemberUid", "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, "isRequired": true, "isSortable": true, "isVirtual": false, - "reference": null, - "type": "Boolean", + "reference": "Member.uid", + "relationship": "BelongsTo", + "type": "String", "validations": [ {"type": "is present", "message": "Failed validation rule: 'Present'"} ] @@ -2025,7 +2662,24 @@ { "defaultValue": null, "enums": null, - "field": "plnStartDate", + "field": "Member_through_targetMemberUid", + "integration": null, + "inverseOf": "MemberInteractions_through_Member_targetMemberUid", + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": "Member.uid", + "relationship": "BelongsTo", + "type": "String", + "validations": [] + }, + { + "defaultValue": null, + "enums": null, + "field": "createdAt", "integration": null, "inverseOf": null, "isFilterable": true, @@ -2041,7 +2695,7 @@ { "defaultValue": null, "enums": null, - "field": "preferences", + "field": "data", "integration": null, "inverseOf": null, "isFilterable": false, @@ -2055,36 +2709,56 @@ "validations": [] }, { - "defaultValue": null, + "defaultValue": false, "enums": null, - "field": "telegramHandler", + "field": "hasFollowUp", "integration": null, "inverseOf": null, "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, + "isRequired": true, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "Boolean", + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] + }, + { + "defaultValue": null, + "enums": null, + "field": "id", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": true, + "isReadOnly": true, "isRequired": false, "isSortable": true, "isVirtual": false, "reference": null, - "type": "String", + "type": "Number", "validations": [] }, { "defaultValue": null, "enums": null, - "field": "twitterHandler", + "field": "type", "integration": null, "inverseOf": null, "isFilterable": true, "isPrimaryKey": false, "isReadOnly": false, - "isRequired": false, + "isRequired": true, "isSortable": true, "isVirtual": false, "reference": null, "type": "String", - "validations": [] + "validations": [ + {"type": "is present", "message": "Failed validation rule: 'Present'"} + ] }, { "defaultValue": null, @@ -2126,7 +2800,7 @@ "isReadOnly": false, "isSearchable": true, "isVirtual": false, - "name": "Member", + "name": "MemberInteraction", "onlyForRelationships": false, "paginationType": "page", "segments": [] @@ -2834,6 +3508,22 @@ "type": "Number", "validations": [] }, + { + "defaultValue": null, + "enums": null, + "field": "officeHours", + "integration": null, + "inverseOf": null, + "isFilterable": true, + "isPrimaryKey": false, + "isReadOnly": false, + "isRequired": false, + "isSortable": true, + "isVirtual": false, + "reference": null, + "type": "String", + "validations": [] + }, { "defaultValue": null, "enums": null, diff --git a/apps/web-api/prisma/migrations/20240731093436_member_interaction_follow_up/migration.sql b/apps/web-api/prisma/migrations/20240731093436_member_interaction_follow_up/migration.sql new file mode 100644 index 000000000..a4aaa0297 --- /dev/null +++ b/apps/web-api/prisma/migrations/20240731093436_member_interaction_follow_up/migration.sql @@ -0,0 +1,83 @@ +-- CreateEnum +CREATE TYPE "MemberFollowUpStatus" AS ENUM ('PENDING', 'COMPLETED'); + +-- CreateEnum +CREATE TYPE "MemberFeedbackResponseType" AS ENUM ('POSITIVE', 'NEGATIVE', 'NEUTRAL'); + +-- CreateTable +CREATE TABLE "MemberInteraction" ( + "id" SERIAL NOT NULL, + "uid" TEXT NOT NULL, + "type" TEXT NOT NULL, + "data" JSONB, + "hasFollowUp" BOOLEAN NOT NULL DEFAULT false, + "sourceMemberUid" TEXT NOT NULL, + "targetMemberUid" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MemberInteraction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MemberFollowUp" ( + "id" SERIAL NOT NULL, + "uid" TEXT NOT NULL, + "status" "MemberFollowUpStatus" NOT NULL, + "type" TEXT NOT NULL, + "data" JSONB, + "isDelayed" BOOLEAN NOT NULL DEFAULT false, + "interactionUid" TEXT, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MemberFollowUp_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "MemberFeedback" ( + "id" SERIAL NOT NULL, + "uid" TEXT NOT NULL, + "type" TEXT NOT NULL, + "data" JSONB, + "rating" INTEGER, + "comments" TEXT[], + "response" "MemberFeedbackResponseType" NOT NULL, + "followUpUid" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MemberFeedback_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "MemberInteraction_uid_key" ON "MemberInteraction"("uid"); + +-- CreateIndex +CREATE UNIQUE INDEX "MemberFollowUp_uid_key" ON "MemberFollowUp"("uid"); + +-- CreateIndex +CREATE UNIQUE INDEX "MemberFeedback_uid_key" ON "MemberFeedback"("uid"); + +-- CreateIndex +CREATE UNIQUE INDEX "MemberFeedback_followUpUid_key" ON "MemberFeedback"("followUpUid"); + +-- AddForeignKey +ALTER TABLE "MemberInteraction" ADD CONSTRAINT "MemberInteraction_sourceMemberUid_fkey" FOREIGN KEY ("sourceMemberUid") REFERENCES "Member"("uid") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberInteraction" ADD CONSTRAINT "MemberInteraction_targetMemberUid_fkey" FOREIGN KEY ("targetMemberUid") REFERENCES "Member"("uid") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberFollowUp" ADD CONSTRAINT "MemberFollowUp_interactionUid_fkey" FOREIGN KEY ("interactionUid") REFERENCES "MemberInteraction"("uid") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberFollowUp" ADD CONSTRAINT "MemberFollowUp_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "Member"("uid") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberFeedback" ADD CONSTRAINT "MemberFeedback_followUpUid_fkey" FOREIGN KEY ("followUpUid") REFERENCES "MemberFollowUp"("uid") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MemberFeedback" ADD CONSTRAINT "MemberFeedback_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "Member"("uid") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/web-api/prisma/migrations/20240807080204_member_interaction_followup_type/migration.sql b/apps/web-api/prisma/migrations/20240807080204_member_interaction_followup_type/migration.sql new file mode 100644 index 000000000..041c7cb15 --- /dev/null +++ b/apps/web-api/prisma/migrations/20240807080204_member_interaction_followup_type/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "MemberFollowUpStatus" ADD VALUE 'CLOSED'; diff --git a/apps/web-api/prisma/schema.prisma b/apps/web-api/prisma/schema.prisma index 5efb00601..626aaaf08 100644 --- a/apps/web-api/prisma/schema.prisma +++ b/apps/web-api/prisma/schema.prisma @@ -82,6 +82,10 @@ model Member { eventGuests PLEventGuest[] teamFocusAreasVersionHistory TeamFocusAreaVersionHistory[] modifiedTeams Team[] @relation("LastModification") + interactions MemberInteraction[] @relation("SourceMemberInteractions") + targetInteractions MemberInteraction[] @relation("TargetMemberInteractions") + followUps MemberFollowUp[] + feedbacks MemberFeedback[] } model MemberRole { @@ -109,6 +113,26 @@ enum PLEventType { INVITE_ONLY } +enum MemberFollowUpStatus { + PENDING + COMPLETED + CLOSED +} + +enum MemberFeedbackResponseType { + POSITIVE + NEGATIVE + NEUTRAL +} + +enum ImageSize { + ORIGINAL + LARGE + MEDIUM + SMALL + TINY +} + model ParticipantsRequest { id Int @id @default(autoincrement()) uid String @unique @default(cuid()) @@ -425,10 +449,49 @@ model TeamFocusAreaVersionHistory { @@unique([focusAreaUid, teamUid, version]) } -enum ImageSize { - ORIGINAL - LARGE - MEDIUM - SMALL - TINY +model MemberInteraction { + id Int @id @default(autoincrement()) + uid String @unique @default(cuid()) + type String + data Json? + hasFollowUp Boolean @default(false) + sourceMemberUid String + sourceMember Member @relation("SourceMemberInteractions", fields: [sourceMemberUid], references: [uid]) + targetMemberUid String? + targetMember Member? @relation("TargetMemberInteractions", fields: [targetMemberUid], references: [uid]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + interactionFollowUps MemberFollowUp[] +} + +model MemberFollowUp { + id Int @id @default(autoincrement()) + uid String @unique @default(cuid()) + status MemberFollowUpStatus + type String + data Json? + isDelayed Boolean @default(false) + interactionUid String? + interaction MemberInteraction? @relation(fields: [interactionUid], references: [uid]) + createdBy String + creator Member @relation(fields: [createdBy], references: [uid]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + feedbacks MemberFeedback? +} + +model MemberFeedback { + id Int @id @default(autoincrement()) + uid String @unique @default(cuid()) + type String + data Json? + rating Int? + comments String[] + response MemberFeedbackResponseType + followUpUid String @unique + followUp MemberFollowUp @relation(fields: [followUpUid], references: [uid]) + createdBy String + creator Member @relation(fields: [createdBy], references: [uid]) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt } diff --git a/apps/web-api/src/app.module.ts b/apps/web-api/src/app.module.ts index 509596275..b6c54d3f0 100644 --- a/apps/web-api/src/app.module.ts +++ b/apps/web-api/src/app.module.ts @@ -35,6 +35,9 @@ import { JoinRequestsModule } from './join-requests/join-requests.module'; import { FocusAreasModule } from './focus-areas/focus-areas.module'; import { PLEventsModule } from './pl-events/pl-events.module'; import { EmptyStringToNullInterceptor } from './interceptors/empty-string-to-null.interceptor'; +import { OfficeHoursModule } from './office-hours/office-hours.module'; +import { MemberFollowUpsModule } from './member-follow-ups/member-follow-ups.module'; +import { MemberFeedbacksModule } from './member-feedbacks/member-feedbacks.module'; @Module({ controllers: [AppController], @@ -84,7 +87,10 @@ import { EmptyStringToNullInterceptor } from './interceptors/empty-string-to-nul ProjectsModule, JoinRequestsModule, FocusAreasModule, - PLEventsModule + PLEventsModule, + OfficeHoursModule, + MemberFollowUpsModule, + MemberFeedbacksModule ], providers: [ { diff --git a/apps/web-api/src/member-feedbacks/member-feedbacks.module.ts b/apps/web-api/src/member-feedbacks/member-feedbacks.module.ts new file mode 100644 index 000000000..1197b46a6 --- /dev/null +++ b/apps/web-api/src/member-feedbacks/member-feedbacks.module.ts @@ -0,0 +1,17 @@ +import { Module } from '@nestjs/common'; +import { MemberFeedbacksService } from './member-feedbacks.service' +import { MemberFollowUpsModule } from '../member-follow-ups/member-follow-ups.module'; + +@Module({ + imports: [ + MemberFollowUpsModule + ], + controllers: [], + providers: [ + MemberFeedbacksService + ], + exports: [ + MemberFeedbacksService + ] +}) +export class MemberFeedbacksModule {} \ No newline at end of file diff --git a/apps/web-api/src/member-feedbacks/member-feedbacks.service.ts b/apps/web-api/src/member-feedbacks/member-feedbacks.service.ts new file mode 100644 index 000000000..6732fecda --- /dev/null +++ b/apps/web-api/src/member-feedbacks/member-feedbacks.service.ts @@ -0,0 +1,62 @@ +import { + Injectable, + BadRequestException, + ConflictException, + NotFoundException +} from '@nestjs/common'; +import { LogService } from '../shared/log.service'; +import { PrismaService } from '../shared/prisma.service'; +import { Prisma } from '@prisma/client'; +import { MemberFollowUpsService } from '../member-follow-ups/member-follow-ups.service'; +import { + MemberFollowUpStatus +} from 'libs/contracts/src/schema'; + +@Injectable() +export class MemberFeedbacksService { + constructor( + private prisma: PrismaService, + private logger: LogService, + private followUpService: MemberFollowUpsService + ) {} + + async createFeedback( + feedback: Prisma.MemberFeedbackUncheckedCreateInput, + loggedInMember, + followUp, + tx?: Prisma.TransactionClient + ) { + try { + const result = await (tx || this.prisma).memberFeedback.create({ + data: { + ...feedback, + createdBy: loggedInMember.uid, + followUpUid: followUp.uid + } + }); + await this.followUpService.updateFollowUpStatusByUid(followUp.uid, MemberFollowUpStatus.Enum.COMPLETED, tx) + return result; + } catch(error) { + this.handleErrors(error); + } + }; + + private handleErrors(error, message?) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException('Unique key constraint error on interaction feed back:', error.message); + case 'P2003': + throw new BadRequestException('Foreign key constraint error on interaction feed back', error.message); + case 'P2025': + throw new NotFoundException('Interaction Feed back is not found with uid:' + message); + default: + throw error; + } + } else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException('Database field validation error on interaction feed back', error.message); + } + throw error; + }; +} \ No newline at end of file diff --git a/apps/web-api/src/member-follow-ups/member-follow-ups.module.ts b/apps/web-api/src/member-follow-ups/member-follow-ups.module.ts new file mode 100644 index 000000000..15cca6daf --- /dev/null +++ b/apps/web-api/src/member-follow-ups/member-follow-ups.module.ts @@ -0,0 +1,14 @@ +import { Module } from '@nestjs/common' +import { MemberFollowUpsService } from './member-follow-ups.service'; + +@Module({ + imports: [], + controllers: [], + providers: [ + MemberFollowUpsService + ], + exports: [ + MemberFollowUpsService + ] +}) +export class MemberFollowUpsModule {} \ No newline at end of file diff --git a/apps/web-api/src/member-follow-ups/member-follow-ups.service.ts b/apps/web-api/src/member-follow-ups/member-follow-ups.service.ts new file mode 100644 index 000000000..4af5df4ae --- /dev/null +++ b/apps/web-api/src/member-follow-ups/member-follow-ups.service.ts @@ -0,0 +1,126 @@ +import { + Injectable, + BadRequestException, + ConflictException, + NotFoundException +} from '@nestjs/common'; +import { LogService } from '../shared/log.service'; +import { PrismaService } from '../shared/prisma.service'; +import { Prisma } from '@prisma/client'; + +@Injectable() +export class MemberFollowUpsService { + constructor( + private prisma: PrismaService, + private logger: LogService + ) {} + + async createFollowUp( + followUp: Prisma.MemberFollowUpUncheckedCreateInput, + interaction, + tx?: Prisma.TransactionClient + ) { + try { + await (tx || this.prisma).memberFollowUp.create({ + data: { + ...followUp + } + }); + } catch(error) { + this.handleErrors(error); + } + } + + async getFollowUps( + query: Prisma.MemberFollowUpFindManyArgs, + tx?: Prisma.TransactionClient + ) { + try { + return await (tx || this.prisma).memberFollowUp.findMany({ + ...query, + include: { + interaction: { + select: { + uid: true, + type: true, + sourceMember: { + select: { + name:true, + image:true + } + }, + targetMember: { + select: { + name:true, + image:true + } + } + } + } + } + }); + } catch(error) { + this.handleErrors(error); + } + } + + async updateFollowUpStatusByUid( + uid: string, + status, + tx?: Prisma.TransactionClient + ) { + try { + return await (tx || this.prisma).memberFollowUp.update({ + where: { + uid + }, + data: { + status + } + }); + } catch(error) { + this.handleErrors(error); + } + } + + buildDelayedFollowUpQuery() { + const daysAgo = parseInt(process.env.INTERACTION_FOLLOWUP_DELAY_IN_DAYS || "7") + const dateOfNthWeekAgo = new Date(); + dateOfNthWeekAgo.setDate(dateOfNthWeekAgo.getDate() - daysAgo); + return { + OR: [ + { + isDelayed: false , + createdAt: { + lte: new Date() + } + }, + { + isDelayed: true, + createdAt: { + lte: dateOfNthWeekAgo, + } + } + ] + } + }; + + private handleErrors(error, message?) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException('Unique key constraint error on follow ups:', error.message); + case 'P2003': + throw new BadRequestException('Foreign key constraint error on follow ups', error.message); + case 'P2025': + throw new NotFoundException('Follow up is not found with uid:' + message); + default: + throw error; + } + } else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException('Database field validation error on follow ups', error.message); + } + throw error; + }; +} diff --git a/apps/web-api/src/office-hours/office-hours.controller.ts b/apps/web-api/src/office-hours/office-hours.controller.ts new file mode 100644 index 000000000..ca1ac4707 --- /dev/null +++ b/apps/web-api/src/office-hours/office-hours.controller.ts @@ -0,0 +1,152 @@ +import { Body, Controller, NotFoundException, Req, UseGuards, UsePipes, Param, ForbiddenException } from '@nestjs/common'; +import { Api, initNestServer } from '@ts-rest/nest'; +import { Request } from 'express'; +import { NoCache } from '../decorators/no-cache.decorator'; +import { apiMemberInteractions } from '../../../../libs/contracts/src/lib/contract-member-interaction'; +import { MembersService } from '../members/members.service'; +import { ZodValidationPipe } from 'nestjs-zod'; +import { PrismaQueryBuilder } from '../utils/prisma-query-builder'; +import { prismaQueryableFieldsFromZod } from '../utils/prisma-queryable-fields-from-zod'; +import { UserTokenValidation } from '../guards/user-token-validation.guard'; +import { + CreateMemberInteractionSchemaDto, + CreateMemberFeedbackSchemaDto, + MemberFollowUpQueryParams, + ResponseMemberFollowUpWithRelationsSchema, + MemberFollowUpStatus, + MemberFollowUpType +} from 'libs/contracts/src/schema'; +import { ApiQueryFromZod } from '../decorators/api-query-from-zod'; +import { ApiOkResponseFromZod } from '../decorators/api-response-from-zod'; +import { OfficeHoursService } from './office-hours.service'; +import { MemberFollowUpsService } from '../member-follow-ups/member-follow-ups.service'; + +const server = initNestServer(apiMemberInteractions); + +@Controller() +@NoCache() +export class OfficeHoursController { + constructor( + private readonly memberService: MembersService, + private readonly interactionService: OfficeHoursService, + private readonly followUpService: MemberFollowUpsService + ) {} + + @Api(server.route.createMemberInteraction) + @UsePipes(ZodValidationPipe) + @UseGuards(UserTokenValidation) + async createMemberInteraction( + @Body() body: CreateMemberInteractionSchemaDto, + @Req() request: Request + ): Promise { + const member: any = await this.memberService.findMemberByEmail(request["userEmail"]); + const interval = parseInt(process.env.INTERACTION_INTERVAL_DELAY_IN_MILLISECONDS || '1800000') + const result: any = await this.interactionService.findInteractions({ + where: { + sourceMemberUid: member?.uid, + targetMemberUid: body?.targetMemberUid, + createdAt: { + gte: new Date(new Date().getTime() - interval) + } + }, + include: { + interactionFollowUps: { + where: { + type: MemberFollowUpType.Enum.MEETING_INITIATED, + status: { + in: [ MemberFollowUpStatus.Enum.PENDING, MemberFollowUpStatus.Enum.CLOSED ] + } + } + } + }, + orderBy: { + createdAt: 'desc' + } + }); + if (result && result.length > 0 && result[0]?.interactionFollowUps?.length > 0) { + throw new ForbiddenException(`Interaction with same user within ${interval / (60 * 1000)} minutes is forbidden`); + } + if (member.uid === body.targetMemberUid) { + throw new ForbiddenException('Interaction with yourself is forbidden'); + } + return await this.interactionService.createInteraction(body as any, member); + } + + @Api(server.route.getMemberInteractionFollowUps) + @ApiQueryFromZod(MemberFollowUpQueryParams) + @ApiOkResponseFromZod(ResponseMemberFollowUpWithRelationsSchema.array()) + @UseGuards(UserTokenValidation) + @NoCache() + async findAll( + @Req() request: Request + ) { + const queryableFields = prismaQueryableFieldsFromZod( + ResponseMemberFollowUpWithRelationsSchema + ); + const { status } : any = request.query; + delete request.query.status; + const builder = new PrismaQueryBuilder(queryableFields); + const builtQuery = builder.build(request.query); + const member: any = await this.memberService.findMemberByEmail(request["userEmail"]); + builtQuery.where = { + AND: [ + builtQuery.where, + { + createdBy: member?.uid, + status: status ? { in: status.split(',') } : {} + }, + this.followUpService.buildDelayedFollowUpQuery() + ] + } + return this.followUpService.getFollowUps(builtQuery); + } + + @Api(server.route.createMemberInteractionFeedback) + @UsePipes(ZodValidationPipe) + @UseGuards(UserTokenValidation) + async createMemberInteractionFeedback( + @Param('uid') interactionFollowUpUid: string, + @Body() body: CreateMemberFeedbackSchemaDto, + @Req() request: Request + ): Promise { + const member: any = await this.memberService.findMemberByEmail(request["userEmail"]); + const followUps = await this.followUpService.getFollowUps({ + where: { + uid : interactionFollowUpUid, + createdBy: member?.uid, + status: { + in: [ MemberFollowUpStatus.Enum.PENDING, MemberFollowUpStatus.Enum.CLOSED ] + } + } + }); + console.log(followUps) + if (followUps && followUps.length === 0) { + throw new NotFoundException(`There is no follow-up associated with the given ID: ${interactionFollowUpUid}`); + } + return await this.interactionService.createInteractionFeedback(body as any, member, followUps?.[0]); + } + + @Api(server.route.closeMemberInteractionFollowUp) + @UsePipes(ZodValidationPipe) + @UseGuards(UserTokenValidation) + async closeMemberInteractionFollowUp( + @Param('interactionUid') interactionUid: string, + @Param('followUpUid') followUpUid: string, + @Req() request: Request + ): Promise { + const member: any = await this.memberService.findMemberByEmail(request["userEmail"]); + const followUps = await this.followUpService.getFollowUps({ + where: { + uid : followUpUid, + interactionUid, + createdBy: member?.uid, + status: MemberFollowUpStatus.Enum.PENDING + } + }); + if (followUps && followUps.length === 0) { + throw new NotFoundException(`No pending follow-up found for the given ID: ${followUpUid}. + It may have been closed or does not exist.`); + } + return await this.interactionService.closeMemberInteractionFollowUpByID(followUpUid); + } +} diff --git a/apps/web-api/src/office-hours/office-hours.module.ts b/apps/web-api/src/office-hours/office-hours.module.ts new file mode 100644 index 000000000..44f4ff2ec --- /dev/null +++ b/apps/web-api/src/office-hours/office-hours.module.ts @@ -0,0 +1,22 @@ +import { Module } from '@nestjs/common'; +import { MembersModule } from '../members/members.module'; +import { OfficeHoursService } from './office-hours.service'; +import { OfficeHoursController } from './office-hours.controller'; +import { MemberFollowUpsModule } from '../member-follow-ups/member-follow-ups.module'; +import { MemberFeedbacksModule } from '../member-feedbacks/member-feedbacks.module'; + +@Module({ + imports: [ + MembersModule, + MemberFollowUpsModule, + MemberFeedbacksModule + ], + controllers: [OfficeHoursController], + providers: [ + OfficeHoursService + ], + exports: [ + OfficeHoursService + ] +}) +export class OfficeHoursModule {} \ No newline at end of file diff --git a/apps/web-api/src/office-hours/office-hours.service.ts b/apps/web-api/src/office-hours/office-hours.service.ts new file mode 100644 index 000000000..643c38d19 --- /dev/null +++ b/apps/web-api/src/office-hours/office-hours.service.ts @@ -0,0 +1,156 @@ +import { + Injectable, + BadRequestException, + ConflictException, + NotFoundException +} from '@nestjs/common'; +import { LogService } from '../shared/log.service'; +import { PrismaService } from '../shared/prisma.service'; +import { MemberFollowUpsService } from '../member-follow-ups/member-follow-ups.service'; +import { MemberFeedbacksService } from '../member-feedbacks/member-feedbacks.service'; +import { Prisma } from '@prisma/client'; +import { + MemberFollowUpStatus, + MemberFollowUpType, + MemberFeedbackResponseType +} from 'libs/contracts/src/schema'; +import { InteractionFailureReasons } from '../utils/constants'; + +@Injectable() +export class OfficeHoursService { + private delayedFollowUps = [ + MemberFollowUpType.Enum.MEETING_SCHEDULED, + MemberFollowUpType.Enum.MEETING_YET_TO_HAPPEN + ]; + + constructor( + private readonly prisma: PrismaService, + private readonly logger: LogService, + private readonly followUpService: MemberFollowUpsService, + private readonly feedbackService: MemberFeedbacksService + ) {} + + async createInteraction( + interaction: Prisma.MemberInteractionUncheckedCreateInput, + loggedInMember + ) { + try { + return this.prisma.$transaction(async(tx) => { + const result = await tx.memberInteraction.create({ + data:{ + ...interaction, + sourceMemberUid: loggedInMember?.uid + } + }); + if (result?.hasFollowUp) { + await this.createInteractionFollowUp(result, loggedInMember, MemberFollowUpType.Enum.MEETING_INITIATED, tx); + await this.createInteractionFollowUp(result, loggedInMember, MemberFollowUpType.Enum.MEETING_SCHEDULED, tx); + }; + return result; + }); + } catch(exception) { + this.handleErrors(exception); + } + } + + async findInteractions(queryOptions: Prisma.MemberInteractionFindManyArgs) { + try { + return await this.prisma.memberInteraction.findMany({ + ...queryOptions + }); + } catch(exception) { + this.handleErrors(exception); + } + }; + + async createInteractionFollowUp(interaction, loggedInMember, type, tx?, scheduledAt?) { + const followUp: any = { + status: MemberFollowUpStatus.Enum.PENDING, + interactionUid: interaction?.uid, + createdBy: loggedInMember?.uid, + type, + data: { + ...interaction.data + }, + isDelayed: this.delayedFollowUps.includes(type) + }; + if (scheduledAt != null) { + followUp.createdAt = new Date(scheduledAt); + } + return await this.followUpService.createFollowUp(followUp, interaction, tx); + } + + async createInteractionFeedback(feedback, member, followUp) { + feedback.comments = feedback.comments?.map(comment => InteractionFailureReasons[comment] || comment) || []; + return await this.prisma.$transaction(async (tx) => { + if ( + followUp.type === MemberFollowUpType.Enum.MEETING_INITIATED && + feedback.response === MemberFeedbackResponseType.Enum.NEGATIVE + ) { + const delayedFollowUps = await this.followUpService.getFollowUps({ + where: { + interactionUid: followUp.interactionUid, + type: MemberFollowUpType.Enum.MEETING_SCHEDULED + } + }, tx); + if (delayedFollowUps?.length) { + await this.followUpService.updateFollowUpStatusByUid( + delayedFollowUps[0]?.uid, + MemberFollowUpStatus.Enum.COMPLETED, + tx + ); + } + } + if ( + feedback.response === MemberFeedbackResponseType.Enum.NEGATIVE && + feedback.comments?.includes('IFR0004') + ) { + await this.createInteractionFollowUp( + followUp.interaction, + member, + MemberFollowUpType.Enum.MEETING_YET_TO_HAPPEN, + tx + ); + } else if ( + feedback.response === MemberFeedbackResponseType.Enum.NEGATIVE && + feedback.comments?.includes('IFR0005') + ) { + await this.createInteractionFollowUp( + followUp.interaction, + member, + MemberFollowUpType.Enum.MEETING_RESCHEDULED, + tx, + feedback?.data?.scheduledAt + ); + } + return await this.feedbackService.createFeedback(feedback, member, followUp, tx); + }); + } + + async closeMemberInteractionFollowUpByID(followUpUid) { + try { + return await this.followUpService.updateFollowUpStatusByUid(followUpUid, MemberFollowUpStatus.Enum.CLOSED); + } catch(error) { + this.handleErrors(error, followUpUid); + } + } + + private handleErrors(error, message?) { + this.logger.error(error); + if (error instanceof Prisma.PrismaClientKnownRequestError) { + switch (error?.code) { + case 'P2002': + throw new ConflictException('Unique key constraint error on interactions:', error.message); + case 'P2003': + throw new BadRequestException('Foreign key constraint error on interactions', error.message); + case 'P2025': + throw new NotFoundException('Interactions is not found with uid:' + message); + default: + throw error; + } + } else if (error instanceof Prisma.PrismaClientValidationError) { + throw new BadRequestException('Database field validation error on Interactions', error.message); + } + throw error; + }; +} \ No newline at end of file diff --git a/apps/web-api/src/utils/constants.ts b/apps/web-api/src/utils/constants.ts index f88aef435..1ef6ff1b5 100644 --- a/apps/web-api/src/utils/constants.ts +++ b/apps/web-api/src/utils/constants.ts @@ -99,3 +99,15 @@ export const DEFAULT_MEMBER_ROLES = { export const PROJECT = 'Project'; export const TEAM = 'Team'; + +export const InteractionFailureReasons: { [key: string]: string } = { + "Link is broken": "IFR0001", + "I plan to schedule soon": "IFR0002", + "Preferred slot is not available": "IFR0003", + "Got rescheduled": "IFR0005", + "Got cancelled" : "IFR0006", + "Member didn’t show up": "IFR0007", + "I could not make it":"IFR0008", + "Call quality issues": "IFR0009", + "Meeting link didn't work": "IFR00010" +}; diff --git a/libs/contracts/src/lib/contract-member-interaction.ts b/libs/contracts/src/lib/contract-member-interaction.ts new file mode 100644 index 000000000..8c4c721e9 --- /dev/null +++ b/libs/contracts/src/lib/contract-member-interaction.ts @@ -0,0 +1,47 @@ +import { initContract } from '@ts-rest/core'; +import { + ResponseMemberFollowUpWithRelationsSchema, + MemberFollowUpQueryParams +} from '../schema'; +import { getAPIVersionAsPath } from '../utils/versioned-path'; + +const contract = initContract(); + +export const apiMemberInteractions = contract.router({ + createMemberInteraction: { + method: 'POST', + path: `${getAPIVersionAsPath('1')}/members/:uid/interactions`, + body: contract.body(), + responses: { + 200: contract.response(), + }, + summary: 'create a new member interactions' + }, + getMemberInteractionFollowUps: { + method: 'GET', + path: `${getAPIVersionAsPath('1')}/members/:memberUid/interactions/follow-ups`, + query: MemberFollowUpQueryParams, + responses: { + 200: ResponseMemberFollowUpWithRelationsSchema.array(), + }, + summary: 'Get member interaction follow ups', + }, + closeMemberInteractionFollowUp: { + method: 'PATCH', + path: `${getAPIVersionAsPath('1')}/members/:uid/interactions/:interactionUid/follow-ups/:followUpUid`, + body: contract.body(), + responses: { + 200: contract.response(), + }, + summary: 'close a member interaction follow up' + }, + createMemberInteractionFeedback: { + method: 'POST', + path: `${getAPIVersionAsPath('1')}/members/:uid/follow-ups/:uid/feedbacks`, + body: contract.body(), + responses: { + 200: contract.response(), + }, + summary: 'create a member interaction feedback', + } +}); diff --git a/libs/contracts/src/schema/index.ts b/libs/contracts/src/schema/index.ts index 90de3df89..8ec41610d 100644 --- a/libs/contracts/src/schema/index.ts +++ b/libs/contracts/src/schema/index.ts @@ -18,4 +18,7 @@ export * from './pl-event'; export * from './pl-event-guest'; export * from './focus-areas'; export * from './team-focus-areas'; -export * from './project-focus-areas'; \ No newline at end of file +export * from './project-focus-areas'; +export * from './member-interaction'; +export * from './member-follow-up'; +export * from './member-feedback'; \ No newline at end of file diff --git a/libs/contracts/src/schema/member-feedback.ts b/libs/contracts/src/schema/member-feedback.ts new file mode 100644 index 000000000..cc217873f --- /dev/null +++ b/libs/contracts/src/schema/member-feedback.ts @@ -0,0 +1,47 @@ +import { z, } from "zod"; +import { createZodDto } from '@abitia/zod-dto'; +import { ResponseMemberSchema } from './member'; +import { ResponseMemberFollowUpSchema } from './member-follow-up'; + +export const MemberFeedbackResponseType = z.enum([ + "POSITIVE", + "NEGATIVE", + "NEUTRAL" +]); + +const MemberFeedbackSchema = z.object({ + id: z.number().int(), + uid: z.string(), + type: z.string(), + data: z.any().optional(), + rating: z.number().int().optional(), + comments: z.array(z.string()).optional(), + response: MemberFeedbackResponseType, + createdAt: z.string(), + updatedAt: z.string(), + followUpUid: z.string(), + createdBy: z.string() +}); + +export const CreateMemberFeedbackSchema = MemberFeedbackSchema.pick({ + type: true, + data: true, + followUpUid: true, + rating: true, + comments: true, + response: true +}); + +export const ResponseMemberFeedbackSchema = MemberFeedbackSchema.omit({ id: true }).strict(); + +export const ResponseMemberFeedbackWithRelationsSchema = ResponseMemberFeedbackSchema.extend({ + creator: ResponseMemberSchema, + followUp: ResponseMemberFollowUpSchema +}); + +export const MemberFeedbackRelationalFields = ResponseMemberFeedbackWithRelationsSchema.pick({ + creator: true, + followUp: true +}).strip(); + +export class CreateMemberFeedbackSchemaDto extends createZodDto(CreateMemberFeedbackSchema) {} diff --git a/libs/contracts/src/schema/member-follow-up.ts b/libs/contracts/src/schema/member-follow-up.ts new file mode 100644 index 000000000..924f972ba --- /dev/null +++ b/libs/contracts/src/schema/member-follow-up.ts @@ -0,0 +1,47 @@ +import { z, } from "zod"; +import { ResponseMemberSchema } from './member'; +import { ResponseMemberInteractionSchema } from './member-interaction'; +import { QueryParams } from './query-params'; + +export const MemberFollowUpType = z.enum([ + "MEETING_INITIATED", + "MEETING_SCHEDULED", + "MEETING_YET_TO_HAPPEN", + "MEETING_RESCHEDULED" +]); + +export const MemberFollowUpStatus = z.enum(["PENDING", "COMPLETED", "CLOSED"]); + +const MemberFollowUpSchema = z.object({ + id: z.number().int(), + uid: z.string(), + type: MemberFollowUpType, + status: MemberFollowUpStatus, + data: z.any().optional(), + isDelayed: z.boolean(), + createdBy: z.string(), + createdAt: z.string(), + updatedAt: z.string(), + interactionUid: z.string() +}); + +export const ResponseMemberFollowUpSchema = MemberFollowUpSchema.omit({ id: true }).strict(); + +export const ResponseMemberFollowUpWithRelationsSchema = ResponseMemberFollowUpSchema.extend({ + creator: ResponseMemberSchema, + interaction: ResponseMemberInteractionSchema +}); + +export const MemberFollowUpRelationalFields = ResponseMemberFollowUpWithRelationsSchema.pick({ + creator: true, + interaction: true +}).strip(); + +export const MemberFollowUpQueryableFields = ResponseMemberFollowUpSchema.keyof(); + +export const MemberFollowUpQueryParams = QueryParams({ + queryableFields: MemberFollowUpQueryableFields, + relationalFields: MemberFollowUpRelationalFields +}); + + diff --git a/libs/contracts/src/schema/member-interaction.ts b/libs/contracts/src/schema/member-interaction.ts new file mode 100644 index 000000000..ccbba869f --- /dev/null +++ b/libs/contracts/src/schema/member-interaction.ts @@ -0,0 +1,28 @@ +import { z, } from "zod"; +import { createZodDto } from '@abitia/zod-dto'; + +export const MemberInteractionType = z.enum([ + "SCHEDULE_MEETING" +]); + +const MemberInteractionSchema = z.object({ + id: z.number().int(), + uid: z.string(), + type: MemberInteractionType, + data: z.any().optional(), + hasFollowUp: z.boolean().optional(), + createdAt: z.string(), + updatedAt: z.string(), + sourceMemberUid: z.string(), + targetMemberUid: z.string().optional(), +}); + +export const CreateMemberInteractionSchema = MemberInteractionSchema.pick({ + type: true, + data: true, + targetMemberUid: true +}); + +export const ResponseMemberInteractionSchema = MemberInteractionSchema.omit({ id: true }).strict(); + +export class CreateMemberInteractionSchemaDto extends createZodDto(CreateMemberInteractionSchema) {}