diff --git a/backend/package-lock.json b/backend/package-lock.json index ccdaad3c5..b954f6ff3 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -23,6 +23,7 @@ "@nestjs/event-emitter": "2.0.4", "@nestjs/passport": "10.0.3", "@nestjs/platform-express": "10.3.10", + "@nestjs/schedule": "^4.1.0", "@nestjs/swagger": "7.3.1", "@nestjs/throttler": "5.2.0", "@nestjs/typeorm": "10.0.2", @@ -4741,6 +4742,31 @@ "@nestjs/core": "^10.0.0" } }, + "node_modules/@nestjs/schedule": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@nestjs/schedule/-/schedule-4.1.0.tgz", + "integrity": "sha512-WEc96WTXZW+VI/Ng+uBpiBUwm6TWtAbQ4RKWkfbmzKvmbRGzA/9k/UyAWDS9k0pp+ZcbC+MaZQtt7TjQHrwX6g==", + "dependencies": { + "cron": "3.1.7", + "uuid": "10.0.0" + }, + "peerDependencies": { + "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0", + "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/@nestjs/schedule/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@nestjs/schematics": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/@nestjs/schematics/-/schematics-10.1.1.tgz", @@ -6461,6 +6487,11 @@ "@types/koa": "*" } }, + "node_modules/@types/luxon": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.4.2.tgz", + "integrity": "sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA==" + }, "node_modules/@types/methods": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz", @@ -8656,6 +8687,15 @@ "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", "devOptional": true }, + "node_modules/cron": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz", + "integrity": "sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==", + "dependencies": { + "@types/luxon": "~3.4.0", + "luxon": "~3.4.0" + } + }, "node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", diff --git a/backend/package.json b/backend/package.json index e22289192..1bcc4a717 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "backend", - "version": "0.0.1", + "version": "1.1.0", "description": "", "author": "", "private": true, @@ -44,6 +44,7 @@ "@nestjs/event-emitter": "2.0.4", "@nestjs/passport": "10.0.3", "@nestjs/platform-express": "10.3.10", + "@nestjs/schedule": "^4.1.0", "@nestjs/swagger": "7.3.1", "@nestjs/throttler": "5.2.0", "@nestjs/typeorm": "10.0.2", diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 6daa9c350..659f6626a 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -27,6 +27,7 @@ import { PracticeProgramModule } from './modules/practice-program/practice-progr import { CivicCenterModule } from './modules/civic-center-service/civic-center.module'; import { EventEmitterModule } from '@nestjs/event-emitter'; import { NotificationsModule } from './modules/notifications/notifications.module'; +import { ScheduleModule } from '@nestjs/schedule'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { NotificationsModule } from './modules/notifications/notifications.modul useClass: RateLimiterConfigService, }), EventEmitterModule.forRoot(), + ScheduleModule.forRoot(), // Providers DatabaseProviderModule, diff --git a/backend/src/common/config/email-config.service.ts b/backend/src/common/config/email-config.service.ts index 7e5a3984c..00031311a 100644 --- a/backend/src/common/config/email-config.service.ts +++ b/backend/src/common/config/email-config.service.ts @@ -22,7 +22,7 @@ export class EmailConfigService { defaults: { from: '"No Reply" ', }, - preview: true, + preview: false, template: { dir: __dirname + '/../../mail/templates', adapter: new HandlebarsAdapter({ asset_url: this.createAssetUrl }), diff --git a/backend/src/mail/constants/template.constants.ts b/backend/src/mail/constants/template.constants.ts index 3580be612..291b85e3a 100644 --- a/backend/src/mail/constants/template.constants.ts +++ b/backend/src/mail/constants/template.constants.ts @@ -15,8 +15,7 @@ export const MAIL_OPTIONS: Record = { template: ORGANIZATION_REQUEST, subject: 'NGO Hub - Solicitare creare cont organizație', context: { - title: - 'Ești doar la un pas de a primi accesul în NGO Hub.', + title: 'Ești doar la un pas de a primi accesul în NGO Hub.', subtitle: () => `Solicitarea de a crea un cont pentru organizația ta în NGO Hub a fost trimisă cu succes. Echipa NGO Hub va verifica informațiile primite și imediat ce este aprobată vei primi o notificare cu detalii despre accesarea ecosistemului de soluții NGO Hub. Dacă ai întrebări, ne poți contacta la ${process.env.MAIL_CONTACT}`, }, @@ -97,10 +96,106 @@ export const MAIL_OPTIONS: Record = { subtitle: (organizationName, applicationName) => `O nouă cerere de instalare a aplicației ${applicationName} a fost creată în ONG Hub pentru organizația ${organizationName}.`, cta: { - link: () => - `${process.env.ONGHUB_URL}/applications/requests`, + link: () => `${process.env.ONGHUB_URL}/applications/requests`, label: 'Vezi cererea', }, }, }, -}; \ No newline at end of file + REMIND_TO_UPDATE_ORGANIZATION_REPORTS: { + template: ORGANIZATION_REQUEST, + subject: + 'Actualizați profilul organizației până pe 30 iunie pentru a evita suspendarea contului', + context: { + title: + 'Actualizați profilul organizației până pe 30 iunie pentru a evita suspendarea contului', + subtitle: () => ` +

Bună,

+ +

Ne bucurăm că ești parte din comunitatea NGO Hub!

+ +

Vrem să îți reamintim că este important să îți actualizezi datele din profilul tău din NGO Hub până la data de 30 iunie ${new Date().getFullYear()}.

+ +

Dacă nu reușești să faci această actualizare până la termenul limită, contul tău va fi suspendat temporar.

+ +

Aceștia sunt pașii pe care trebuie să îi urmezi pentru actualizare:

+ +
    +
  1. Conectează-te la contul tău NGO Hub
  2. +
  3. Mergi la secțiunea „Organizația mea"
  4. +
  5. Verifică și actualizează toate informațiile din secțiunea „Informații financiare" și „ONG-ul în numere".
  6. +
  7. Salvează modificările
  8. +
+ +

Dacă ai nevoie de ajutor sau ai orice fel de întrebare, ne poți contacta oricând la civic@code4.ro sau poți programa o sesiune Civic Tech 911 direct din contul NGO Hub al organizației.

+ `, + cta: { + link: () => `${process.env.ONGHUB_URL}/organization`, + label: 'Intra in cont', + }, + }, + }, + WEEKLY_REMINDER_TO_UPDATE_ORGANIZATION_REPORTS: { + template: ORGANIZATION_REQUEST, + subject: + 'Reminder: Actualizați profilul organizației până pe 30 iunie pentru a evita suspendarea contului', + context: { + title: + 'Actualizați profilul organizației până pe 30 iunie pentru a evita suspendarea contului', + subtitle: () => ` +

Bună,

+ +

Ne bucurăm că ești parte din comunitatea NGO Hub!

+ +

Îți reamintim că termenul limită pentru actualizarea datelor din profilul tău este 30 iunie ${new Date().getFullYear()}. Pentru a-ți păstra contul activ, te rugăm să îți actualizezi informațiile cât mai curând posibil.

+ +

Aceștia sunt pașii pe care trebuie să îi urmezi pentru actualizare:

+ +
    +
  1. Conectează-te la contul tău NGO Hub
  2. +
  3. Mergi la secțiunea „Organizația mea"
  4. +
  5. Verifică și actualizează toate informațiile din secțiunea „Informații financiare" și „ONG-ul în numere".
  6. +
  7. Salvează modificările
  8. +
+ +

Dacă ai nevoie de ajutor sau ai orice fel de întrebare, ne poți contacta oricând la civic@code4.ro sau poți programa o sesiune Civic Tech 911 direct din contul NGO Hub al organizației.

+ `, + cta: { + link: () => `${process.env.ONGHUB_URL}/organization`, + label: 'Intra in cont', + }, + }, + }, + NOTIFY_FOR_UNAVAILABLE_OR_INVALID_FINANCIAL_INFORMATION: { + template: ORGANIZATION_REQUEST, + subject: + 'Acțiune necesară: Te rugăm să corectezi datele din profilul organizației din NGO Hub', + context: { + title: + 'Acțiune necesară: Te rugăm să corectezi datele din profilul organizației din NGO Hub', + subtitle: () => ` +

Bună,

+ +

Ne bucurăm că ești parte din comunitatea NGO Hub!

+ +

Am observat că unele dintre informațiile recent actualizate din profilul tău de pe NGO Hub par a fi invalide sau incomplete. Pentru a ne asigura că ai acces neîntrerupt la contul tău, te rugăm să îți actualizezi și corectezi datele cât mai curând posibil.

+ +

Aceștia sunt pașii pe care trebuie să îi urmezi pentru actualizare:

+ +
    +
  1. Conectează-te la contul tău NGO Hub
  2. +
  3. Mergi la secțiunea „Organizația mea"
  4. +
  5. Verifică și actualizează toate informațiile din secțiunea „Informații financiare" și „ONG-ul în numere".
  6. +
  7. Salvează modificările
  8. +
+ +

Este important să faci aceste actualizări pentru a evita suspendarea contului tău NGO Hub.

+ +

Dacă ai nevoie de ajutor sau ai orice fel de întrebare, ne poți contacta oricând la civic@code4.ro sau poți programa o sesiune Civic Tech 911 direct din contul NGO Hub al organizației.

+ `, + cta: { + link: () => `${process.env.ONGHUB_URL}/organization`, + label: 'Intra in cont', + }, + }, + }, +}; diff --git a/backend/src/mail/templates/mail-template.hbs b/backend/src/mail/templates/mail-template.hbs index 81e6c1f24..ebd34c568 100644 --- a/backend/src/mail/templates/mail-template.hbs +++ b/backend/src/mail/templates/mail-template.hbs @@ -66,12 +66,15 @@ {{> header}}

{{title}}

-

{{subtitle}}

+

{{{subtitle}}}

{{#if cta}} {{/if}} + +

Cu drag,

+

Echipa NGO Hub

{{> footer}} diff --git a/backend/src/migrations/1723119327053-ApplicationLabel.ts b/backend/src/migrations/1723119327053-ApplicationLabel.ts new file mode 100644 index 000000000..4f3430333 --- /dev/null +++ b/backend/src/migrations/1723119327053-ApplicationLabel.ts @@ -0,0 +1,33 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ApplicationLabel1723119327053 implements MigrationInterface { + name = 'ApplicationLabel1723119327053'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "_application-label" ("id" SERIAL NOT NULL, "deleted_on" TIMESTAMP WITH TIME ZONE, "created_on" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_on" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "name" text NOT NULL, CONSTRAINT "PK_c0aaf1127ad3beeaf0d3ad70096" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e4a4e4b1582c4e665cff9be33e" ON "_application-label" ("created_on") `, + ); + await queryRunner.query( + `ALTER TABLE "application" ADD "application_label_id" integer`, + ); + await queryRunner.query( + `ALTER TABLE "application" ADD CONSTRAINT "FK_318029631a770782ba1c66721fd" FOREIGN KEY ("application_label_id") REFERENCES "_application-label"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "application" DROP CONSTRAINT "FK_318029631a770782ba1c66721fd"`, + ); + await queryRunner.query( + `ALTER TABLE "application" DROP COLUMN "application_label_id"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_e4a4e4b1582c4e665cff9be33e"`, + ); + await queryRunner.query(`DROP TABLE "_application-label"`); + } +} diff --git a/backend/src/migrations/1724056332367-AddNewFinancialReportStatus.ts b/backend/src/migrations/1724056332367-AddNewFinancialReportStatus.ts new file mode 100644 index 000000000..4490fdaf6 --- /dev/null +++ b/backend/src/migrations/1724056332367-AddNewFinancialReportStatus.ts @@ -0,0 +1,25 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddNewFinancialReportStatus1724056332367 + implements MigrationInterface +{ + name = 'AddNewFinancialReportStatus1724056332367'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."organization_financial_status_enum" AS ENUM('Not Completed', 'Pending', 'Completed', 'Invalid')`, + ); + await queryRunner.query( + `ALTER TABLE "organization_financial" ADD "status" "public"."organization_financial_status_enum" NOT NULL DEFAULT 'Not Completed'`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "organization_financial" DROP COLUMN "status"`, + ); + await queryRunner.query( + `DROP TYPE "public"."organization_financial_status_enum"`, + ); + } +} diff --git a/backend/src/migrations/1724232066999-RemoveCompletionStatus.ts b/backend/src/migrations/1724232066999-RemoveCompletionStatus.ts new file mode 100644 index 000000000..1e38dd51f --- /dev/null +++ b/backend/src/migrations/1724232066999-RemoveCompletionStatus.ts @@ -0,0 +1,104 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class RemoveCompletionStatus1724232066999 implements MigrationInterface { + name = 'RemoveCompletionStatus1724232066999'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + await queryRunner.query( + `ALTER TABLE "organization" DROP COLUMN "synced_on"`, + ); + await queryRunner.query( + `ALTER TABLE "organization" DROP COLUMN "completion_status"`, + ); + await queryRunner.query( + `DROP TYPE "public"."organization_completion_status_enum"`, + ); + await queryRunner.query( + `ALTER TABLE "organization_history" DROP COLUMN "synced_on"`, + ); + await queryRunner.query(`CREATE VIEW "OrganizationView" AS + SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE WHEN MIN(COALESCE("organization_financial".status, 'Not Completed')) != 'Completed' + OR MIN(COALESCE("report".status, 'Not Completed')) != 'Completed' + OR MIN(COALESCE("partner".status, 'Not Completed')) != 'Completed' + OR MIN(COALESCE("investor".status, 'Not Completed')) != 'Completed' THEN + 'Not completed' + ELSE + 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + COUNT("user".id) AS "userCount", + "organization_general".logo AS "logo", + MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id + `); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT\n "organization".id AS "id",\n "organization".status AS "status",\n "organization".created_on AS "createdOn",\n CASE WHEN MIN(COALESCE("organization_financial".status, \'Not Completed\')) != \'Completed\'\n OR MIN(COALESCE("report".status, \'Not Completed\')) != \'Completed\'\n OR MIN(COALESCE("partner".status, \'Not Completed\')) != \'Completed\'\n OR MIN(COALESCE("investor".status, \'Not Completed\')) != \'Completed\' THEN\n \'Not completed\'\n ELSE\n \'Completed\'\n END AS "completionStatus",\n "organization_general".name AS "name",\n "organization_general".alias AS "alias",\n COUNT("user".id) AS "userCount",\n "organization_general".logo AS "logo",\n MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\'))) AS "updatedOn"\n FROM\n "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id\n AND "user".deleted_on IS NULL\n AND "user".ROLE = \'employee\'\n AND "user".status != \'pending\'\n LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId"\n LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id\n LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId"\n LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId"\n LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId"\n WHERE\n "organization".status != \'pending\'\n GROUP BY\n "organization".id,\n "organization_general".id', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + await queryRunner.query( + `ALTER TABLE "organization_history" ADD "synced_on" TIMESTAMP WITH TIME ZONE NOT NULL`, + ); + await queryRunner.query( + `CREATE TYPE "public"."organization_completion_status_enum" AS ENUM('Completed', 'Not Completed')`, + ); + await queryRunner.query( + `ALTER TABLE "organization" ADD "completion_status" "public"."organization_completion_status_enum" NOT NULL DEFAULT 'Not Completed'`, + ); + await queryRunner.query( + `ALTER TABLE "organization" ADD "synced_on" TIMESTAMP WITH TIME ZONE NOT NULL`, + ); + await queryRunner.query(`CREATE VIEW "OrganizationView" AS SELECT "organization".id as "id", "organization".status AS "status", "organization".created_on as "createdOn", "organization".updated_on as "updatedOn", "organization".completion_status as "completionStatus", "organization_general".name as "name","organization_general".alias as "alias", COUNT("user".id) as "userCount", "organization_general".logo as "logo" FROM "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id AND "user".deleted_on IS NULL AND "user".role = 'employee' and "user".status != 'pending' + WHERE "organization".status != 'pending' + GROUP BY "organization".id, "organization_general".id`); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT "organization".id as "id", "organization".status AS "status", "organization".created_on as "createdOn", "organization".updated_on as "updatedOn", "organization".completion_status as "completionStatus", "organization_general".name as "name","organization_general".alias as "alias", COUNT("user".id) as "userCount", "organization_general".logo as "logo" FROM "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id AND "user".deleted_on IS NULL AND "user".role = \'employee\' and "user".status != \'pending\'\n WHERE "organization".status != \'pending\'\n GROUP BY "organization".id, "organization_general".id', + ], + ); + } +} diff --git a/backend/src/migrations/1724330041291-AddAdminEmailToOrganizationView.ts b/backend/src/migrations/1724330041291-AddAdminEmailToOrganizationView.ts new file mode 100644 index 000000000..f385c9637 --- /dev/null +++ b/backend/src/migrations/1724330041291-AddAdminEmailToOrganizationView.ts @@ -0,0 +1,112 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddAdminEmailToOrganizationView1724330041291 + implements MigrationInterface +{ + name = 'AddAdminEmailToOrganizationView1724330041291'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + await queryRunner.query(`CREATE VIEW "OrganizationView" AS + SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE + WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != 'Completed' OR "organization_financial".status IS NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "report".status != 'Completed' OR "report".status IS NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "partner".status != 'Completed' OR "partner".status IS NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "investor".status != 'Completed' OR "investor".status IS NULL THEN 1 END) > 0 + THEN 'Not Completed' + ELSE 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + "organization_general".email AS "adminEmail", + COUNT("user".id) AS "userCount", + "organization_general".logo AS "logo", + MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id + `); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT\n "organization".id AS "id",\n "organization".status AS "status",\n "organization".created_on AS "createdOn",\n \tCASE \n WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != \'Completed\' OR "organization_financial".status IS NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "report".status != \'Completed\' OR "report".status IS NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "partner".status != \'Completed\' OR "partner".status IS NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "investor".status != \'Completed\' OR "investor".status IS NULL THEN 1 END) > 0\n THEN \'Not Completed\'\n ELSE \'Completed\'\n \tEND AS "completionStatus",\n "organization_general".name AS "name",\n "organization_general".alias AS "alias",\n "organization_general".email AS "adminEmail",\n COUNT("user".id) AS "userCount",\n "organization_general".logo AS "logo",\n MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\'))) AS "updatedOn"\n FROM\n "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id\n AND "user".deleted_on IS NULL\n AND "user".ROLE = \'employee\'\n AND "user".status != \'pending\'\n LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId"\n LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id\n LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId"\n LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId"\n LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId"\n WHERE\n "organization".status != \'pending\'\n GROUP BY\n "organization".id,\n "organization_general".id', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + await queryRunner.query(`CREATE VIEW "OrganizationView" AS SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE WHEN MIN(COALESCE("organization_financial".status, 'Not Completed')) != 'Completed' + OR MIN(COALESCE("report".status, 'Not Completed')) != 'Completed' + OR MIN(COALESCE("partner".status, 'Not Completed')) != 'Completed' + OR MIN(COALESCE("investor".status, 'Not Completed')) != 'Completed' THEN + 'Not completed' + ELSE + 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + COUNT("user".id) AS "userCount", + "organization_general".logo AS "logo", + MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id`); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT\n "organization".id AS "id",\n "organization".status AS "status",\n "organization".created_on AS "createdOn",\n CASE WHEN MIN(COALESCE("organization_financial".status, \'Not Completed\')) != \'Completed\'\n OR MIN(COALESCE("report".status, \'Not Completed\')) != \'Completed\'\n OR MIN(COALESCE("partner".status, \'Not Completed\')) != \'Completed\'\n OR MIN(COALESCE("investor".status, \'Not Completed\')) != \'Completed\' THEN\n \'Not completed\'\n ELSE\n \'Completed\'\n END AS "completionStatus",\n "organization_general".name AS "name",\n "organization_general".alias AS "alias",\n COUNT("user".id) AS "userCount",\n "organization_general".logo AS "logo",\n MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\'))) AS "updatedOn"\n FROM\n "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id\n AND "user".deleted_on IS NULL\n AND "user".ROLE = \'employee\'\n AND "user".status != \'pending\'\n LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId"\n LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id\n LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId"\n LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId"\n LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId"\n WHERE\n "organization".status != \'pending\'\n GROUP BY\n "organization".id,\n "organization_general".id', + ], + ); + } +} diff --git a/backend/src/migrations/1724932441086-UserPhoneNullable.ts b/backend/src/migrations/1724932441086-UserPhoneNullable.ts new file mode 100644 index 000000000..e0065ddad --- /dev/null +++ b/backend/src/migrations/1724932441086-UserPhoneNullable.ts @@ -0,0 +1,23 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UserPhoneNullable1724932441086 implements MigrationInterface { + name = 'UserPhoneNullable1724932441086'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "phone" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "user_history" ALTER COLUMN "phone" DROP NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user" ALTER COLUMN "phone" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "user_history" ALTER COLUMN "phone" SET NOT NULL`, + ); + } +} diff --git a/backend/src/migrations/1725278083496-OrganizationViewUpdates.ts b/backend/src/migrations/1725278083496-OrganizationViewUpdates.ts new file mode 100644 index 000000000..dcebe0bf3 --- /dev/null +++ b/backend/src/migrations/1725278083496-OrganizationViewUpdates.ts @@ -0,0 +1,135 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class OrganizationViewUpdates1725278083496 + implements MigrationInterface +{ + name = 'OrganizationViewUpdates1725278083496'; + + public async up(queryRunner: QueryRunner): Promise { + // Drop the existing view and its metadata + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + + // Create the updated view with the following changes: + // 1. Modified the CASE statement for completionStatus: + // - Changed from "OR status IS NULL" to "AND status IS NOT NULL" + // This ensures that only non-null statuses that are not 'Completed' are considered as 'Not Completed' + // 2. Updated the updatedOn column: + // - Added a CASE statement to return NULL if the max date is '1970-01-01' + // - Cast the result to text + await queryRunner.query(`CREATE VIEW "OrganizationView" AS + SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE + WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != 'Completed' AND "organization_financial".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "report".status != 'Completed' AND "report".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "partner".status != 'Completed' AND "partner".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "investor".status != 'Completed' AND "investor".status IS NOT NULL THEN 1 END) > 0 + THEN 'Not Completed' + ELSE 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + "organization_general".email AS "adminEmail", + COUNT(DISTINCT "user".id) AS "userCount", + "organization_general".logo AS "logo", + CASE + WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) = '1970-01-01' + THEN NULL + ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01')))::text + END AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id + `); + + // Insert metadata for the new view + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT\n "organization".id AS "id",\n "organization".status AS "status",\n "organization".created_on AS "createdOn",\n \tCASE \n WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != \'Completed\' AND "organization_financial".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "report".status != \'Completed\' AND "report".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "partner".status != \'Completed\' AND "partner".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "investor".status != \'Completed\' AND "investor".status IS NOT NULL THEN 1 END) > 0\n THEN \'Not Completed\'\n ELSE \'Completed\'\n \tEND AS "completionStatus",\n "organization_general".name AS "name",\n "organization_general".alias AS "alias",\n "organization_general".email AS "adminEmail",\n COUNT(DISTINCT "user".id) AS "userCount",\n "organization_general".logo AS "logo",\n CASE\n WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\'))) = \'1970-01-01\'\n THEN NULL\n ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\')))::text\n END AS "updatedOn"\n FROM\n "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id\n AND "user".deleted_on IS NULL\n AND "user".ROLE = \'employee\'\n AND "user".status != \'pending\'\n LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId"\n LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id\n LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId"\n LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId"\n LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId"\n WHERE\n "organization".status != \'pending\'\n GROUP BY\n "organization".id,\n "organization_general".id', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop the updated view and its metadata + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + + // Recreate the original view with the following differences: + // 1. CASE statement for completionStatus uses "OR status IS NULL" instead of "AND status IS NOT NULL" + // 2. updatedOn column doesn't have the CASE statement and doesn't cast to text + await queryRunner.query(`CREATE VIEW "OrganizationView" AS SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE + WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != 'Completed' OR "organization_financial".status IS NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "report".status != 'Completed' OR "report".status IS NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "partner".status != 'Completed' OR "partner".status IS NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "investor".status != 'Completed' OR "investor".status IS NULL THEN 1 END) > 0 + THEN 'Not Completed' + ELSE 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + "organization_general".email AS "adminEmail", + COUNT(DISTINCT "user".id) AS "userCount", + "organization_general".logo AS "logo", + MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id`); + + // Insert metadata for the original view + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT\n "organization".id AS "id",\n "organization".status AS "status",\n "organization".created_on AS "createdOn",\n \tCASE \n WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != \'Completed\' OR "organization_financial".status IS NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "report".status != \'Completed\' OR "report".status IS NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "partner".status != \'Completed\' OR "partner".status IS NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "investor".status != \'Completed\' OR "investor".status IS NULL THEN 1 END) > 0\n THEN \'Not Completed\'\n ELSE \'Completed\'\n \tEND AS "completionStatus",\n "organization_general".name AS "name",\n "organization_general".alias AS "alias",\n "organization_general".email AS "adminEmail",\n COUNT(DISTINCT "user".id) AS "userCount",\n "organization_general".logo AS "logo",\n MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\'))) AS "updatedOn"\n FROM\n "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id\n AND "user".deleted_on IS NULL\n AND "user".ROLE = \'employee\'\n AND "user".status != \'pending\'\n LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId"\n LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id\n LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId"\n LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId"\n LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId"\n WHERE\n "organization".status != \'pending\'\n GROUP BY\n "organization".id,\n "organization_general".id', + ], + ); + } +} diff --git a/backend/src/migrations/1725362133806-OrgViewUpdateAdminEmail.ts b/backend/src/migrations/1725362133806-OrgViewUpdateAdminEmail.ts new file mode 100644 index 000000000..f99493cef --- /dev/null +++ b/backend/src/migrations/1725362133806-OrgViewUpdateAdminEmail.ts @@ -0,0 +1,135 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class OrgViewUpdateAdminEmail1725362133806 + implements MigrationInterface +{ + name = 'OrgViewUpdateAdminEmail1725362133806'; + + public async up(queryRunner: QueryRunner): Promise { + // Drop the existing view and its metadata + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + + // Create the updated view + // Changes: + // - "adminEmail" now uses "organization_general".contact_person->>'email' instead of "organization_general".email because we are interested in "Contact person in relation with ONGHub" + await queryRunner.query(`CREATE VIEW "OrganizationView" AS + SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE + WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != 'Completed' AND "organization_financial".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "report".status != 'Completed' AND "report".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "partner".status != 'Completed' AND "partner".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "investor".status != 'Completed' AND "investor".status IS NOT NULL THEN 1 END) > 0 + THEN 'Not Completed' + ELSE 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + "organization_general".contact_person->>'email' AS "adminEmail", + COUNT(DISTINCT "user".id) AS "userCount", + "organization_general".logo AS "logo", + CASE + WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) = '1970-01-01' + THEN NULL + ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01')))::text + END AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id + `); + + // Insert metadata for the new view + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT\n "organization".id AS "id",\n "organization".status AS "status",\n "organization".created_on AS "createdOn",\n \tCASE \n WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != \'Completed\' AND "organization_financial".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "report".status != \'Completed\' AND "report".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "partner".status != \'Completed\' AND "partner".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "investor".status != \'Completed\' AND "investor".status IS NOT NULL THEN 1 END) > 0\n THEN \'Not Completed\'\n ELSE \'Completed\'\n \tEND AS "completionStatus",\n "organization_general".name AS "name",\n "organization_general".alias AS "alias",\n "organization_general".contact_person->>\'email\' AS "adminEmail",\n COUNT(DISTINCT "user".id) AS "userCount",\n "organization_general".logo AS "logo",\n CASE\n WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\'))) = \'1970-01-01\'\n THEN NULL\n ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\')))::text\n END AS "updatedOn"\n FROM\n "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id\n AND "user".deleted_on IS NULL\n AND "user".ROLE = \'employee\'\n AND "user".status != \'pending\'\n LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId"\n LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id\n LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId"\n LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId"\n LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId"\n WHERE\n "organization".status != \'pending\'\n GROUP BY\n "organization".id,\n "organization_general".id', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + // Drop the updated view and its metadata + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + + // Recreate the original view + // Changes: + // - "adminEmail" now uses "organization_general".email instead of "organization_general".contact_person->>'email' + await queryRunner.query(`CREATE VIEW "OrganizationView" AS SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE + WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != 'Completed' AND "organization_financial".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "report".status != 'Completed' AND "report".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "partner".status != 'Completed' AND "partner".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "investor".status != 'Completed' AND "investor".status IS NOT NULL THEN 1 END) > 0 + THEN 'Not Completed' + ELSE 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + "organization_general".email AS "adminEmail", + COUNT(DISTINCT "user".id) AS "userCount", + "organization_general".logo AS "logo", + CASE + WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) = '1970-01-01' + THEN NULL + ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01')))::text + END AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id`); + + // Insert metadata for the original view + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT\n "organization".id AS "id",\n "organization".status AS "status",\n "organization".created_on AS "createdOn",\n \tCASE \n WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != \'Completed\' AND "organization_financial".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "report".status != \'Completed\' AND "report".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "partner".status != \'Completed\' AND "partner".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "investor".status != \'Completed\' AND "investor".status IS NOT NULL THEN 1 END) > 0\n THEN \'Not Completed\'\n ELSE \'Completed\'\n \tEND AS "completionStatus",\n "organization_general".name AS "name",\n "organization_general".alias AS "alias",\n "organization_general".email AS "adminEmail",\n COUNT(DISTINCT "user".id) AS "userCount",\n "organization_general".logo AS "logo",\n CASE\n WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\'))) = \'1970-01-01\'\n THEN NULL\n ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\')))::text\n END AS "updatedOn"\n FROM\n "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id\n AND "user".deleted_on IS NULL\n AND "user".ROLE = \'employee\'\n AND "user".status != \'pending\'\n LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId"\n LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id\n LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId"\n LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId"\n LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId"\n WHERE\n "organization".status != \'pending\'\n GROUP BY\n "organization".id,\n "organization_general".id', + ], + ); + } +} diff --git a/backend/src/migrations/1725368161763-OrgViewChangeFinancialStatusCalc.ts b/backend/src/migrations/1725368161763-OrgViewChangeFinancialStatusCalc.ts new file mode 100644 index 000000000..59b46561c --- /dev/null +++ b/backend/src/migrations/1725368161763-OrgViewChangeFinancialStatusCalc.ts @@ -0,0 +1,121 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class OrgViewChangeFinancialStatusCalc1725368161763 + implements MigrationInterface +{ + name = 'OrgViewChangeFinancialStatusCalc1725368161763'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + await queryRunner.query(`CREATE VIEW "OrganizationView" AS + SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE + WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status NOT IN ('Completed', 'Pending') AND "organization_financial".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "report".status != 'Completed' AND "report".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "partner".status != 'Completed' AND "partner".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "investor".status != 'Completed' AND "investor".status IS NOT NULL THEN 1 END) > 0 + THEN 'Not Completed' + ELSE 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + "organization_general".contact_person->>'email' AS "adminEmail", + COUNT(DISTINCT "user".id) AS "userCount", + "organization_general".logo AS "logo", + CASE + WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) = '1970-01-01' + THEN NULL + ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01')))::text + END AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id + `); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT\n "organization".id AS "id",\n "organization".status AS "status",\n "organization".created_on AS "createdOn",\n \tCASE \n WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status NOT IN (\'Completed\', \'Pending\') AND "organization_financial".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "report".status != \'Completed\' AND "report".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "partner".status != \'Completed\' AND "partner".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "investor".status != \'Completed\' AND "investor".status IS NOT NULL THEN 1 END) > 0\n THEN \'Not Completed\'\n ELSE \'Completed\'\n \tEND AS "completionStatus",\n "organization_general".name AS "name",\n "organization_general".alias AS "alias",\n "organization_general".contact_person->>\'email\' AS "adminEmail",\n COUNT(DISTINCT "user".id) AS "userCount",\n "organization_general".logo AS "logo",\n CASE\n WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\'))) = \'1970-01-01\'\n THEN NULL\n ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\')))::text\n END AS "updatedOn"\n FROM\n "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id\n AND "user".deleted_on IS NULL\n AND "user".ROLE = \'employee\'\n AND "user".status != \'pending\'\n LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId"\n LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id\n LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId"\n LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId"\n LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId"\n WHERE\n "organization".status != \'pending\'\n GROUP BY\n "organization".id,\n "organization_general".id', + ], + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "typeorm_metadata" WHERE "type" = $1 AND "name" = $2 AND "schema" = $3`, + ['VIEW', 'OrganizationView', 'public'], + ); + await queryRunner.query(`DROP VIEW "OrganizationView"`); + await queryRunner.query(`CREATE VIEW "OrganizationView" AS SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE + WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != 'Completed' AND "organization_financial".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "report".status != 'Completed' AND "report".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "partner".status != 'Completed' AND "partner".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "investor".status != 'Completed' AND "investor".status IS NOT NULL THEN 1 END) > 0 + THEN 'Not Completed' + ELSE 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + "organization_general".contact_person->>'email' AS "adminEmail", + COUNT(DISTINCT "user".id) AS "userCount", + "organization_general".logo AS "logo", + CASE + WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) = '1970-01-01' + THEN NULL + ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01')))::text + END AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id`); + await queryRunner.query( + `INSERT INTO "typeorm_metadata"("database", "schema", "table", "type", "name", "value") VALUES (DEFAULT, $1, DEFAULT, $2, $3, $4)`, + [ + 'public', + 'VIEW', + 'OrganizationView', + 'SELECT\n "organization".id AS "id",\n "organization".status AS "status",\n "organization".created_on AS "createdOn",\n \tCASE \n WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status != \'Completed\' AND "organization_financial".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "report".status != \'Completed\' AND "report".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "partner".status != \'Completed\' AND "partner".status IS NOT NULL THEN 1 END) > 0\n OR COUNT(DISTINCT CASE WHEN "investor".status != \'Completed\' AND "investor".status IS NOT NULL THEN 1 END) > 0\n THEN \'Not Completed\'\n ELSE \'Completed\'\n \tEND AS "completionStatus",\n "organization_general".name AS "name",\n "organization_general".alias AS "alias",\n "organization_general".contact_person->>\'email\' AS "adminEmail",\n COUNT(DISTINCT "user".id) AS "userCount",\n "organization_general".logo AS "logo",\n CASE\n WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\'))) = \'1970-01-01\'\n THEN NULL\n ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, \'1970-01-01\'), COALESCE("report".updated_on, \'1970-01-01\'), COALESCE("partner".updated_on, \'1970-01-01\'), COALESCE("investor".updated_on, \'1970-01-01\')))::text\n END AS "updatedOn"\n FROM\n "organization" "organization"\n LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id\n LEFT JOIN "user" "user" ON "user".organization_id = "organization".id\n AND "user".deleted_on IS NULL\n AND "user".ROLE = \'employee\'\n AND "user".status != \'pending\'\n LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId"\n LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id\n LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId"\n LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId"\n LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId"\n WHERE\n "organization".status != \'pending\'\n GROUP BY\n "organization".id,\n "organization_general".id', + ], + ); + } +} diff --git a/backend/src/migrations/1725518140608-AllowTotalAndEmployeesToBeNull.ts b/backend/src/migrations/1725518140608-AllowTotalAndEmployeesToBeNull.ts new file mode 100644 index 000000000..a71a93d69 --- /dev/null +++ b/backend/src/migrations/1725518140608-AllowTotalAndEmployeesToBeNull.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AllowTotalAndEmployeesToBeNull1725518140608 + implements MigrationInterface +{ + name = 'AllowTotalAndEmployeesToBeNull1725518140608'; + + /** + * Allow Total and Employees to be null to better reflect if the user has not completed the financials or is just the default value + */ + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "organization_financial" ALTER COLUMN "number_of_employees" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "organization_financial" ALTER COLUMN "number_of_employees" DROP DEFAULT`, + ); + await queryRunner.query( + `ALTER TABLE "organization_financial" ALTER COLUMN "total" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "organization_financial" ALTER COLUMN "total" DROP DEFAULT`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "organization_financial" ALTER COLUMN "total" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "organization_financial" ALTER COLUMN "total" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "organization_financial" ALTER COLUMN "number_of_employees" SET DEFAULT '0'`, + ); + await queryRunner.query( + `ALTER TABLE "organization_financial" ALTER COLUMN "number_of_employees" SET NOT NULL`, + ); + } +} diff --git a/backend/src/modules/_publicAPI/interfaces/user-with-organization.interface.ts b/backend/src/modules/_publicAPI/interfaces/user-with-organization.interface.ts index 7794df694..4af96de04 100644 --- a/backend/src/modules/_publicAPI/interfaces/user-with-organization.interface.ts +++ b/backend/src/modules/_publicAPI/interfaces/user-with-organization.interface.ts @@ -13,5 +13,8 @@ export interface IUserWithOrganization { activityArea: string; logo: string; description: string; + cui: string; + legalReprezentativeFullName: string; + legalReprezentativeRole: string; }; } diff --git a/backend/src/modules/_publicAPI/services/user-organization.service.ts b/backend/src/modules/_publicAPI/services/user-organization.service.ts index db9534ccf..ebbff119f 100644 --- a/backend/src/modules/_publicAPI/services/user-organization.service.ts +++ b/backend/src/modules/_publicAPI/services/user-organization.service.ts @@ -38,7 +38,7 @@ export class UserOrganizationService { } // check if there is an organization id - this methods also handles organization not found - const { organizationGeneral, organizationActivity } = + const { organizationGeneral, organizationActivity, organizationLegal } = await this.organizationService.findWithRelations(user.organizationId); // parse organization activityArea to string @@ -61,6 +61,11 @@ export class UserOrganizationService { activityArea: activityArea, logo: organizationGeneral.logo, description: organizationGeneral.shortDescription, + cui: organizationGeneral.cui, + legalReprezentativeFullName: + organizationLegal?.legalReprezentative?.fullName || '', + legalReprezentativeRole: + organizationLegal?.legalReprezentative?.role || '', }, }; } catch (error) { diff --git a/backend/src/modules/application/constants/application.constants.ts b/backend/src/modules/application/constants/application.constants.ts index 27d353eec..ed5d0f177 100644 --- a/backend/src/modules/application/constants/application.constants.ts +++ b/backend/src/modules/application/constants/application.constants.ts @@ -10,6 +10,7 @@ export const ORGANIZATION_ALL_APPS_COLUMNS = [ 'ongApp.status as "ongStatus"', 'ongApp.created_on as "createdOn"', 'application.type as type', + 'applicationLabel.name as "applicationLabel"', ]; export const APPLICATIONS_FILES_DIR = 'applications'; diff --git a/backend/src/modules/application/controllers/application.controller.ts b/backend/src/modules/application/controllers/application.controller.ts index 28fae5c19..9dd3843b3 100644 --- a/backend/src/modules/application/controllers/application.controller.ts +++ b/backend/src/modules/application/controllers/application.controller.ts @@ -108,24 +108,6 @@ export class ApplicationController { ); } - @Roles(Role.SUPER_ADMIN) - @ApiParam({ name: 'id', type: String }) - @Patch(':id/activate') - activate(@Param('id') id: number) { - return this.appService.update(id, { - status: ApplicationStatus.ACTIVE, - }); - } - - @Roles(Role.SUPER_ADMIN) - @ApiParam({ name: 'id', type: String }) - @Patch(':id/deactivate') - deactivate(@Param('id') id: number) { - return this.appService.update(id, { - status: ApplicationStatus.DISABLED, - }); - } - @Roles(Role.SUPER_ADMIN) @ApiParam({ name: 'id', type: String }) @ApiQuery({ type: () => ApplicationAccessFilterDto }) diff --git a/backend/src/modules/application/dto/create-application.dto.ts b/backend/src/modules/application/dto/create-application.dto.ts index 1726aacbb..2262a92b3 100644 --- a/backend/src/modules/application/dto/create-application.dto.ts +++ b/backend/src/modules/application/dto/create-application.dto.ts @@ -10,6 +10,8 @@ import { import { REGEX } from 'src/common/constants/patterns.constant'; import { ApplicationPullingType } from '../enums/application-pulling-type.enum'; import { ApplicationTypeEnum } from '../enums/ApplicationType.enum'; +import { ApplicationLabel } from 'src/shared/entities/application-labels.entity'; +import { ApplicationStatus } from '../enums/application-status.enum'; export class CreateApplicationDto { @IsString() @@ -56,4 +58,10 @@ export class CreateApplicationDto { @IsOptional() @Length(2, 100, { each: true }) steps?: string[]; + + @IsOptional() + applicationLabel: Partial; + + @IsEnum(ApplicationStatus) + status: ApplicationStatus; } diff --git a/backend/src/modules/application/dto/update-application.dto.ts b/backend/src/modules/application/dto/update-application.dto.ts index ce20eaaac..71973a8bc 100644 --- a/backend/src/modules/application/dto/update-application.dto.ts +++ b/backend/src/modules/application/dto/update-application.dto.ts @@ -1,15 +1,10 @@ import { PartialType, OmitType } from '@nestjs/swagger'; import { IsEnum, IsOptional } from 'class-validator'; -import { ApplicationStatus } from '../enums/application-status.enum'; import { CreateApplicationDto } from './create-application.dto'; export class UpdateApplicationDto extends PartialType( OmitType(CreateApplicationDto, ['type']), ) { - @IsEnum(ApplicationStatus) - @IsOptional() - status?: ApplicationStatus; - @IsOptional() cognitoClientId?: string; } diff --git a/backend/src/modules/application/entities/application.entity.ts b/backend/src/modules/application/entities/application.entity.ts index b88beb87e..05ebbc077 100644 --- a/backend/src/modules/application/entities/application.entity.ts +++ b/backend/src/modules/application/entities/application.entity.ts @@ -1,9 +1,17 @@ import { BaseEntity } from 'src/common/base/base-entity.class'; -import { Column, Entity, OneToMany } from 'typeorm'; +import { + Column, + Entity, + JoinColumn, + JoinTable, + ManyToOne, + OneToMany, +} from 'typeorm'; import { ApplicationPullingType } from '../enums/application-pulling-type.enum'; import { ApplicationStatus } from '../enums/application-status.enum'; import { ApplicationTypeEnum } from '../enums/ApplicationType.enum'; import { OngApplication } from './ong-application.entity'; +import { ApplicationLabel } from 'src/shared/entities/application-labels.entity'; @Entity() export class Application extends BaseEntity { @@ -60,4 +68,11 @@ export class Application extends BaseEntity { @OneToMany(() => OngApplication, (ongApp) => ongApp.application) ongApplications: OngApplication[]; + + @Column({ type: 'integer', name: 'application_label_id', nullable: true }) + applicationLabelId: number; + + @ManyToOne((type) => ApplicationLabel) + @JoinColumn({ name: 'application_label_id' }) + applicationLabel: ApplicationLabel; } diff --git a/backend/src/modules/application/interfaces/ong-application.interface.ts b/backend/src/modules/application/interfaces/ong-application.interface.ts index 80dcb4b4c..902e88e26 100644 --- a/backend/src/modules/application/interfaces/ong-application.interface.ts +++ b/backend/src/modules/application/interfaces/ong-application.interface.ts @@ -1,3 +1,4 @@ +import { ApplicationLabel } from 'src/shared/entities/application-labels.entity'; import { ApplicationPullingType } from '../enums/application-pulling-type.enum'; import { ApplicationStatus } from '../enums/application-status.enum'; import { ApplicationTypeEnum } from '../enums/ApplicationType.enum'; @@ -23,4 +24,5 @@ export interface IOngApplicationDetails extends IOngApplication { videoLink: string; userStatus: UserOngApplicationStatus; pullingType: ApplicationPullingType; + applicationLabel: ApplicationLabel; } diff --git a/backend/src/modules/application/services/application.service.ts b/backend/src/modules/application/services/application.service.ts index 901e8637f..297875623 100644 --- a/backend/src/modules/application/services/application.service.ts +++ b/backend/src/modules/application/services/application.service.ts @@ -37,6 +37,8 @@ import { ApplicationTableViewRepository } from '../repositories/application-tabl import { ApplicationRepository } from '../repositories/application.repository'; import { OngApplicationRepository } from '../repositories/ong-application.repository'; import { UserOngApplicationRepository } from '../repositories/user-ong-application.repository'; +import { ApplicationLabel } from 'src/shared/entities/application-labels.entity'; +import { NomenclaturesService } from 'src/shared/services'; @Injectable() export class ApplicationService { @@ -49,6 +51,7 @@ export class ApplicationService { private readonly ongApplicationRepository: OngApplicationRepository, private readonly userOngApplicationRepository: UserOngApplicationRepository, private readonly applicationOngViewRepository: ApplicationOngViewRepository, + private readonly nomenclatureService: NomenclaturesService, ) {} public async create( @@ -70,9 +73,17 @@ export class ApplicationService { }; } + let applicationLabel = null; + if (createApplicationDto.applicationLabel) { + applicationLabel = await this.saveAndGetApplicationLabel( + createApplicationDto.applicationLabel, + ); + } + // 3. save the app return this.applicationRepository.save({ ...createApplicationDto, + applicationLabel, }); } catch (error) { this.logger.error({ @@ -250,6 +261,7 @@ export class ApplicationService { 'application.video_link as "videoLink"', 'application.pulling_type as "pullingType"', 'application.status as "applicationStatus"', + 'applicationLabel', ]) .leftJoin( 'ong_application', @@ -262,6 +274,11 @@ export class ApplicationService { 'userOngApp', 'userOngApp.ong_application_id = ongApp.id', ) + .leftJoin( + '_application-label', + 'applicationLabel', + 'applicationLabel.id = application.application_label_id', + ) .where('application.id = :applicationId', { applicationId }); // for employee add further filtersin by user id @@ -292,8 +309,19 @@ export class ApplicationService { ); } + const applicationLabel = { + id: applicationWithDetails.applicationLabel_id, + name: applicationWithDetails.applicationLabel_name, + }; + + delete applicationWithDetails.applicationLabel_id; + delete applicationWithDetails.applicationLabel_name; + delete applicationWithDetails.applicationLabel_created_on; + delete applicationWithDetails.applicationLabel_updated_on; + return { ...applicationWithDetails, + applicationLabel, logo, }; } @@ -346,7 +374,17 @@ export class ApplicationService { }; } - return this.applicationRepository.update({ id }, applicationPayload); + let applicationLabel = null; + if (applicationPayload.applicationLabel) { + applicationLabel = await this.saveAndGetApplicationLabel( + applicationPayload.applicationLabel, + ); + } + + return this.applicationRepository.update( + { id }, + { ...applicationPayload, applicationLabel }, + ); } catch (error) { this.logger.error({ error: { error }, @@ -502,4 +540,17 @@ export class ApplicationService { return applicationCount; } + + private async saveAndGetApplicationLabel( + label: Partial, + ): Promise { + if (label.id) { + return label as ApplicationLabel; + } + + const newLabel = + await this.nomenclatureService.createApplicationLabel(label); + + return newLabel; + } } diff --git a/backend/src/modules/application/services/ong-application.service.ts b/backend/src/modules/application/services/ong-application.service.ts index 6dc627bc3..aea1db8a1 100644 --- a/backend/src/modules/application/services/ong-application.service.ts +++ b/backend/src/modules/application/services/ong-application.service.ts @@ -91,7 +91,7 @@ export class OngApplicationService { : OngApplicationStatus.ACTIVE, }); - if(application.type === ApplicationTypeEnum.STANDALONE) { + if (application.type === ApplicationTypeEnum.STANDALONE) { // 8. trigger emails for admin and super-admin this.eventEmitter.emit( EVENTS.REQUEST_APPLICATION_ACCESS, @@ -99,12 +99,11 @@ export class OngApplicationService { ); } - return ongApp; } catch (error) { Sentry.captureException(error, { - extra: {organizationId, applicationId}, - }) + extra: { organizationId, applicationId }, + }); this.logger.error({ error: { error }, ...ONG_APPLICATION_ERRORS.CREATE }); const err = error?.response; throw new BadRequestException({ @@ -133,6 +132,11 @@ export class OngApplicationService { 'ongApp.applicationId = application.id AND ongApp.organizationId = :organizationId', { organizationId }, ) + .leftJoin( + '_application-label', + 'applicationLabel', + 'applicationLabel.id = application.application_label_id', + ) .where( 'ongApp.organizationId = :organizationId and ongApp.status != :status', { @@ -174,6 +178,11 @@ export class OngApplicationService { const applicationsQuery = this.applicationRepository .getQueryBuilder() .select(ORGANIZATION_ALL_APPS_COLUMNS) + .leftJoin( + '_application-label', + 'applicationLabel', + 'applicationLabel.id = application.application_label_id', + ) .leftJoin( 'ong_application', 'ongApp', diff --git a/backend/src/modules/notifications/constants/events.contants.ts b/backend/src/modules/notifications/constants/events.contants.ts index 6531df1c0..9991257ef 100644 --- a/backend/src/modules/notifications/constants/events.contants.ts +++ b/backend/src/modules/notifications/constants/events.contants.ts @@ -6,4 +6,5 @@ export const EVENTS = { DISABLE_ORGANIZATION_REQUEST: 'disable.ong.request', RESTRICT_ORGANIZATION: 'restrict.organization', REQUEST_APPLICATION_ACCESS: 'application.request', + INVALID_FINANCIAL_REPORTS: 'organization.financial.reports.invalid', }; diff --git a/backend/src/modules/notifications/events/invalid-financial-reports-event.class.ts b/backend/src/modules/notifications/events/invalid-financial-reports-event.class.ts new file mode 100644 index 000000000..4157c3471 --- /dev/null +++ b/backend/src/modules/notifications/events/invalid-financial-reports-event.class.ts @@ -0,0 +1,7 @@ +export default class InvalidFinancialReportsEvent { + constructor(private _email: string) {} + + public get email() { + return this._email; + } +} diff --git a/backend/src/modules/notifications/services/notifications.service.ts b/backend/src/modules/notifications/services/notifications.service.ts index b046a005b..a83a48f9a 100644 --- a/backend/src/modules/notifications/services/notifications.service.ts +++ b/backend/src/modules/notifications/services/notifications.service.ts @@ -18,6 +18,7 @@ import RejectOngRequestEvent from '../events/reject-ong-request-event.class'; import RestrictOngEvent from '../events/restrict-ong-event.class'; import ApplicationRequestEvent from '../events/ong-requested-application-access-event.class'; import * as Sentry from '@sentry/node'; +import InvalidFinancialReportsEvent from '../events/invalid-financial-reports-event.class'; @Injectable() export class NotificationsService { @@ -38,9 +39,8 @@ export class NotificationsService { where: { role: Role.SUPER_ADMIN }, }); - const organziation = await this.organizationService.findWithRelations( - organizationId, - ); + const organziation = + await this.organizationService.findWithRelations(organizationId); // send email to admin to delete the application const { @@ -230,9 +230,8 @@ export class NotificationsService { try { const { organizationId } = payload; - const organization = await this.organizationService.findWithUsers( - organizationId, - ); + const organization = + await this.organizationService.findWithUsers(organizationId); const admins = organization.users.filter( (user) => user.role === Role.ADMIN, @@ -263,15 +262,17 @@ export class NotificationsService { } @OnEvent(EVENTS.REQUEST_APPLICATION_ACCESS) - async handleRequestApplicationAccess({applicationName, organizationId}: ApplicationRequestEvent) { - + async handleRequestApplicationAccess({ + applicationName, + organizationId, + }: ApplicationRequestEvent) { try { const superAdmins = await this.userService.findMany({ where: { role: Role.SUPER_ADMIN }, }); const organization = await this.organizationService.find(organizationId, { - relations: ['organizationGeneral'] + relations: ['organizationGeneral'], }); const { @@ -298,7 +299,6 @@ export class NotificationsService { }, }, }); - } catch (error) { Sentry.captureException(error); this.logger.error({ @@ -306,8 +306,34 @@ export class NotificationsService { error, }); } - } + @OnEvent(EVENTS.INVALID_FINANCIAL_REPORTS) + async handleRemindToCompleteFinancialData( + payload: InvalidFinancialReportsEvent, + ) { + const { + subject, + template, + context: { + title, + subtitle, + cta: { link, label }, + }, + } = MAIL_OPTIONS.NOTIFY_FOR_UNAVAILABLE_OR_INVALID_FINANCIAL_INFORMATION; + + await this.mailService.sendEmail({ + to: payload.email, + template, + subject, + context: { + title, + subtitle: subtitle(), + cta: { + link: link(), + label, + }, + }, + }); + } } - diff --git a/backend/src/modules/organization/constants/errors.constants.ts b/backend/src/modules/organization/constants/errors.constants.ts index 1702cb3c1..10306d282 100644 --- a/backend/src/modules/organization/constants/errors.constants.ts +++ b/backend/src/modules/organization/constants/errors.constants.ts @@ -109,10 +109,6 @@ export const ORGANIZATION_ERRORS = { message: 'Organization is restricted', errorCode: 'ORG_020', }, - COMPLETION_STATUS: { - message: 'Erorr while updating organization Completion Status', - errorCode: 'ORG_021', - }, GET_ORGANIZATIONS_WITH_ACTIVE_PRACTICE_PROGRAMS: { message: 'Error while loading organizations with practice programs', errorCode: 'ORG_022', diff --git a/backend/src/modules/organization/constants/organization-filter.config.ts b/backend/src/modules/organization/constants/organization-filter.config.ts index f45069146..1fb4636fa 100644 --- a/backend/src/modules/organization/constants/organization-filter.config.ts +++ b/backend/src/modules/organization/constants/organization-filter.config.ts @@ -12,7 +12,7 @@ export const ORGANIZATION_FILTERS_CONFIG = { completionStatus: true, logo: true, }, - searchableColumns: ['name'], + searchableColumns: ['name', 'alias'], defaultSortBy: 'id', defaultOrderDirection: OrderDirection.ASC, relations: {}, diff --git a/backend/src/modules/organization/controllers/organization-profile.controller.ts b/backend/src/modules/organization/controllers/organization-profile.controller.ts index b279368c5..9f8721a29 100644 --- a/backend/src/modules/organization/controllers/organization-profile.controller.ts +++ b/backend/src/modules/organization/controllers/organization-profile.controller.ts @@ -31,6 +31,8 @@ import { UpdateOrganizationDto } from '../dto/update-organization.dto'; import { Organization } from '../entities'; import { OrganizationRequestService } from '../services/organization-request.service'; import { OrganizationService } from '../services/organization.service'; +import { OrganizationFinancialService } from '../services'; +import { OrganizationReportService } from '../services/organization-report.service'; @ApiTooManyRequestsResponse() @UseInterceptors(ClassSerializerInterceptor) @@ -40,6 +42,8 @@ export class OrganizationProfileController { constructor( private readonly organizationService: OrganizationService, private readonly organizationRequestService: OrganizationRequestService, + private readonly organizationFinancialService: OrganizationFinancialService, + private readonly organizationReportService: OrganizationReportService, ) {} @Roles(Role.ADMIN, Role.EMPLOYEE) @@ -48,6 +52,30 @@ export class OrganizationProfileController { return this.organizationService.findWithRelations(user.organizationId); } + @Roles(Role.ADMIN) + @Get('reports-status') + async getReportsStatus( + @ExtractUser() user: User, + ): Promise<{ + numberOfErroredFinancialReports: number; + numberOfErroredReportsInvestorsPartners: number; + }> { + const numberOfErroredFinancialReports = + await this.organizationFinancialService.countNotCompletedReports( + user.organizationId, + ); + + const numberOfErroredReportsInvestorsPartners = + await this.organizationReportService.countNotCompletedReports( + user.organizationId, + ); + + return { + numberOfErroredFinancialReports, + numberOfErroredReportsInvestorsPartners, + }; + } + @Roles(Role.ADMIN) @ApiBody({ type: UpdateOrganizationDto }) @ApiConsumes('multipart/form-data') diff --git a/backend/src/modules/organization/controllers/testing.controller.ts b/backend/src/modules/organization/controllers/testing.controller.ts new file mode 100644 index 000000000..59617c2b3 --- /dev/null +++ b/backend/src/modules/organization/controllers/testing.controller.ts @@ -0,0 +1,44 @@ +import { + ClassSerializerInterceptor, + Controller, + Post, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTooManyRequestsResponse } from '@nestjs/swagger'; +import { Roles } from 'src/common/decorators/roles.decorator'; +import { Role } from 'src/modules/user/enums/role.enum'; +import { OrganizationCronsService } from '../services/crons.service'; + +@ApiTooManyRequestsResponse() +@UseInterceptors(ClassSerializerInterceptor) +@ApiBearerAuth() +@Controller('testing') +export class TestingController { + constructor( + private readonly organizationCronsService: OrganizationCronsService, + ) {} + + @Roles(Role.SUPER_ADMIN) + @Post('generate-reports-1st-january') + generateReports(): Promise { + return this.organizationCronsService.generateFinancialDataAndReportsForPreviousYear(); + } + + @Roles(Role.SUPER_ADMIN) + @Post('fetch-anaf-data') + fetchANAFDataForFinancialReports(): Promise { + return this.organizationCronsService.fetchANAFDataForFinancialReports(); + } + + @Roles(Role.SUPER_ADMIN) + @Post('complete-reports-1st-june') + sendEmailToRemindOrganizationProfileUpdate(): Promise { + return this.organizationCronsService.sendEmailToRemindOrganizationProfileUpdate(); + } + + @Roles(Role.SUPER_ADMIN) + @Post('complete-reports-1st-june-to-30th-june') + sendReminderForOrganizationProfileUpdate(): Promise { + return this.organizationCronsService.sendReminderForOrganizationProfileUpdate(); + } +} diff --git a/backend/src/modules/organization/entities/organization-financial.entity.ts b/backend/src/modules/organization/entities/organization-financial.entity.ts index 14bcfbb48..15772d2c4 100644 --- a/backend/src/modules/organization/entities/organization-financial.entity.ts +++ b/backend/src/modules/organization/entities/organization-financial.entity.ts @@ -4,7 +4,10 @@ import { Organization } from './organization.entity'; import { Income } from '../dto/income.dto'; import { Expense } from '../dto/expense.dto'; import { FinancialType } from '../enums/organization-financial-type.enum'; -import { CompletionStatus } from '../enums/organization-financial-completion.enum'; +import { + CompletionStatus, + OrganizationFinancialReportStatus, +} from '../enums/organization-financial-completion.enum'; import { Exclude } from 'class-transformer'; @Entity() @@ -30,14 +33,14 @@ export class OrganizationFinancial extends BaseEntity { @Column({ type: 'integer', name: 'number_of_employees', - default: 0, + nullable: true, }) numberOfEmployees: number; @Column({ type: 'integer', name: 'year' }) year: number; - @Column({ type: 'integer', name: 'total', default: 0 }) + @Column({ type: 'integer', name: 'total', nullable: true }) total: number; @Column({ @@ -46,7 +49,15 @@ export class OrganizationFinancial extends BaseEntity { name: 'completion_status', default: CompletionStatus.NOT_COMPLETED, }) - status: CompletionStatus; + status: CompletionStatus; // TODO: delete after data migration in production. The Status will be moved to reportStatus + + @Column({ + type: 'enum', + enum: OrganizationFinancialReportStatus, + name: 'status', + default: OrganizationFinancialReportStatus.NOT_COMPLETED, + }) + reportStatus: OrganizationFinancialReportStatus; @Column({ type: 'boolean', diff --git a/backend/src/modules/organization/entities/organization-general.entity.ts b/backend/src/modules/organization/entities/organization-general.entity.ts index 3133b1614..aa70cd9cf 100644 --- a/backend/src/modules/organization/entities/organization-general.entity.ts +++ b/backend/src/modules/organization/entities/organization-general.entity.ts @@ -67,7 +67,7 @@ export class OrganizationGeneral extends BaseEntity { @Column({ type: 'text', name: 'description', nullable: true }) description: string; - @Column({ type: 'text', name: 'address', nullable: false, default: '' }) + @Column({ type: 'text', name: 'address', nullable: true, default: '' }) address: string; @Column({ type: 'text', name: 'logo', nullable: true }) diff --git a/backend/src/modules/organization/entities/organization-history.entity.ts b/backend/src/modules/organization/entities/organization-history.entity.ts index 23e6d1684..e3249fc7f 100644 --- a/backend/src/modules/organization/entities/organization-history.entity.ts +++ b/backend/src/modules/organization/entities/organization-history.entity.ts @@ -35,12 +35,6 @@ export class OrganizationHistory ============================================================= */ - @Column({ - type: 'timestamp with time zone', - name: 'synced_on', - }) - syncedOn: Date; - @Column({ type: 'enum', enum: OrganizationStatus, diff --git a/backend/src/modules/organization/entities/organization-view.entity.ts b/backend/src/modules/organization/entities/organization-view.entity.ts index 7e6497041..533bdb49c 100644 --- a/backend/src/modules/organization/entities/organization-view.entity.ts +++ b/backend/src/modules/organization/entities/organization-view.entity.ts @@ -3,11 +3,46 @@ import { CompletionStatus } from '../enums/organization-financial-completion.enu import { OrganizationStatus } from '../enums/organization-status.enum'; @ViewEntity('OrganizationView', { - expression: `SELECT "organization".id as "id", "organization".status AS "status", "organization".created_on as "createdOn", "organization".updated_on as "updatedOn", "organization".completion_status as "completionStatus", "organization_general".name as "name","organization_general".alias as "alias", COUNT("user".id) as "userCount", "organization_general".logo as "logo" FROM "organization" "organization" - LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id - LEFT JOIN "user" "user" ON "user".organization_id = "organization".id AND "user".deleted_on IS NULL AND "user".role = 'employee' and "user".status != 'pending' - WHERE "organization".status != 'pending' - GROUP BY "organization".id, "organization_general".id + expression: ` + SELECT + "organization".id AS "id", + "organization".status AS "status", + "organization".created_on AS "createdOn", + CASE + WHEN COUNT(DISTINCT CASE WHEN "organization_financial".status NOT IN ('Completed', 'Pending') AND "organization_financial".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "report".status != 'Completed' AND "report".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "partner".status != 'Completed' AND "partner".status IS NOT NULL THEN 1 END) > 0 + OR COUNT(DISTINCT CASE WHEN "investor".status != 'Completed' AND "investor".status IS NOT NULL THEN 1 END) > 0 + THEN 'Not Completed' + ELSE 'Completed' + END AS "completionStatus", + "organization_general".name AS "name", + "organization_general".alias AS "alias", + "organization_general".contact_person->>'email' AS "adminEmail", + COUNT(DISTINCT "user".id) AS "userCount", + "organization_general".logo AS "logo", + CASE + WHEN MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01'))) = '1970-01-01' + THEN NULL + ELSE MAX(GREATEST(COALESCE("organization_financial".updated_on, '1970-01-01'), COALESCE("report".updated_on, '1970-01-01'), COALESCE("partner".updated_on, '1970-01-01'), COALESCE("investor".updated_on, '1970-01-01')))::text + END AS "updatedOn" + FROM + "organization" "organization" + LEFT JOIN "organization_general" "organization_general" ON "organization".organization_general_id = "organization_general".id + LEFT JOIN "user" "user" ON "user".organization_id = "organization".id + AND "user".deleted_on IS NULL + AND "user".ROLE = 'employee' + AND "user".status != 'pending' + LEFT JOIN "organization_financial" "organization_financial" ON "organization".id = "organization_financial"."organizationId" + LEFT JOIN "organization_report" "organization_report" ON "organization".organization_report_id = "organization_report".id + LEFT JOIN "report" "report" ON "organization_report".id = "report"."organizationReportId" + LEFT JOIN "partner" "partner" ON "organization_report".id = "partner"."organizationReportId" + LEFT JOIN "investor" "investor" ON "organization_report".id = "investor"."organizationReportId" + WHERE + "organization".status != 'pending' + GROUP BY + "organization".id, + "organization_general".id `, }) export class OrganizationView { @@ -21,7 +56,7 @@ export class OrganizationView { createdOn: Date; @ViewColumn() - updatedOn: Date; + updatedOn: Date | null; @ViewColumn() name: string; @@ -37,4 +72,7 @@ export class OrganizationView { @ViewColumn() completionStatus: CompletionStatus; + + @ViewColumn() + adminEmail: string; } diff --git a/backend/src/modules/organization/entities/organization.entity.ts b/backend/src/modules/organization/entities/organization.entity.ts index ae824ad9f..e5f58154d 100644 --- a/backend/src/modules/organization/entities/organization.entity.ts +++ b/backend/src/modules/organization/entities/organization.entity.ts @@ -15,12 +15,6 @@ import { OrganizationRequest } from './organization-request.entity'; @Entity() export class Organization extends BaseEntity { - @Column({ - type: 'timestamp with time zone', - name: 'synced_on', - }) - syncedOn: Date; - @Column({ type: 'enum', enum: OrganizationStatus, @@ -29,14 +23,6 @@ export class Organization extends BaseEntity { }) status: OrganizationStatus; - @Column({ - type: 'enum', - enum: CompletionStatus, - name: 'completion_status', - default: CompletionStatus.NOT_COMPLETED, - }) - completionStatus: CompletionStatus; - @Exclude() @Column({ type: 'integer', diff --git a/backend/src/modules/organization/enums/organization-financial-completion.enum.ts b/backend/src/modules/organization/enums/organization-financial-completion.enum.ts index 7ea9b6149..019453f78 100644 --- a/backend/src/modules/organization/enums/organization-financial-completion.enum.ts +++ b/backend/src/modules/organization/enums/organization-financial-completion.enum.ts @@ -2,3 +2,20 @@ export enum CompletionStatus { COMPLETED = 'Completed', NOT_COMPLETED = 'Not Completed', } + +/** + * + * 1. not_completed - financial reports exist, but no data has been added. + * 2. pending - financial reports exist, admin filled in some information, but ANAF information is not yet ready. + * 3. completed - financial reports exist, admin filled in and it checks out against ANAF information. + * 4. invalid - financial reports exist, admin filled in but it does not check out against ANAF information. + * + * @export + * @enum {number} + */ +export enum OrganizationFinancialReportStatus { + NOT_COMPLETED = 'Not Completed', + PENDING = 'Pending', + COMPLETED = 'Completed', + INVALID = 'Invalid', +} diff --git a/backend/src/modules/organization/organization.module.ts b/backend/src/modules/organization/organization.module.ts index d1f400721..0998dd589 100644 --- a/backend/src/modules/organization/organization.module.ts +++ b/backend/src/modules/organization/organization.module.ts @@ -45,6 +45,8 @@ import { OrganizationHistory } from './entities/organization-history.entity'; import { OrganizationRequestHistory } from './entities/organization-request-history.entity'; import { PracticeProgramModule } from '../practice-program/practice-program.module'; import { CivicCenterModule } from '../civic-center-service/civic-center.module'; +import { OrganizationCronsService } from './services/crons.service'; +import { TestingController } from './controllers/testing.controller'; @Module({ imports: [ @@ -68,7 +70,11 @@ import { CivicCenterModule } from '../civic-center-service/civic-center.module'; PracticeProgramModule, CivicCenterModule, ], - controllers: [OrganizationController, OrganizationProfileController], + controllers: [ + OrganizationController, + OrganizationProfileController, + TestingController, + ], providers: [ ContactService, OrganizationService, @@ -90,7 +96,14 @@ import { CivicCenterModule } from '../civic-center-service/civic-center.module'; OrganizationViewRepository, OrganizationRequestRepository, OrganizationRequestService, + OrganizationCronsService, + ], + exports: [ + OrganizationService, + OrganizationRequestService, + OrganizationFinancialService, + OrganizationReportService, + OrganizationCronsService, // FOR TESTING CONTROLLER PURPOSE ], - exports: [OrganizationService, OrganizationRequestService], }) export class OrganizationModule {} diff --git a/backend/src/modules/organization/services/crons.service.ts b/backend/src/modules/organization/services/crons.service.ts new file mode 100644 index 000000000..702d0bb4d --- /dev/null +++ b/backend/src/modules/organization/services/crons.service.ts @@ -0,0 +1,205 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { OrganizationRepository } from '../repositories/organization.repository'; +import { + OrganizationFinancialRepository, + OrganizationViewRepository, +} from '../repositories'; +import { OrganizationFinancialService } from './organization-financial.service'; +import { OrganizationReportService } from './organization-report.service'; +import * as Sentry from '@sentry/node'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { MailService } from 'src/mail/services/mail.service'; +import { MAIL_OPTIONS } from 'src/mail/constants/template.constants'; +import { EntityManager, In, Not, getManager } from 'typeorm'; +import { + CompletionStatus, + OrganizationFinancialReportStatus, +} from '../enums/organization-financial-completion.enum'; +import { OrganizationStatus } from '../enums/organization-status.enum'; + +@Injectable() +export class OrganizationCronsService { + private readonly logger = new Logger(OrganizationCronsService.name); + + constructor( + private readonly organizationRepository: OrganizationRepository, + private readonly organizationViewRepository: OrganizationViewRepository, + private readonly organizationFinancialService: OrganizationFinancialService, + private readonly organizationReportService: OrganizationReportService, + private readonly mailService: MailService, + ) {} + + /** + * * * * * * + | | | | | | + | | | | | day of week + | | | | months + | | | day of month + | | hours + | minutes + seconds (optional) +*/ + + @Cron('0 5 1 1 *') // 1st of January, 5:00 AM + async generateFinancialDataAndReportsForPreviousYear() { + const thisYear = new Date().getFullYear(); + const lastYear = thisYear - 1; + + // 1. Get all organizations with are missing the previous year the financial data and reports + const organizations = await this.organizationRepository.getMany({ + where: { + organizationGeneral: { + yearCreated: Not(thisYear), + }, + }, + relations: { + organizationFinancial: true, + organizationGeneral: true, + organizationReport: { + reports: true, + partners: true, + investors: true, + }, + }, + }); + + for (const org of organizations) { + try { + // 2. Generate the financial reports + await this.organizationFinancialService.generateNewReports({ + organization: org, + year: lastYear, + }); + + // 5. Generate the Reports / Partners / Investors + await this.organizationReportService.generateNewReports({ + organization: org, + year: lastYear, + }); + } catch (err) { + this.logger.error(err); + Sentry.captureException(err, { + extra: { + organizationId: org.id, + year: lastYear, + }, + }); + } + } + } + + /** + * + * Organizations must complete their reports until 30th of June each year, otherwise the account will be suspended + * + * The reports are: + * 1. Financial Report + * 2. ONG In Numere + * 2.1. Reports + * 2.2. Investors + * 2.3. Partners + * + * + * On 1st Of june, we send an email to all organization which didn't fully complete their reports. + * + */ + @Cron('0 12 1 6 *') // 1st of June, 12 PM server time + async sendEmailToRemindOrganizationProfileUpdate() { + // 1. Get all organizations missin the completion of financial data and reports + const organizations: { adminEmail: string }[] = + await this.organizationViewRepository.getMany({ + where: { + status: OrganizationStatus.ACTIVE, + completionStatus: CompletionStatus.NOT_COMPLETED, + }, + select: { + adminEmail: true, + }, + }); + + const receivers = organizations.map((org) => org.adminEmail); + + const { + subject, + template, + context: { + title, + subtitle, + cta: { link, label }, + }, + } = MAIL_OPTIONS.REMIND_TO_UPDATE_ORGANIZATION_REPORTS; + + for (let email of receivers) { + await this.mailService.sendEmail({ + to: email, + template, + subject, + context: { + title, + subtitle: subtitle(), + cta: { + link: link(), + label, + }, + }, + }); + } + } + + // Every monday at 8:00 AM (server time) from 8th to 30th of June + @Cron('0 8 8-30 6 1') + async sendReminderForOrganizationProfileUpdate() { + // 1. Get all organizations missin the completion of financial data and reports + const organizations: { adminEmail: string }[] = + await this.organizationViewRepository.getMany({ + where: { + status: OrganizationStatus.ACTIVE, + completionStatus: CompletionStatus.NOT_COMPLETED, + }, + select: { + adminEmail: true, + }, + }); + + const receivers = organizations.map((org) => org.adminEmail); + + const { + subject, + template, + context: { + title, + subtitle, + cta: { link, label }, + }, + } = MAIL_OPTIONS.WEEKLY_REMINDER_TO_UPDATE_ORGANIZATION_REPORTS; + + for (let email of receivers) { + await this.mailService.sendEmail({ + to: email, + template, + subject, + context: { + title, + subtitle: subtitle(), + cta: { + link: link(), + label, + }, + }, + }); + } + } + + /** + * At 07:00 (Server Time) every Monday in every month from June through December. + */ + @Cron('0 7 * 6-12 1') + async fetchANAFDataForFinancialReports() { + try { + await this.organizationFinancialService.refetchANAFDataForFinancialReports(); + } catch (err) { + Sentry.captureMessage('fetchANAFDataForFinancialReports failed'); + Sentry.captureException(err); + } + } +} diff --git a/backend/src/modules/organization/services/organization-financial.service.ts b/backend/src/modules/organization/services/organization-financial.service.ts index 6512611fe..f14786d41 100644 --- a/backend/src/modules/organization/services/organization-financial.service.ts +++ b/backend/src/modules/organization/services/organization-financial.service.ts @@ -1,17 +1,29 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { UpdateOrganizationFinancialDto } from '../dto/update-organization-financial.dto'; -import { OrganizationFinancialRepository } from '../repositories'; +import { + OrganizationFinancialRepository, + OrganizationRepository, +} from '../repositories'; import { ORGANIZATION_ERRORS } from '../constants/errors.constants'; -import { CompletionStatus } from '../enums/organization-financial-completion.enum'; +import { + CompletionStatus, + OrganizationFinancialReportStatus, +} from '../enums/organization-financial-completion.enum'; import { FinancialType } from '../enums/organization-financial-type.enum'; -import { OrganizationFinancial } from '../entities'; +import { Organization, OrganizationFinancial } from '../entities'; import { AnafService, FinancialInformation, } from 'src/shared/services/anaf.service'; -import { OnEvent } from '@nestjs/event-emitter'; +import { EventEmitter2, OnEvent } from '@nestjs/event-emitter'; import CUIChangedEvent from '../events/CUI-changed-event.class'; import { ORGANIZATION_EVENTS } from '../constants/events.constants'; +import * as Sentry from '@sentry/node'; +import { In, Not } from 'typeorm'; +import { EVENTS } from 'src/modules/notifications/constants/events.contants'; +import InvalidFinancialReportsEvent from 'src/modules/notifications/events/invalid-financial-reports-event.class'; +import { OrganizationStatus } from '../enums/organization-status.enum'; +import { ContactPerson } from '../interfaces/contact-person.interface'; @Injectable() export class OrganizationFinancialService { @@ -19,14 +31,18 @@ export class OrganizationFinancialService { constructor( private readonly organizationFinancialRepository: OrganizationFinancialRepository, + private readonly organizationRepository: OrganizationRepository, private readonly anafService: AnafService, + private readonly eventEmitter: EventEmitter2, ) {} + // TODO: Deprecated, we don't allow changing the CUI anymore, so this is obsolete. To be discussed and deleted @OnEvent(ORGANIZATION_EVENTS.CUI_CHANGED) async handleCuiChanged({ organizationId, newCUI }: CUIChangedEvent) { return this.handleRegenerateFinancial({ organizationId, cui: newCUI }); } + // TODO: Deprecated, we don't allow changing the CUI anymore, so this is obsolete. To be discussed and deleted public async handleRegenerateFinancial({ organizationId, cui, @@ -41,7 +57,7 @@ export class OrganizationFinancialService { }); // 2. Get the financial data from ANAF for the new CUI const lastYear = new Date().getFullYear() - 1; - const financialFromAnaf = await this.getFinancialInformation( + const financialFromAnaf = await this.getFinancialInformationFromANAF( cui, lastYear, ); @@ -78,20 +94,31 @@ export class OrganizationFinancialService { where: { id: updateOrganizationFinancialDto.id }, }); + if (!organizationFinancial) { + throw new NotFoundException({ + ...ORGANIZATION_ERRORS.ANAF, // TODO: update error as it has incorrect message. Basically here we didn't find the entity to update, we don't have problems with the ANAF data + }); + } + const totals = Object.values(updateOrganizationFinancialDto.data).reduce( (prev: number, current: number) => (prev += +current || 0), 0, ); - if (!organizationFinancial) { - throw new NotFoundException({ - ...ORGANIZATION_ERRORS.ANAF, - }); - } + let reportStatus: OrganizationFinancialReportStatus; + + // BR: Look into OrganizationFinancialReportStatus (ENUM) to understand the business logic behind the statuses + reportStatus = this.determineReportStatus( + totals, + organizationFinancial.total, + organizationFinancial.synched_anaf, + ); return this.organizationFinancialRepository.save({ ...organizationFinancial, data: updateOrganizationFinancialDto.data, + reportStatus, + // TODO: remove this status status: totals === organizationFinancial.total ? CompletionStatus.COMPLETED @@ -121,7 +148,45 @@ export class OrganizationFinancialService { ]; } - public async getFinancialInformation( + /** + * Determines the status of a financial report based on the organization's input and ANAF data. + * + * @param addedByOrganizationTotal - The total amount entered by the organization (per each category). + * @param anafTotal - The total amount retrieved from ANAF. + * @param isSynced - Whether the report has been synced with ANAF data. + * @returns The status of the financial report. + */ + private determineReportStatus( + addedByOrganizationTotal: number, + anafTotal: number, + isSynced: boolean, + ): OrganizationFinancialReportStatus { + // If the organization hasn't entered any data, the report is not completed + if (addedByOrganizationTotal === null) { + return OrganizationFinancialReportStatus.NOT_COMPLETED; + } + + // If the report is synced with ANAF data + if (isSynced) { + // If the totals match, the report is completed + if (anafTotal === addedByOrganizationTotal) { + return OrganizationFinancialReportStatus.COMPLETED; + } else { + // If the totals don't match, the report is invalid + return OrganizationFinancialReportStatus.INVALID; + } + } else { + // If not synced but the organization has entered some data, the report is pending + if (addedByOrganizationTotal !== 0) { + return OrganizationFinancialReportStatus.PENDING; + } else { + // If not synced and no data entered, the report is not completed + return OrganizationFinancialReportStatus.NOT_COMPLETED; + } + } + } + + public async getFinancialInformationFromANAF( cui: string, year: number, ): Promise { @@ -141,10 +206,245 @@ export class OrganizationFinancialService { return obj.indicator === 'I46'; }); + // If any of the required indicators are undefined, it likely means + // the CUI is not for an NGO or the ANAF service structure has changed + if ( + income === undefined || + expense === undefined || + employees === undefined + ) { + const missingIndicators = [ + income === undefined ? 'I38 (income)' : null, + expense === undefined ? 'I40 (expense)' : null, + employees === undefined ? 'I46 (employees)' : null, + ] + .filter(Boolean) + .join(', '); + + const sentryMessage = `ANAF data missing required indicators (${missingIndicators}) for CUI: ${cui}, Year: ${year}`; + Sentry.captureMessage(sentryMessage, { + level: 'warning', + extra: { + cui, + year, + anafData, + missingIndicators, + }, + }); + this.logger.warn(sentryMessage); + return null; + } + return { numberOfEmployees: employees?.val_indicator, totalExpense: expense?.val_indicator, totalIncome: income?.val_indicator, }; } + + public async generateNewReports({ + organization, + year, + }: { + organization: Organization; + year: number; + }): Promise { + if ( + organization.organizationFinancial.find( + (financial) => financial.year === year, + ) + ) { + // Avoid duplicating data + return; + } + + const financialFromAnaf = await this.getFinancialInformationFromANAF( + organization.organizationGeneral.cui, + year, + ); + + // 3. Generate financial reports data + const newFinancialReport = this.generateFinancialReportsData( + year, + financialFromAnaf, + ); + + // 4. Save the new reports + try { + await Promise.all( + newFinancialReport.map((orgFinancial) => + this.organizationFinancialRepository.save({ + ...orgFinancial, + organizationId: organization.id, + }), + ), + ); + } catch (err) { + Sentry.captureException(err, { + extra: { organization, year }, + }); + } + } + + public async countNotCompletedReports(organizationId: number) { + const count = await this.organizationFinancialRepository.count({ + where: { + organizationId, + reportStatus: Not( + In([ + OrganizationFinancialReportStatus.COMPLETED, + OrganizationFinancialReportStatus.PENDING, + ]), + ), + }, + }); + + return count; + } + + async refetchANAFDataForFinancialReports() { + // 1. Find all organizations with missing ANAF data in the Financial Reports + type OrganizationsWithMissingANAFData = { + id: number; + organizationFinancial: OrganizationFinancial[]; + organizationGeneral: { + cui: string; + email: string; + contact: ContactPerson; + }; + }; + + const data: OrganizationsWithMissingANAFData[] = + await this.organizationRepository.getMany({ + relations: { + organizationFinancial: true, + organizationGeneral: true, + }, + where: { + status: OrganizationStatus.ACTIVE, + organizationFinancial: { + synched_anaf: false, + }, + }, + select: { + id: true, + organizationGeneral: { + cui: true, + email: true, + contact: { + email: true, + }, + }, + organizationFinancial: true, + }, + }); + + for (let org of data) { + try { + // Find all years for which we should call ANAF services + type YearsFinancial = { + [year: string]: { + Income: { id: number; existingTotal: number }; + Expense: { id: number; existingTotal: number }; + }; + }; + const years: YearsFinancial = org.organizationFinancial.reduce( + (acc, curr) => { + if (!acc[curr.year]) { + acc[curr.year] = {}; + } + + const existingData = curr.data as Object; + let existingTotal = null; + + if (existingData) { + existingTotal = Object.keys(existingData).reduce( + (acc, curr) => acc + +existingData[curr], + 0, + ); + } + + acc[curr.year][curr.type] = { id: curr.id, existingTotal }; + + return acc; + }, + {}, + ); + + // A notification will be sent to the organization if the completed data is invalid + let sendNotificationForInvalidData = false; + + // Iterate over all years and call ANAF + for (let year of Object.keys(years)) { + // 2. Fetch data from ANAF for the given CUI and YEAR + const anafData = await this.getFinancialInformationFromANAF( + org.organizationGeneral.cui, + +year, + ); + + if (anafData) { + // We have the data, upadate the reports. First "Income" + if (years[year].Income) { + const reportStatus = this.determineReportStatus( + years[year].Income.existingTotal, + anafData.totalIncome, + true, + ); + await this.organizationFinancialRepository.updateOne({ + id: years[year].Income.id, + total: anafData.totalIncome, + numberOfEmployees: anafData.numberOfEmployees, + synched_anaf: true, + reportStatus, + }); + + sendNotificationForInvalidData = sendNotificationForInvalidData + ? true + : reportStatus !== OrganizationFinancialReportStatus.COMPLETED; + } + + // Second: "Expense" + if (years[year].Expense) { + const reportStatus = this.determineReportStatus( + years[year].Expense.existingTotal, + anafData.totalExpense, + true, + ); + await this.organizationFinancialRepository.updateOne({ + id: years[year].Expense.id, + total: anafData.totalExpense, + numberOfEmployees: anafData.numberOfEmployees, + synched_anaf: true, + reportStatus, + }); + + sendNotificationForInvalidData = sendNotificationForInvalidData + ? true + : reportStatus !== OrganizationFinancialReportStatus.COMPLETED; + } + } + } + + // In case one of the report is invalid, we notify the ADMIN to modify them + // ! COMMENTED FOR NOW AS PER MIRUNA REQUEST UNTIL FURTHER NOTICE + // if ( + // sendNotificationForInvalidData && + // org.organizationGeneral?.contact?.email + // ) { + // this.eventEmitter.emit( + // EVENTS.INVALID_FINANCIAL_REPORTS, + // new InvalidFinancialReportsEvent( + // org.organizationGeneral?.contact?.email, + // ), + // ); + // } + } catch (err) { + Sentry.captureException(err, { + extra: { + organization: org.id, + }, + }); + } + } + } } diff --git a/backend/src/modules/organization/services/organization-report.service.ts b/backend/src/modules/organization/services/organization-report.service.ts index cd3d7bf93..bc9535136 100644 --- a/backend/src/modules/organization/services/organization-report.service.ts +++ b/backend/src/modules/organization/services/organization-report.service.ts @@ -19,7 +19,7 @@ import { OrganizationReportRepository, PartnerRepository, } from '../repositories'; -import { OrganizationReport } from '../entities'; +import { Organization, OrganizationReport } from '../entities'; import { S3FileManagerService } from 'src/shared/services/s3-file-manager.service'; import { INVESTOR_LIST, @@ -27,7 +27,8 @@ import { PARTNER_LIST, } from '../constants/files.constants'; import { FILE_TYPE } from 'src/shared/enum/FileType.enum'; -import { FILE_ERRORS } from 'src/shared/constants/file-errors.constants'; +import * as Sentry from '@sentry/node'; +import { Not } from 'typeorm'; @Injectable() export class OrganizationReportService { @@ -228,4 +229,100 @@ export class OrganizationReportService { status: CompletionStatus.NOT_COMPLETED, }); } + + public async generateNewReports({ + organization, + year, + }: { + organization: Organization; + year: number; + }): Promise { + let organizationReport = organization.organizationReport; + + if (!organizationReport) { + const newOrganizationReport = + await this.organizationReportRepository.save({ + organization: { id: organization.id }, + reports: [], + partners: [], + investors: [], + }); + organizationReport = newOrganizationReport; + } + + // Check if the given organizationId has already reports for the given year to avoid duplicating them + const hasReport = organizationReport?.reports.find( + (report) => report.year === year, + ); + const hasPartners = organizationReport?.partners.find( + (partner) => partner.year === year, + ); + const hasInvestors = organizationReport?.investors.find( + (investor) => investor.year === year, + ); + + if (hasReport && hasPartners && hasInvestors) { + return; + } + + try { + await this.organizationReportRepository.save({ + ...organizationReport, + ...(!hasReport + ? { reports: [...organizationReport.reports, { year }] } + : {}), + ...(!hasPartners + ? { + partners: [...organizationReport.partners, { year }], + } + : {}), + ...(!hasInvestors + ? { + investors: [...organizationReport.investors, { year }], + } + : {}), + }); + } catch (err) { + console.log(err); + Sentry.captureException(err, { + extra: { + organization, + year, + }, + }); + } + } + + public async countNotCompletedReports(organizationId: number) { + const count = await this.organizationReportRepository.count({ + where: [ + { + organization: { + id: organizationId, + }, + partners: { + status: Not(CompletionStatus.COMPLETED), + }, + }, + { + organization: { + id: organizationId, + }, + reports: { + status: Not(CompletionStatus.COMPLETED), + }, + }, + { + organization: { + id: organizationId, + }, + investors: { + status: Not(CompletionStatus.COMPLETED), + }, + }, + ], + }); + + return count; + } } diff --git a/backend/src/modules/organization/services/organization.service.ts b/backend/src/modules/organization/services/organization.service.ts index 90d114729..c1cf41309 100644 --- a/backend/src/modules/organization/services/organization.service.ts +++ b/backend/src/modules/organization/services/organization.service.ts @@ -58,7 +58,7 @@ import { OrganizationReportService } from './organization-report.service'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { EVENTS } from 'src/modules/notifications/constants/events.contants'; import RestrictOngEvent from 'src/modules/notifications/events/restrict-ong-event.class'; -import { isBefore } from 'date-fns'; +import * as Sentry from '@sentry/node'; @Injectable() export class OrganizationService { @@ -198,24 +198,21 @@ export class OrganizationService { where: { id: In(createOrganizationDto.activity.domains) }, }); - // BR: ANAF data is available after 30 June each year (aprox). - // If the current date is gt 30 June we query the last year, otherwise 2 years ago const lastYear = new Date().getFullYear() - 1; - const _30June = new Date(new Date().getFullYear(), 5, 30); - const yearToGetAnafData = isBefore(new Date(), _30June) - ? lastYear - 1 - : lastYear; + // BR: ANAF data will be available only if the organization existed last year, otherwise is futile to try to fetch it or generate reports. + const organizationExistedLastYear = + createOrganizationDto.general.yearCreated < new Date().getFullYear(); // get anaf data - const financialInformation = - await this.organizationFinancialService.getFinancialInformation( - createOrganizationDto.general.cui, - yearToGetAnafData, - ); + const financialInformation = organizationExistedLastYear + ? await this.organizationFinancialService.getFinancialInformationFromANAF( + createOrganizationDto.general.cui, + lastYear, + ) + : null; // create the parent entry with default values const organization = await this.organizationRepository.save({ - syncedOn: new Date(), organizationGeneral: { contact: { fullName: createUserRequestDto.name, @@ -235,16 +232,33 @@ export class OrganizationService { organizationLegal: { ...createOrganizationDto.legal, }, - organizationFinancial: - this.organizationFinancialService.generateFinancialReportsData( - yearToGetAnafData, - financialInformation, - ), - organizationReport: { - reports: [{ year: yearToGetAnafData }], - partners: [{ year: yearToGetAnafData }], - investors: [{ year: yearToGetAnafData }], - }, + ...(organizationExistedLastYear + ? { + organizationFinancial: + this.organizationFinancialService.generateFinancialReportsData( + lastYear, + financialInformation, + ), + } + : {}), + // Initialize organizationReport based on whether the organization existed last year + ...(organizationExistedLastYear + ? { + // If the organization existed last year, create initial reports, partners, and investors for that year + organizationReport: { + reports: [{ year: lastYear }], + partners: [{ year: lastYear }], + investors: [{ year: lastYear }], + }, + } + : { + // If the organization is new, initialize empty arrays for reports, partners, and investors + organizationReport: { + reports: [], + partners: [], + investors: [], + }, + }), }); // upload logo @@ -359,14 +373,13 @@ export class OrganizationService { 'organizationLegal', 'organizationLegal.legalReprezentative', 'organizationLegal.directors', - 'organizationFinancial', - 'organizationReport', - 'organizationReport.reports', - 'organizationReport.partners', - 'organizationReport.investors', + // 'organizationFinancial', + // 'organizationReport', + // 'organizationReport.reports', + // 'organizationReport.partners', + // 'organizationReport.investors', ], }); - const ignoreKeys = [ 'organizationGeneralId', 'organizationActivityId', @@ -486,11 +499,11 @@ export class OrganizationService { 'organizationLegal.legalReprezentative.phone': 'Legal Representative Phone', 'organizationLegal.directors': 'Directors', - organizationFinancial: 'Organization Financials', - 'organizationReport.id': 'Organization Report ID', - 'organizationReport.reports': 'Reports', - 'organizationReport.partners': 'Partners', - 'organizationReport.investors': 'Investors', + // organizationFinancial: 'Organization Financials', + // 'organizationReport.id': 'Organization Report ID', + // 'organizationReport.reports': 'Reports', + // 'organizationReport.partners': 'Partners', + // 'organizationReport.investors': 'Investors', 'organizationGeneral.contact.name': 'Contact Name', 'organizationGeneral.organizationCity': 'Organization City', 'organizationGeneral.organizationCounty': 'Organization County', @@ -520,11 +533,9 @@ export class OrganizationService { } return res; } - const flatten = organizations.map((org) => { return flattenObject(org); }); - return flatten; } @@ -1040,8 +1051,6 @@ export class OrganizationService { updateOrganizationDto.financial, ); - await this.updateOrganizationCompletionStatus(organization.id); - return organizationFinancial; } @@ -1051,8 +1060,6 @@ export class OrganizationService { updateOrganizationDto.report, ); - await this.updateOrganizationCompletionStatus(organization.id); - return organizationReport; } @@ -1082,8 +1089,6 @@ export class OrganizationService { files, ); - await this.updateOrganizationCompletionStatus(organization.id); - return this.organizationReportService.findOne( organization.organizationReportId, ); @@ -1112,8 +1117,6 @@ export class OrganizationService { files, ); - await this.updateOrganizationCompletionStatus(organization.id); - return this.organizationReportService.findOne( organization.organizationReportId, ); @@ -1135,8 +1138,6 @@ export class OrganizationService { await this.organizationReportService.deletePartner(partnerId); - await this.updateOrganizationCompletionStatus(organization.id); - return this.organizationReportService.findOne( organization.organizationReportId, ); @@ -1158,8 +1159,6 @@ export class OrganizationService { await this.organizationReportService.deleteInvestor(investorId); - await this.updateOrganizationCompletionStatus(organization.id); - return this.organizationReportService.findOne( organization.organizationReportId, ); @@ -1311,45 +1310,59 @@ export class OrganizationService { await queryRunner.manager.delete(Organization, organizationId); // 2. delete report - const reportIds = organization.organizationReport.reports.map( - (report) => report.id, - ); - await queryRunner.manager.delete(Report, reportIds); + if (organization.organizationReport?.reports.length) { + const reportIds = organization.organizationReport.reports.map( + (report) => report.id, + ); + await queryRunner.manager.delete(Report, reportIds); + } - const inverstorIds = organization.organizationReport.investors.map( - (investor) => investor.id, - ); - await queryRunner.manager.delete(Investor, inverstorIds); + if (organization.organizationReport?.investors.length) { + const inverstorIds = organization.organizationReport.investors.map( + (investor) => investor.id, + ); + await queryRunner.manager.delete(Investor, inverstorIds); + } - const partnerIds = organization.organizationReport.partners.map( - (partner) => partner.id, - ); - await queryRunner.manager.delete(Partner, partnerIds); + if (organization.organizationReport?.partners.length) { + const partnerIds = organization.organizationReport.partners.map( + (partner) => partner.id, + ); + await queryRunner.manager.delete(Partner, partnerIds); + } - await queryRunner.manager.delete( - OrganizationReport, - organization.organizationReportId, - ); + if (organization.organizationReportId) { + await queryRunner.manager.delete( + OrganizationReport, + organization.organizationReportId, + ); + } // 3. delete financial - const organizationFinancialIds = organization.organizationFinancial.map( - (financial) => financial.id, - ); - await queryRunner.manager.delete( - OrganizationFinancial, - organizationFinancialIds, - ); + if (organization.organizationFinancial.length) { + const organizationFinancialIds = organization.organizationFinancial.map( + (financial) => financial.id, + ); + await queryRunner.manager.delete( + OrganizationFinancial, + organizationFinancialIds, + ); + } // 4. delete delete legal - await queryRunner.manager.delete( - Contact, - organization.organizationLegal.legalReprezentativeId, - ); + if (organization.organizationLegal.legalReprezentativeId) { + await queryRunner.manager.delete( + Contact, + organization.organizationLegal.legalReprezentativeId, + ); + } - const directorsIds = organization.organizationLegal.directors.map( - (director) => director.id, - ); - await queryRunner.manager.delete(Contact, directorsIds); + if (organization.organizationLegal.directors.length) { + const directorsIds = organization.organizationLegal.directors.map( + (director) => director.id, + ); + await queryRunner.manager.delete(Contact, directorsIds); + } await queryRunner.manager.delete( OrganizationLegal, @@ -1372,9 +1385,12 @@ export class OrganizationService { } catch (error) { // since we have errors lets rollback the changes we made await queryRunner.rollbackTransaction(); + console.log(error); + + Sentry.captureException(error); this.logger.error({ - error: { error }, + error: JSON.stringify(error), ...ORGANIZATION_ERRORS.DELETE.ONG, }); const err = error?.response; @@ -1531,7 +1547,7 @@ export class OrganizationService { let financialFromAnaf = null; try { financialFromAnaf = - await this.organizationFinancialService.getFinancialInformation( + await this.organizationFinancialService.getFinancialInformationFromANAF( organization.organizationGeneral.cui, year, ); @@ -1563,8 +1579,6 @@ export class OrganizationService { partners: [...organization.organizationReport.partners, { year }], investors: [...organization.organizationReport.investors, { year }], }, - syncedOn: new Date(), - completionStatus: CompletionStatus.NOT_COMPLETED, }); return orgUpdated; @@ -1582,6 +1596,14 @@ export class OrganizationService { return this.organizationRepository.count(findConditions); } + public async countOrganizationsWithUpdatedReports(): Promise { + return this.organizationViewRepository.count({ + where: { + completionStatus: CompletionStatus.COMPLETED, + }, + }); + } + public async countOrganizationsWithActivePracticePrograms(): Promise { return this.organizationRepository.count({ where: { @@ -1604,74 +1626,19 @@ export class OrganizationService { }); } - /** - * Check if the organization has all the financial, report, partner and investor data corectly completed and update the completionStatus - */ - private async updateOrganizationCompletionStatus( + public async getFinancialAndReportsLastUpdatedOn( organizationId: number, - ): Promise { - try { - // 1. get organization with financial, report, partener and investor data - const organization = await this.organizationRepository.get({ - where: { id: organizationId }, - relations: [ - 'organizationFinancial', - 'organizationReport', - 'organizationReport.reports', - 'organizationReport.partners', - 'organizationReport.investors', - ], - }); + ): Promise { + const data = await this.organizationViewRepository.get({ + where: { + id: organizationId, + }, + select: { + updatedOn: true, + }, + }); - // 2. check if we have all financial data completed - const organizationFinancialCompleted = - organization.organizationFinancial.findIndex( - (financial) => financial.status === CompletionStatus.NOT_COMPLETED, - ) < 0; - - // 3. check if report data is completed - const organizationReportsCompleted = - organization.organizationReport.reports.findIndex( - (report) => report.status === CompletionStatus.NOT_COMPLETED, - ) < 0; - - // 4. check if investor data is completed - const organizationInvestorsCompleted = - organization.organizationReport.investors.findIndex( - (investor) => investor.status === CompletionStatus.NOT_COMPLETED, - ) < 0; - - // 5. check if partner data si completed - const organizationPartnersCompleted = - organization.organizationReport.partners.findIndex( - (partner) => partner.status === CompletionStatus.NOT_COMPLETED, - ) < 0; - - // 6. If all the statuses above are true than the organization is up to date - const organizationInSync = - organizationFinancialCompleted && - organizationReportsCompleted && - organizationInvestorsCompleted && - organizationPartnersCompleted; - - // 7. Update the organization with latest status - await this.organizationRepository.update( - { id: organizationId }, - { - completionStatus: organizationInSync - ? CompletionStatus.COMPLETED - : CompletionStatus.NOT_COMPLETED, - syncedOn: new Date(), - }, - ); - } catch (error) { - // TO: validate if we throw error - this.logger.error({ - ...ORGANIZATION_ERRORS.COMPLETION_STATUS, - error, - organizationId, - }); - } + return data?.updatedOn; } private flattenOrganization( diff --git a/backend/src/modules/statistics/interfaces/organization-statistics.interface.ts b/backend/src/modules/statistics/interfaces/organization-statistics.interface.ts index c14d6d208..0a3eeb804 100644 --- a/backend/src/modules/statistics/interfaces/organization-statistics.interface.ts +++ b/backend/src/modules/statistics/interfaces/organization-statistics.interface.ts @@ -8,12 +8,13 @@ export interface IAllOrganizationsStatistics { } export interface IOrganizationStatistics { - isOrganizationUpdated: boolean; organizationCreatedOn: Date; - organizationSyncedOn: Date; + organizationSyncedOn: Date | null; numberOfInstalledApps: number; numberOfUsers: number; hubStatistics: IGeneralONGHubStatistics; + numberOfErroredFinancialReports: number; + numberOfErroredReportsInvestorsPartners: number; } export interface IOrganizationRequestStatistics { diff --git a/backend/src/modules/statistics/services/statistics.service.ts b/backend/src/modules/statistics/services/statistics.service.ts index 02167ae38..e83e1e6b8 100644 --- a/backend/src/modules/statistics/services/statistics.service.ts +++ b/backend/src/modules/statistics/services/statistics.service.ts @@ -15,7 +15,10 @@ import { IOrganizationStatistics, IOrganizationStatusStatistics, } from 'src/modules/statistics/interfaces/organization-statistics.interface'; -import { OrganizationService } from 'src/modules/organization/services'; +import { + OrganizationFinancialService, + OrganizationService, +} from 'src/modules/organization/services'; import { OrganizationRequestService } from 'src/modules/organization/services/organization-request.service'; import { UserService } from 'src/modules/user/services/user.service'; import { OrganizatioStatusnStatisticsViewRepository } from '../repositories/organization-status-statistics-view.repository'; @@ -31,6 +34,7 @@ import { ApplicationService } from 'src/modules/application/services/application import { UserStatus } from 'src/modules/user/enums/user-status.enum'; import { In } from 'typeorm'; import { ApplicationStatus } from 'src/modules/application/enums/application-status.enum'; +import { OrganizationReportService } from 'src/modules/organization/services/organization-report.service'; @Injectable() export class StatisticsService { @@ -43,6 +47,8 @@ export class StatisticsService { private readonly applicationService: ApplicationService, private readonly practiceProgramService: PracticeProgramService, private readonly civicCenterService: CivicCenterServiceService, + private readonly organizationFinancialService: OrganizationFinancialService, + private readonly organizationReportService: OrganizationReportService, ) {} public async getOrganizationRequestStatistics( @@ -225,12 +231,7 @@ export class StatisticsService { }); const numberOfUpdatedOrganizations = - await this.organizationsService.countOrganizations({ - where: { - status: OrganizationStatus.ACTIVE, - completionStatus: CompletionStatus.COMPLETED, - }, - }); + await this.organizationsService.countOrganizationsWithUpdatedReports(); const numberOfUsers = await this.userService.countUsers({ where: { @@ -295,16 +296,33 @@ export class StatisticsService { }, }); + const numberOfErroredFinancialReports = + await this.organizationFinancialService.countNotCompletedReports( + organizationId, + ); + + const numberOfErroredReportsInvestorsPartners = + await this.organizationReportService.countNotCompletedReports( + organizationId, + ); + + const financialReportsAndReportsInvestorPartenerslastUpdatedOn = + await this.organizationsService.getFinancialAndReportsLastUpdatedOn( + organizationId, + ); + return { - isOrganizationUpdated: - organization.completionStatus === CompletionStatus.COMPLETED, organizationCreatedOn: organization.createdOn, - organizationSyncedOn: organization.syncedOn, + organizationSyncedOn: + financialReportsAndReportsInvestorPartenerslastUpdatedOn, numberOfInstalledApps: activeApps, numberOfUsers, hubStatistics: await this.getGeneralONGHubStatistics(), + numberOfErroredFinancialReports, + numberOfErroredReportsInvestorsPartners, }; } catch (error) { + console.log(error); this.logger.error({ error: { error }, ...STATISTICS_ERRORS.ORGANIZATION_STATISTICS, diff --git a/backend/src/modules/user/dto/create-user.dto.ts b/backend/src/modules/user/dto/create-user.dto.ts index 7e9ff9b42..5bad6b254 100644 --- a/backend/src/modules/user/dto/create-user.dto.ts +++ b/backend/src/modules/user/dto/create-user.dto.ts @@ -27,8 +27,7 @@ export class CreateUserDto { @MaxLength(50) email: string; - @IsString() - @IsNotEmpty() + @IsOptional() @IsPhoneValid() phone: string; diff --git a/backend/src/modules/user/entities/user-history.entity.ts b/backend/src/modules/user/entities/user-history.entity.ts index ae8b3b548..ec4da2b0b 100644 --- a/backend/src/modules/user/entities/user-history.entity.ts +++ b/backend/src/modules/user/entities/user-history.entity.ts @@ -35,7 +35,7 @@ export class UserHistory extends BaseEntity implements HistoryEntityInterface { @Column({ type: 'varchar', name: 'email' }) email: string; - @Column({ type: 'varchar', name: 'phone' }) + @Column({ type: 'varchar', name: 'phone', nullable: true }) phone: string; @Column({ type: 'enum', enum: Role, name: 'role', default: Role.EMPLOYEE }) diff --git a/backend/src/modules/user/entities/user.entity.ts b/backend/src/modules/user/entities/user.entity.ts index 2fd584e9e..83cf171f0 100644 --- a/backend/src/modules/user/entities/user.entity.ts +++ b/backend/src/modules/user/entities/user.entity.ts @@ -15,7 +15,7 @@ export class User extends BaseEntity { @Column({ type: 'varchar', name: 'email', unique: true }) email: string; - @Column({ type: 'varchar', name: 'phone' }) + @Column({ type: 'varchar', name: 'phone', nullable: true }) phone: string; @Column({ type: 'enum', enum: Role, name: 'role', default: Role.EMPLOYEE }) diff --git a/backend/src/modules/user/services/cognito.service.ts b/backend/src/modules/user/services/cognito.service.ts index e5429659c..34b644add 100644 --- a/backend/src/modules/user/services/cognito.service.ts +++ b/backend/src/modules/user/services/cognito.service.ts @@ -29,10 +29,7 @@ export class CognitoUserService { Username: email, DesiredDeliveryMediums: [DeliveryMediumType.EMAIL], UserAttributes: [ - { - Name: 'phone_number', - Value: phone, - }, + ...(phone ? [{ Name: 'phone_number', Value: phone }] : []), { Name: 'name', Value: name, @@ -48,9 +45,8 @@ export class CognitoUserService { ], }); - const data: AdminCreateUserCommandOutput = await this.cognitoProvider.send( - createUserCommand, - ); + const data: AdminCreateUserCommandOutput = + await this.cognitoProvider.send(createUserCommand); return data.User.Username; } diff --git a/backend/src/modules/user/services/user.service.ts b/backend/src/modules/user/services/user.service.ts index 1e7a6b941..4b603032b 100644 --- a/backend/src/modules/user/services/user.service.ts +++ b/backend/src/modules/user/services/user.service.ts @@ -372,7 +372,10 @@ export class UserService { ) { throw new BadRequestException(USER_ERRORS.ALREADY_EXISTS_EMAIL); } else if ( - await this.userRepository.get({ where: { phone: createUserDto.phone } }) + createUserDto.phone && + (await this.userRepository.get({ + where: { phone: createUserDto.phone }, + })) ) { throw new BadRequestException(USER_ERRORS.ALREADY_EXISTS_PHONE); } diff --git a/backend/src/shared/controllers/nomenclatures.controller.ts b/backend/src/shared/controllers/nomenclatures.controller.ts index 26060590d..9135f7927 100644 --- a/backend/src/shared/controllers/nomenclatures.controller.ts +++ b/backend/src/shared/controllers/nomenclatures.controller.ts @@ -13,69 +13,86 @@ import { NomenclaturesService } from '../services'; import { CacheInterceptor } from '@nestjs/cache-manager'; @Public() -@UseInterceptors(ClassSerializerInterceptor, CacheInterceptor) +@UseInterceptors(ClassSerializerInterceptor) @Controller('nomenclatures') export class NomenclaturesController { constructor(private nomenclaturesService: NomenclaturesService) {} @Get('cities') + @UseInterceptors(CacheInterceptor) getCities(@Query() citySearchDto: CitySearchDto) { return this.nomenclaturesService.getCitiesSearch(citySearchDto); } @Get('counties') + @UseInterceptors(CacheInterceptor) getCounties() { return this.nomenclaturesService.getCounties({}); } @Get('domains') + @UseInterceptors(CacheInterceptor) getDomains() { return this.nomenclaturesService.getDomains({}); } @Get('regions') + @UseInterceptors(CacheInterceptor) getRegions() { return this.nomenclaturesService.getRegions({}); } @Get('federations') + @UseInterceptors(CacheInterceptor) getFederations() { return this.nomenclaturesService.getFederations({}); } @Get('coalitions') + @UseInterceptors(CacheInterceptor) getCoalitions() { return this.nomenclaturesService.getCoalitions({}); } @Get('faculties') + @UseInterceptors(CacheInterceptor) getFaculties(@Query() { search }: FacultySearchDto) { const options = search ? { where: { name: ILike(`%${search}%`) } } : {}; return this.nomenclaturesService.getFaculties(options); } @Get('skills') + @UseInterceptors(CacheInterceptor) getSkills() { return this.nomenclaturesService.getSkills({}); } @Get('practice-domains') + @UseInterceptors(CacheInterceptor) getPracticeDomains() { return this.nomenclaturesService.getPracticeDomains({}); } @Get('service-domains') + @UseInterceptors(CacheInterceptor) getServiceDomains() { return this.nomenclaturesService.getServiceDomains({}); } @Get('beneficiaries') + @UseInterceptors(CacheInterceptor) getBeneficiaries() { return this.nomenclaturesService.getBeneficiaries({}); } @Get('issuers') + @UseInterceptors(CacheInterceptor) getIssuers() { return this.nomenclaturesService.getIssuers({}); } + + @Get('application-labels') + getApplicationLabels() { + return this.nomenclaturesService.getApplicationLabels({}); + } } diff --git a/backend/src/shared/entities/application-labels.entity.ts b/backend/src/shared/entities/application-labels.entity.ts new file mode 100644 index 000000000..74b687b83 --- /dev/null +++ b/backend/src/shared/entities/application-labels.entity.ts @@ -0,0 +1,8 @@ +import { BaseEntity } from 'src/common/base/base-entity.class'; +import { Column, Entity } from 'typeorm'; + +@Entity({ name: '_application-label' }) +export class ApplicationLabel extends BaseEntity { + @Column({ type: 'text', name: 'name' }) + name: string; +} diff --git a/backend/src/shared/services/anaf.service.ts b/backend/src/shared/services/anaf.service.ts index 3ced11d67..7f40df01c 100644 --- a/backend/src/shared/services/anaf.service.ts +++ b/backend/src/shared/services/anaf.service.ts @@ -3,6 +3,7 @@ import { HttpService } from '@nestjs/axios'; import { ANAF_URL } from 'src/common/constants/anaf.constants'; import { firstValueFrom, lastValueFrom, of } from 'rxjs'; import { map, catchError } from 'rxjs/operators'; +import * as Sentry from '@sentry/node'; export interface FinancialInformation { numberOfEmployees: number; @@ -28,6 +29,12 @@ export class AnafService { map((res) => res.data.i), catchError((err) => { this.logger.error('ANAF error', err); + Sentry.captureException(err, { + extra: { + companyCUI, + year, + }, + }); return of(null); }), ) diff --git a/backend/src/shared/services/nomenclatures.service.ts b/backend/src/shared/services/nomenclatures.service.ts index af2d37465..790afe0c9 100644 --- a/backend/src/shared/services/nomenclatures.service.ts +++ b/backend/src/shared/services/nomenclatures.service.ts @@ -17,6 +17,7 @@ import { Federation } from '../entities/federation.entity'; import { PracticeDomain } from 'src/modules/practice-program/entities/practice_domain.entity'; import { ServiceDomain } from 'src/modules/civic-center-service/entities/service-domain.entity'; import { Beneficiary } from 'src/modules/civic-center-service/entities/beneficiary.entity'; +import { ApplicationLabel } from '../entities/application-labels.entity'; @Injectable() export class NomenclaturesService { @@ -45,6 +46,8 @@ export class NomenclaturesService { private readonly beneficiaryRepository: Repository, @InjectRepository(Issuer) private readonly issuersRepository: Repository, + @InjectRepository(ApplicationLabel) + private readonly applicationLabelRepository: Repository, ) {} public getCity(conditions: FindOneOptions) { @@ -169,4 +172,14 @@ export class NomenclaturesService { public getIssuers(conditions: FindManyOptions) { return this.issuersRepository.find(conditions); } + + public getApplicationLabels(conditions: FindManyOptions) { + return this.applicationLabelRepository.find(conditions); + } + + public createApplicationLabel( + applicationLabel: Partial, + ): Promise { + return this.applicationLabelRepository.save(applicationLabel); + } } diff --git a/backend/src/shared/shared.module.ts b/backend/src/shared/shared.module.ts index dc13eab07..31086f825 100644 --- a/backend/src/shared/shared.module.ts +++ b/backend/src/shared/shared.module.ts @@ -20,6 +20,7 @@ import { FileManagerService } from './services/file-manager.service'; import { PracticeDomain } from 'src/modules/practice-program/entities/practice_domain.entity'; import { ServiceDomain } from 'src/modules/civic-center-service/entities/service-domain.entity'; import { Beneficiary } from 'src/modules/civic-center-service/entities/beneficiary.entity'; +import { ApplicationLabel } from './entities/application-labels.entity'; @Global() @Module({ @@ -37,6 +38,7 @@ import { Beneficiary } from 'src/modules/civic-center-service/entities/beneficia ServiceDomain, Beneficiary, Issuer, + ApplicationLabel, ]), HttpModule, ], diff --git a/frontend/package.json b/frontend/package.json index 72040eed0..8680ae21b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "frontend", - "version": "0.1.0", + "version": "1.1.0", "private": true, "dependencies": { "@headlessui/react": "^2.1.1", diff --git a/frontend/src/assets/locales/en/translation.json b/frontend/src/assets/locales/en/translation.json index 921f3e26a..92ae7a712 100644 --- a/frontend/src/assets/locales/en/translation.json +++ b/frontend/src/assets/locales/en/translation.json @@ -25,7 +25,9 @@ "loading": "Loading …", "completion_status": { "completed": "Completed", - "not_completed": "Not completed" + "not_completed": "Not completed", + "invalid": "Invalid", + "pending": "Pending" }, "any": "All", "restricted": "Restricted", @@ -497,19 +499,16 @@ "no_document": "No document uploaded", "file_name": "Statute_Organisation", "statute_upload": "Upload file", - "non_political_affiliation": "Declaration of non-political affiliation", "non_political_affiliation_information": "Upload the self-declaration by the legal representative of the organization stating that the president and members of the Board of Directors are not part of the leadership of a political party and/or do not hold any public office. This declaration will not be displayed publicly. The document is only used to validate the organization's eligibility to access certain solutions in NGO Hub (such as Vot ONG and RO Help). Accepted file formats: pdf, maximum 25 MB", "non_political_affiliation_no_document": "No document uploaded", "non_political_affiliation_file_name": "Declaration of non-political affiliation", "non_political_affiliation_upload": "Upload file", - "balance_sheet": "Balance Sheet", "balance_sheet_information": "Upload the latest balance sheet submitted to the Ministry of Finance. This will not be displayed publicly. The document is only used to validate the organization's eligibility to access certain solutions in NGO Hub (such as Vot ONG and RO Help). Accepted file formats: pdf, maximum 50 MB", "balance_sheet_no_document": "No document uploaded", "balance_sheet_file_name": "Balance Sheet", "balance_sheet_upload": "Upload file", - "modal": { "add": "Add", "edit_director": "Editing of the Board member", @@ -531,12 +530,10 @@ "title": "Are you sure you want to delete the Organisation's Constitution?", "description": "TODO#1: Lorem ipsum." }, - "delete_non_politicial_affiliation_modal": { "title": "Are you sure you want to delete the Declaration of non-political affiliation?", "description": "" }, - "delete_balance_sheet_modal": { "title": "Are you sure you want to delete the Balance Sheet?", "description": "" @@ -673,6 +670,13 @@ "info": "The total income resulting from financial operations, for example the interest you get on deposits or exchange rate differences." } }, + "tooltip": { + "completed": "All information has been completed and matches that of ANAF", + "not_completed": "Information has not been completed yet. Please enter your financial information to avoid account suspension", + "invalid": "The information entered does not match that of ANAF. Please review and correct any errors", + "pending": "The information has been partially or fully completed and is being checked against the information from ANAF", + "error": "Error getting status" + }, "expense": { "net": { "label": "Net salaries (human resources)", @@ -1247,9 +1251,9 @@ "view_active_apps": "View active apps", "active_users": "Users in the organisation", "handle_users": "Manage users", - "view_data": "View data", - "updated_data": "Organisation data is up-to-date", - "outdated_data": "The organisation's data is not up-to-date", + "view_data": "View information", + "financial_reports_are_uptodate": "The Financial Reports are up-to-date", + "financial_reports_are_outdated": "The Financial Reports needs to be updated", "error": "Data retrieval error", "active_organizations": "Active", "inactive_organizations": "Inactive", @@ -1264,6 +1268,14 @@ "yearly": "Since forever", "monthly": "Lunar", "daily": "Daily" + }, + "financial_reports": { + "updated": "Financial information is updated", + "not_updated": "Financial information is not updated" + }, + "organization_reports": { + "updated": "The information in the “NGO in numbers“ section is updated", + "not_updated": "The information in the “NGO in numbers“ section is not updated" } }, "activity_title": "Organisation active in the NGO Hub in", @@ -1606,4 +1618,4 @@ "date_added": "Date of feedback" } } -} +} \ No newline at end of file diff --git a/frontend/src/assets/locales/ro/translation.json b/frontend/src/assets/locales/ro/translation.json index 471243ea8..44610a412 100644 --- a/frontend/src/assets/locales/ro/translation.json +++ b/frontend/src/assets/locales/ro/translation.json @@ -25,8 +25,11 @@ "loading": "Se încarcă...", "completion_status": { "completed": "Completat", - "not_completed": "Necompletat" + "not_completed": "Necompletat", + "invalid": "Invalid", + "pending": "In verificare" }, + "add_option": "Adaugă opțiunea", "any": "Toate", "restricted": "Restricționat", "active": "Activ", @@ -47,7 +50,7 @@ "reset": "Resetează filtre", "show": "Filtre" }, - "unavailable": "În curând", + "unavailable": "Temporar indisponibilă", "to_be_removed": "De eliminat", "decimal": "Valori cu decimale nu sunt permise.", "unknown_error": "A apărut o eroare necunoscută", @@ -536,19 +539,16 @@ "no_document": "Nu există niciun document adăugat", "file_name": "Statut_Organizație", "statute_upload": "Încarcă fișier", - "non_political_affiliation": "Declarație de neapartenență politică", "non_political_affiliation_information": "Încarcă declarația pe proprie răspundere prin care reprezentantul legal al organizației declară că președintele și membrii Consiliul Director nu fac parte din conducerea unui partid politic și/sau nu dețin nicio funcție publică. Această declarație nu va fi afișată public. Documentul este folosit doar pentru a valida eligibilitatea organizației de a accesa anumite soluții din NGO Hub (ca de exemplu Vot ONG și RO Help). Fișiere acceptate: pdf, maxim 25 MB", "non_political_affiliation_no_document": "Nu există niciun document adăugat", "non_political_affiliation_file_name": "Declarație de neapartenență politică", "non_political_affiliation_upload": "Încarcă fișier", - "balance_sheet": "Bilanț contabil", "balance_sheet_information": "Încarcă ultimul bilanțul contabil depus la Ministerul de Finanțe. Acesta nu va fi afișat public. Documentul este folosit doar pentru a valida eligibilitatea organizației de a accesa anumite soluții din NGO Hub (ca de exemplu Vot ONG și RO Help). Fișiere acceptate: pdf, maxim 25 MB", "balance_sheet_no_document": "Nu există niciun document adăugat", "balance_sheet_file_name": "Bilanț contabil", "balance_sheet_upload": "Încarcă fișier", - "modal": { "add": "Adaugă", "edit_director": "Editare membru Consiliu Director", @@ -570,12 +570,10 @@ "title": "Ești sigur ca dorești ștergerea Statutului Organizației?", "description": "În cazul ștergerii statutului, un nou statut trebuie încărcat în maxim 30 de zile pentru a menține activ contul organizației în NGO Hub." }, - "delete_non_politicial_affiliation_modal": { "title": "Ești sigur ca dorești ștergerea Declarației de neapartenență politică?", "description": "" }, - "delete_balance_sheet_modal": { "title": "Ești sigur ca dorești ștergerea Bilanțului contabil?", "description": "" @@ -720,6 +718,13 @@ "info": "Totalul veniturilor care au rezultat in urma unor operațiuni financiare, de exemplu dobânda pe care o obții la depozite sau diferențele de schimb valutar." } }, + "tooltip": { + "completed": "Toate informațiile au fost completate și se potrivesc cu cele de la ANAF", + "not_completed": "Informațiile nu au fost completate încă. Te rugăm să introduci informațiile financiare pentru a evita suspendarea contului", + "invalid": "Informațiile introduse nu se potrivesc cu cele de la ANAF. Te rugăm să le revizuiești și să corectezi eventualele erori", + "pending": "Informațiile au fost completate parțial sau integral și sunt în curs de verificare cu informațiile de la ANAF", + "error": "Eroare la obținerea statusului" + }, "expense": { "net": { "label": "Salarii nete (resurse umane)", @@ -947,7 +952,7 @@ "max": "Numărul de telefon poate avea maxim 15 caractere", "min": "Numărul de telefon poate avea minim 10 caractere", "invalid": "Numărul de telefon are un format invalid", - "label": "Telefon*" + "label": "Telefon" } } }, @@ -1060,6 +1065,16 @@ "label": "Descriere scurtă", "helper": "Descrie aplicația în maxim 200 de caractere" }, + "status": { + "label": "Status aplicație", + "required": "Statusul aplicației este obligatoriu", + "options": { + "active": "Activă (aplicația poate fi adăugată de organizații în profilul lor)", + "disabled": "Inactivă (aplicația nu poate fi adăugată de organizații în profilul lor)" + }, + "section_title": "Status aplicație", + "section_subtitle": "Statusul aplicației influențează disponibilitatea acesteia pentru organizații" + }, "description": { "required": "Descrierea extinsă este obligatorie", "max": "Descrierea extinsă poate avea maxim 7000 de caractere", @@ -1095,6 +1110,12 @@ "min": "Pasul trebuie să aibă minim 2 caractere", "max": "Pasul trebuie să aibă maxim 100 de caractere", "label": "Pas" + }, + "application_label": { + "label": "Eticheta pentru aplicație (eticheta apare în meniul Toate aplicațiile)", + "helper": "Adaugă o etichetă deja existentă sau creează una nouă", + "maxLength": "Eticheta poate avea maxim 30 de caractere", + "minLength": "Eticheta poate avea minimum 2 de caractere" } }, "request_modal": { @@ -1290,14 +1311,13 @@ "number_of_pending_requests": "Cereri înscriere organizații în așteptare", "number_of_updated_organizations": "Număr de organizații cu datele actualizate", "number_of_users": "Număr total de utilizatori individuali", - "next_update": "Următoarea actualizare de date va fi necesară la 30 Iunie 2024.", + "next_update": "Actualizează datele până pe 30.06.{{value}} pentru a evita suspendarea contului NGO Hub", + "next_year_update": "Următoarea actualizare va fi necesară începând cu 01.01.{{value}}", "active_apps": "Aplicațiile active", "view_active_apps": "Vezi aplicațiile active", "active_users": "Utilizatori în organizație", "handle_users": "Gestionează utilizatori", - "view_data": "Vizualizează datele", - "updated_data": "Datele organizației sunt actualizate", - "outdated_data": "Datele organizației nu sunt actualizate", + "view_data": "Vizualizează informațiile", "error": "Eroare la preluarea datelor", "active_organizations": "Active", "inactive_organizations": "Inactive", @@ -1312,6 +1332,16 @@ "yearly": "Dintotdeauna", "monthly": "Lunar", "daily": "Zilnic" + }, + "financial_reports": { + "updated": "Informațiile financiare sunt actualizate", + "not_updated": "Informațiile financiare nu sunt actualizate.", + "please_update": "Actualizeaza-ți datele pentru a putea continua sa folosesti NGO Hub." + }, + "organization_reports": { + "updated": "Informațiile din secțiunea “ONG-ul în numere” sunt actualizate", + "not_updated": "Informațiile din secțiunea “ONG-ul în numere” nu sunt actualizate.", + "please_update": "Adauga noile raportari pentru a putea continua sa folosesti NHO Hub." } }, "activity_title": "Organizație activă în NGO Hub din", diff --git a/frontend/src/common/constants/version.constants.ts b/frontend/src/common/constants/version.constants.ts index 41b62cd67..8b6a6a4e5 100644 --- a/frontend/src/common/constants/version.constants.ts +++ b/frontend/src/common/constants/version.constants.ts @@ -1 +1 @@ -export const FRONTEND_VERSION = 'v0.1.2'; +export const FRONTEND_VERSION = 'v1.1.0'; diff --git a/frontend/src/common/helpers/format.helper.ts b/frontend/src/common/helpers/format.helper.ts index 818b93e60..d9f0e3027 100644 --- a/frontend/src/common/helpers/format.helper.ts +++ b/frontend/src/common/helpers/format.helper.ts @@ -70,6 +70,11 @@ export const mapSelectToSkill = ( ): { id?: number; name: string } => item?.__isNew__ ? { name: item.label } : { id: item.value, name: item.label }; +export const mapSelectToApplicationLabel = ( + item: ISelectData & { __isNew__?: boolean }, +): { id?: number; name: string } => + item?.__isNew__ ? { name: item.label } : { id: item.value, name: item.label }; + // Cities / Counties export const mapCitiesToSelect = (item: any): ISelectData => ({ value: item?.id, diff --git a/frontend/src/common/interfaces/application-label.interface.ts b/frontend/src/common/interfaces/application-label.interface.ts new file mode 100644 index 000000000..d3b372483 --- /dev/null +++ b/frontend/src/common/interfaces/application-label.interface.ts @@ -0,0 +1,3 @@ +import { BaseNomenclatureEntity } from './base-nomenclature-entity.interface'; + +export interface ApplicationLabel extends BaseNomenclatureEntity {} diff --git a/frontend/src/components/Header/Header.tsx b/frontend/src/components/Header/Header.tsx index 7f1f24538..9df68634a 100644 --- a/frontend/src/components/Header/Header.tsx +++ b/frontend/src/components/Header/Header.tsx @@ -3,12 +3,20 @@ import './Header.css'; import logo from './../../assets/images/logo.svg'; import { useAuthContext } from '../../contexts/AuthContext'; import { Menu, Transition } from '@headlessui/react'; -import { CogIcon, ArrowLeftEndOnRectangleIcon, Bars4Icon, ChevronDownIcon } from '@heroicons/react/24/outline'; +import { + CogIcon, + ArrowLeftEndOnRectangleIcon, + Bars4Icon, + ChevronDownIcon, +} from '@heroicons/react/24/outline'; import { classNames } from '../../common/helpers/tailwind.helper'; import { useNavigate } from 'react-router-dom'; import { useUser } from '../../store/selectors'; import { useTranslation } from 'react-i18next'; import { signInWithRedirect } from 'aws-amplify/auth'; +import WarningBanner from '../warning-banner/WarningBanner'; +import { UserRole } from '../../pages/users/enums/UserRole.enum'; +import { useOrganizationReportsStatus } from '../../services/organization/Organization.queries'; interface HeaderProps { openSlidingMenu?: any; @@ -20,114 +28,132 @@ const Header = ({ openSlidingMenu, hideLogInButton }: HeaderProps) => { const navigate = useNavigate(); const { profile } = useUser(); - const { t } = useTranslation('header'); + const { data: reportsStatus } = useOrganizationReportsStatus(profile?.role === UserRole.ADMIN); + + const { t } = useTranslation(['header', 'dashboard']); return ( -
- + {reportsStatus && reportsStatus.numberOfErroredFinancialReports > 0 && ( + + )} + {reportsStatus && reportsStatus.numberOfErroredReportsInvestorsPartners > 0 && ( + + )} +
+ ); }; diff --git a/frontend/src/components/Select/Select.tsx b/frontend/src/components/Select/Select.tsx index 080960709..6024b2db7 100644 --- a/frontend/src/components/Select/Select.tsx +++ b/frontend/src/components/Select/Select.tsx @@ -77,31 +77,33 @@ const Select = (props: { value={item} itemID={`${props.config.id}__select-${item.name}`} > - {({ selected, active }) => ( - <> - - {props.config.displayedAttribute - ? item[props.config.displayedAttribute] - : item} - - - {selected ? ( + { + ({ selected, active }) => ( + <> - - ) : null} - - )} + + {(props.selected?.id ? props?.selected?.id === item.id : selected) ? ( + + + ) : null} + + ) + } ))} diff --git a/frontend/src/components/content-wrapper/ContentWrapper.tsx b/frontend/src/components/content-wrapper/ContentWrapper.tsx index 1ad0b486f..6eb629276 100644 --- a/frontend/src/components/content-wrapper/ContentWrapper.tsx +++ b/frontend/src/components/content-wrapper/ContentWrapper.tsx @@ -53,7 +53,7 @@ const ContentWrapper = ({ {fields.length > 0 && ( -
+
diff --git a/frontend/src/pages/my-apps/components/ApplicationCard.tsx b/frontend/src/pages/my-apps/components/ApplicationCard.tsx index b01b174bd..80ccfc651 100644 --- a/frontend/src/pages/my-apps/components/ApplicationCard.tsx +++ b/frontend/src/pages/my-apps/components/ApplicationCard.tsx @@ -31,8 +31,7 @@ const ApplicationCard = ({ application }: { application: ApplicationWithOngStatu return (
- {(application.ongStatus === OngApplicationStatus.RESTRICTED || - application.status === ApplicationStatus.DISABLED) && ( + {(application.ongStatus === OngApplicationStatus.RESTRICTED) && (

{t('unavailable', { ns: 'common' })}

@@ -45,6 +44,12 @@ const ApplicationCard = ({ application }: { application: ApplicationWithOngStatu

)} + {application.applicationLabel && application.ongStatus !== OngApplicationStatus.PENDING_REMOVAL && application.ongStatus !== OngApplicationStatus.RESTRICTED && ( +
+

+ {application.applicationLabel} +

+
)} Application logo { const navigate = useNavigate(); @@ -110,7 +115,9 @@ const Organization = () => { /> - + ); }; diff --git a/frontend/src/pages/organization/components/OrganizationData/OrganizationData.tsx b/frontend/src/pages/organization/components/OrganizationData/OrganizationData.tsx index c0264dd76..55595b04f 100644 --- a/frontend/src/pages/organization/components/OrganizationData/OrganizationData.tsx +++ b/frontend/src/pages/organization/components/OrganizationData/OrganizationData.tsx @@ -1,4 +1,9 @@ -import { ArrowDownTrayIcon, PencilIcon, TrashIcon, ArrowUpTrayIcon } from '@heroicons/react/24/outline'; +import { + ArrowDownTrayIcon, + PencilIcon, + TrashIcon, + ArrowUpTrayIcon, +} from '@heroicons/react/24/outline'; import React, { useContext, useEffect, useState } from 'react'; import { TableColumn } from 'react-data-table-component'; import { useTranslation } from 'react-i18next'; @@ -37,9 +42,11 @@ import ReportSummaryModal from './components/ReportSummaryModal'; import { InvestorsTableHeaders } from './table-headers/InvestorTable.headers'; import { PartnerTableHeaders } from './table-headers/PartnerTable.headers'; import { ReportsTableHeaders } from './table-headers/ReportsTable.headers'; +import { useQueryClient } from 'react-query'; const OrganizationData = () => { const { id } = useParams(); + const queryClient = useQueryClient(); const [isActivitySummaryModalOpen, setIsActivitySummaryModalOpen] = useState(false); const [selectedReport, setSelectedReport] = useState(null); @@ -317,6 +324,9 @@ const OrganizationData = () => { }, }, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['organization-reports-status'] }); + }, onError: () => { useErrorToast(t('load_error')); }, @@ -325,13 +335,20 @@ const OrganizationData = () => { }; const onDeleteReport = (row: Report) => { - updateReport({ - organization: { - report: { - reportId: row.id, + updateReport( + { + organization: { + report: { + reportId: row.id, + }, }, }, - }); + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['organization-reports-status'] }); + }, + }, + ); }; const onDownloadFile = async (row: Partner | Investor) => { diff --git a/frontend/src/pages/organization/components/OrganizationFinancial/OrganizationFinancial.tsx b/frontend/src/pages/organization/components/OrganizationFinancial/OrganizationFinancial.tsx index 491803345..de87faa24 100644 --- a/frontend/src/pages/organization/components/OrganizationFinancial/OrganizationFinancial.tsx +++ b/frontend/src/pages/organization/components/OrganizationFinancial/OrganizationFinancial.tsx @@ -21,8 +21,11 @@ import { OrganizationFinancialTableHeaders } from './OrganizationFinancialTableH import { OrganizationStatus } from '../../enums/OrganizationStatus.enum'; import LoadingContent from '../../../../components/data-table/LoadingContent'; import { useRetryAnafFinancialMutation } from '../../../../services/organization/Organization.queries'; +import { useQueryClient } from 'react-query'; const OrganizationFinancial = () => { + const queryClient = useQueryClient(); + const [isExpenseReportModalOpen, setIsExpenseReportModalOpen] = useState(false); const [isIncomeReportModalOpen, setIsIncomeReportModalOpen] = useState(false); const [selectedReport, setSelectedReport] = useState(null); @@ -120,6 +123,9 @@ const OrganizationFinancial = () => { }, }, { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['organization-reports-status'] }); + }, onError: () => { useErrorToast(t('save_error', { ns: 'organization' })); }, @@ -138,7 +144,7 @@ const OrganizationFinancial = () => {

{organization?.status === OrganizationStatus.PENDING && - organizationFinancial[0].synched_anaf === false && + organizationFinancial[0]?.synched_anaf === false && role == UserRole.SUPER_ADMIN && (

diff --git a/frontend/src/pages/organization/components/OrganizationFinancial/OrganizationFinancialTableHeaders.tsx b/frontend/src/pages/organization/components/OrganizationFinancial/OrganizationFinancialTableHeaders.tsx index 0e1f26b1c..286e3fdcc 100644 --- a/frontend/src/pages/organization/components/OrganizationFinancial/OrganizationFinancialTableHeaders.tsx +++ b/frontend/src/pages/organization/components/OrganizationFinancial/OrganizationFinancialTableHeaders.tsx @@ -4,7 +4,7 @@ import { IOrganizationFinancial } from '../../interfaces/OrganizationFinancial.i import { formatCurrency, formatDate } from '../../../../common/helpers/format.helper'; import StatusBadge, { BadgeStatus } from '../../../../components/status-badge/StatusBadge'; import { FinancialType } from '../../enums/FinancialType.enum'; -import { CompletionStatus } from '../../enums/CompletionStatus.enum'; +import { OrganizationFinancialReportStatus } from '../../enums/CompletionStatus.enum'; import i18n from '../../../../common/config/i18n'; import DataTableNameHeader from '../../../../components/data-table-name-header/DataTableNameHeader'; @@ -19,6 +19,50 @@ const translations = { sum: i18n.t('financial:modal.sum'), completed: i18n.t('common:completion_status.completed'), not_completed: i18n.t('common:completion_status.not_completed'), + invalid: i18n.t('common:completion_status.invalid'), + pending: i18n.t('common:completion_status.pending'), + tooltip: { + completed: i18n.t('financial:tooltip.completed'), + not_completed: i18n.t('financial:tooltip.not_completed'), + pending: i18n.t('financial:tooltip.pending'), + invalid: i18n.t('financial:tooltip.invalid'), + error: i18n.t('financial:tooltip.error'), + }, +}; + +const mapReportStatusToTextAndBadge = (status: OrganizationFinancialReportStatus) => { + switch (status) { + case OrganizationFinancialReportStatus.COMPLETED: + return { + translation: translations.completed, + badge: BadgeStatus.SUCCESS, + tooltipContent: translations.tooltip.completed, + }; + case OrganizationFinancialReportStatus.INVALID: + return { + translation: translations.invalid, + badge: BadgeStatus.ERROR, + tooltipContent: translations.tooltip.invalid, + }; + case OrganizationFinancialReportStatus.NOT_COMPLETED: + return { + translation: translations.not_completed, + badge: BadgeStatus.WARNING, + tooltipContent: translations.tooltip.not_completed, + }; + case OrganizationFinancialReportStatus.PENDING: + return { + translation: translations.pending, + badge: BadgeStatus.WARNING, + tooltipContent: translations.tooltip.pending, + }; + default: + return { + translation: 'Error', + badge: BadgeStatus.ERROR, + tooltipContent: translations.tooltip.error, + }; + } }; export const OrganizationFinancialTableHeaders: TableColumn[] = [ @@ -43,7 +87,7 @@ export const OrganizationFinancialTableHeaders: TableColumn, - selector: (row: IOrganizationFinancial) => row.numberOfEmployees, + selector: (row: IOrganizationFinancial) => row.numberOfEmployees ?? '-', sortable: true, minWidth: '8rem', grow: 0, @@ -51,7 +95,8 @@ export const OrganizationFinancialTableHeaders: TableColumn, - selector: (row: IOrganizationFinancial) => formatCurrency(row?.total), + selector: (row: IOrganizationFinancial) => + row?.total !== null ? formatCurrency(row?.total) : '-', sortable: true, minWidth: '7rem', grow: 0, @@ -69,14 +114,10 @@ export const OrganizationFinancialTableHeaders: TableColumn, cell: (row: IOrganizationFinancial) => ( ), sortable: true, diff --git a/frontend/src/pages/organization/components/OrganizationFinancial/components/ExpenseReportModal.tsx b/frontend/src/pages/organization/components/OrganizationFinancial/components/ExpenseReportModal.tsx index 54c0b19ad..19733be0e 100644 --- a/frontend/src/pages/organization/components/OrganizationFinancial/components/ExpenseReportModal.tsx +++ b/frontend/src/pages/organization/components/OrganizationFinancial/components/ExpenseReportModal.tsx @@ -17,7 +17,7 @@ import { ANAF_HELPER_LINK } from '../../../../../common/constants/constants'; const ExpenseReportModal = ({ onClose, readonly, - total = 0, + total, year, defaultValue, onSave, @@ -110,9 +110,9 @@ const ExpenseReportModal = ({

- {`${formatCurrency( - total, - )} RON`} + + {total !== null && total !== undefined ? `${formatCurrency(total)} RON` : 'N/A'} + {t('modal.expense')} @@ -177,16 +177,16 @@ const ExpenseReportModal = ({ {t('modal.defalcat')} - {`${totalDefalcat} `} - {totalDefalcat !== total && ( + {`${totalDefalcat} RON`} + {total !== null && total !== undefined && totalDefalcat !== total && ( {total > totalDefalcat ? `(${formatCurrency(total - totalDefalcat)} RON ${t( - 'modal.unallocated', - )})` + 'modal.unallocated', + )})` : `(${formatCurrency(totalDefalcat - total)} RON ${t( - 'modal.excess', - )})`} + 'modal.excess', + )})`} )} diff --git a/frontend/src/pages/organization/components/OrganizationFinancial/components/IncomeReportModal.tsx b/frontend/src/pages/organization/components/OrganizationFinancial/components/IncomeReportModal.tsx index f039064a7..85bab2232 100644 --- a/frontend/src/pages/organization/components/OrganizationFinancial/components/IncomeReportModal.tsx +++ b/frontend/src/pages/organization/components/OrganizationFinancial/components/IncomeReportModal.tsx @@ -18,7 +18,7 @@ const IncomeReportModal = ({ onClose, readonly, year, - total = 0, + total, defaultValue, onSave, }: ReportModalProps) => { @@ -110,9 +110,9 @@ const IncomeReportModal = ({
- {`${formatCurrency( - total, - )} RON`} + + {total !== null && total !== undefined ? `${formatCurrency(total)} RON` : 'N/A'} + {t('modal.income')} @@ -178,15 +178,15 @@ const IncomeReportModal = ({ {`${totalDefalcat} `} - {totalDefalcat !== total && ( + {total !== null && total !== undefined && totalDefalcat !== total && ( {total > totalDefalcat ? `(${formatCurrency(total - totalDefalcat)} RON ${t( - 'modal.unallocated', - )})` + 'modal.unallocated', + )})` : `(${formatCurrency(totalDefalcat - total)} RON ${t( - 'modal.excess', - )})`} + 'modal.excess', + )})`} )} diff --git a/frontend/src/pages/organization/components/OrganizationGeneral/OrganizationGeneral.tsx b/frontend/src/pages/organization/components/OrganizationGeneral/OrganizationGeneral.tsx index bc4ce0c30..62f6909b3 100644 --- a/frontend/src/pages/organization/components/OrganizationGeneral/OrganizationGeneral.tsx +++ b/frontend/src/pages/organization/components/OrganizationGeneral/OrganizationGeneral.tsx @@ -21,13 +21,14 @@ import SectionHeader from '../../../../components/section-header/SectionHeader'; import Select from '../../../../components/Select/Select'; import Textarea from '../../../../components/Textarea/Textarea'; import { useAuthContext } from '../../../../contexts/AuthContext'; -import { useCitiesQuery, useIssuersQuery } from '../../../../services/nomenclature/Nomenclature.queries'; +import { useCitiesQuery } from '../../../../services/nomenclature/Nomenclature.queries'; import { useNomenclature, useSelectedOrganization } from '../../../../store/selectors'; import { UserRole } from '../../../users/enums/UserRole.enum'; import { OrganizationContext } from '../../interfaces/OrganizationContext'; import { OrganizationGeneralConfig } from './OrganizationGeneralConfig'; import FormInput from '../../../../components/form-input/FormInput'; import PhoneNumberInput from '../../../../components/IntlTelInput/PhoneNumberInput'; +import { County } from '../../../../common/interfaces/county.interface'; const OrganizationGeneral = () => { const [readonly, setReadonly] = useState(true); @@ -111,20 +112,23 @@ const OrganizationGeneral = () => { watchCity, ]); - useEffect(() => { - if (county && !readonly) { - setValue('city', null); + const handleSetCounty = (newCounty: County) => { + if (newCounty.id === county.id) { + return; } - }, [cities]); - useEffect(() => { - if (organizationCounty && !readonly) { - const { organizationCity, organizationCounty: orgCounty } = getValues(); - if (organizationCity?.countyId !== orgCounty.id) { - setValue('organizationCity', null); - } + setCounty(newCounty); + setValue('city', null); + } + + const handleSetOrganizationCounty = (newCounty: County) => { + if (newCounty.id === organizationCounty.id) { + return; } - }, [organizationCities]); + + setOrganizationCounty(newCounty); + setValue('organizationCity', null); + } const onChangeFile = (event: React.ChangeEvent) => { if (event.target.files && event.target.files.length > 0) { @@ -449,8 +453,7 @@ const OrganizationGeneral = () => { selected={value} onChange={(e: any) => { onChange(e); - setCounty(e); - setValue('city', null, { shouldValidate: true }); + handleSetCounty(e); }} readonly={readonly} /> @@ -475,6 +478,7 @@ const OrganizationGeneral = () => { selected={value} onChange={onChange} readonly={readonly} + disabled={!county} /> ); }} @@ -700,6 +704,7 @@ const OrganizationGeneral = () => { placeholder={OrganizationGeneralConfig.organizationAddress.config.placeholder} onChange={onChange} id="create-organization-general__org-organization-address" + disabled={watchHasSameAddress} /> ); }} @@ -723,9 +728,10 @@ const OrganizationGeneral = () => { selected={value} onChange={(e: any) => { onChange(e); - setOrganizationCounty(e); + handleSetOrganizationCounty(e) }} readonly={readonly} + disabled={watchHasSameAddress} /> ); }} @@ -748,6 +754,7 @@ const OrganizationGeneral = () => { selected={value} onChange={onChange} readonly={readonly} + disabled={watchHasSameAddress || !organizationCounty} /> ); }} diff --git a/frontend/src/pages/organization/components/Overview/OrganizationOverview.tsx b/frontend/src/pages/organization/components/Overview/OrganizationOverview.tsx index d9727378f..163e6f224 100644 --- a/frontend/src/pages/organization/components/Overview/OrganizationOverview.tsx +++ b/frontend/src/pages/organization/components/Overview/OrganizationOverview.tsx @@ -31,12 +31,21 @@ const OrganizationOverview = () => {
- +
+ + +
+
{
diff --git a/frontend/src/pages/organization/enums/CompletionStatus.enum.ts b/frontend/src/pages/organization/enums/CompletionStatus.enum.ts index 7ea9b6149..f6a594d1f 100644 --- a/frontend/src/pages/organization/enums/CompletionStatus.enum.ts +++ b/frontend/src/pages/organization/enums/CompletionStatus.enum.ts @@ -1,4 +1,21 @@ -export enum CompletionStatus { +export enum CompletionStatus { // TODO: to remove, deprecated in favor of OrganizationFinancialReportStatus or keep it around for general purpose COMPLETED = 'Completed', NOT_COMPLETED = 'Not Completed', } + +/** + * + * 1. not_completed - financial reports exist, but no data has been added. + * 2. pending - financial reports exist, admin filled in some information, but ANAF information is not yet ready. + * 3. completed - financial reports exist, admin filled in and it checks out against ANAF information. + * 4. invalid - financial reports exist, admin filled in but it does not check out against ANAF information. + * + * @export + * @enum {number} + */ +export enum OrganizationFinancialReportStatus { + NOT_COMPLETED = 'Not Completed', + PENDING = 'Pending', + COMPLETED = 'Completed', + INVALID = 'Invalid', +} diff --git a/frontend/src/pages/organization/interfaces/OrganizationContext.ts b/frontend/src/pages/organization/interfaces/OrganizationContext.ts index c2e776870..9f916772e 100644 --- a/frontend/src/pages/organization/interfaces/OrganizationContext.ts +++ b/frontend/src/pages/organization/interfaces/OrganizationContext.ts @@ -1,5 +1,22 @@ +import { UseMutateFunction } from 'react-query'; +import { OrganizationPayload } from '../../../services/organization/Organization.queries'; +import { IOrganizationActivity } from './OrganizationActivity.interface'; +import { IOrganizationFinancial } from './OrganizationFinancial.interface'; +import { IOrganizationGeneral } from './OrganizationGeneral.interface'; +import { IOrganizationLegal } from './OrganizationLegal.interface'; +import { IOrganizationReport } from './OrganizationReport.interface'; + export interface OrganizationContext { disabled: boolean; isLoading: boolean; - updateOrganization: any; + updateOrganization: UseMutateFunction< + | IOrganizationGeneral + | IOrganizationActivity + | IOrganizationFinancial + | IOrganizationLegal + | IOrganizationReport, + unknown, + OrganizationPayload, + unknown + >; } diff --git a/frontend/src/pages/organization/interfaces/OrganizationFinancial.interface.ts b/frontend/src/pages/organization/interfaces/OrganizationFinancial.interface.ts index 80f8155b8..199aa342e 100644 --- a/frontend/src/pages/organization/interfaces/OrganizationFinancial.interface.ts +++ b/frontend/src/pages/organization/interfaces/OrganizationFinancial.interface.ts @@ -1,15 +1,19 @@ import { BaseEntity } from '../../../common/interfaces/base-entity.interface'; import { Expense } from './Expense.interface'; import { Income } from './Income.interface'; -import { CompletionStatus } from '../enums/CompletionStatus.enum'; +import { + CompletionStatus, + OrganizationFinancialReportStatus, +} from '../enums/CompletionStatus.enum'; import { FinancialType } from '../enums/FinancialType.enum'; export interface IOrganizationFinancial extends BaseEntity { type: FinancialType; - numberOfEmployees: number; + numberOfEmployees: number | null; year: number; - total: number; + total: number | null; synched_anaf: boolean; data: Partial | Partial | null; status: CompletionStatus; + reportStatus: OrganizationFinancialReportStatus; } diff --git a/frontend/src/pages/organization/interfaces/OrganizationStatistics.interface.ts b/frontend/src/pages/organization/interfaces/OrganizationStatistics.interface.ts index 37252f2df..712d31bc4 100644 --- a/frontend/src/pages/organization/interfaces/OrganizationStatistics.interface.ts +++ b/frontend/src/pages/organization/interfaces/OrganizationStatistics.interface.ts @@ -8,15 +8,16 @@ export interface IAllOrganizationsStatistics { } export interface IOrganizationStatistics { - isOrganizationUpdated: boolean; organizationCreatedOn: Date; - organizationSyncedOn: Date; + organizationSyncedOn: Date | null; numberOfInstalledApps: number; numberOfUsers: number; - hubStatistics: IGeneralONGHubStatistics + hubStatistics: IGeneralONGHubStatistics; + numberOfErroredFinancialReports: number; + numberOfErroredReportsInvestorsPartners: number; } export interface IGeneralONGHubStatistics { numberOfActiveOrganizations: number; numberOfApplications: number; -} \ No newline at end of file +} diff --git a/frontend/src/pages/organization/interfaces/ReportModalProps.interface.ts b/frontend/src/pages/organization/interfaces/ReportModalProps.interface.ts index 8fc1decfd..dc67665d1 100644 --- a/frontend/src/pages/organization/interfaces/ReportModalProps.interface.ts +++ b/frontend/src/pages/organization/interfaces/ReportModalProps.interface.ts @@ -4,7 +4,7 @@ import { Income } from './Income.interface'; export interface ReportModalProps { onClose: () => void; year?: number; - total?: number; + total?: number | null; readonly?: boolean; defaultValue?: Partial | Partial | null; onSave: (data: Partial | Partial) => void; diff --git a/frontend/src/pages/organization/table-headers/OrganizationsTable.headers.tsx b/frontend/src/pages/organization/table-headers/OrganizationsTable.headers.tsx index 0c21bde80..35d084aee 100644 --- a/frontend/src/pages/organization/table-headers/OrganizationsTable.headers.tsx +++ b/frontend/src/pages/organization/table-headers/OrganizationsTable.headers.tsx @@ -24,7 +24,7 @@ const translations = { export const OrganizationsTableHeaders: TableColumn[] = [ { - id: 'name', + id: 'alias', name: , sortable: true, minWidth: '10rem', @@ -65,7 +65,8 @@ export const OrganizationsTableHeaders: TableColumn[] = [ name: , sortable: true, minWidth: '12rem', - selector: (row: IOrganizationView) => formatDate(row?.updatedOn as string), + selector: (row: IOrganizationView) => + row?.updatedOn ? formatDate(row?.updatedOn as string) : '-', }, { id: 'completionStatus', diff --git a/frontend/src/pages/users/components/UserCreate/UserCreateConfig.ts b/frontend/src/pages/users/components/UserCreate/UserCreateConfig.ts index 354348dce..c918253f9 100644 --- a/frontend/src/pages/users/components/UserCreate/UserCreateConfig.ts +++ b/frontend/src/pages/users/components/UserCreate/UserCreateConfig.ts @@ -79,10 +79,6 @@ export const UserCreateConfig: Record = { phone: { key: 'phone', rules: { - required: { - value: true, - message: translations.phone.required, - }, maxLength: { value: 15, message: translations.phone.max, @@ -92,6 +88,9 @@ export const UserCreateConfig: Record = { message: translations.phone.min, }, validate: (value: string) => { + if (!value) { + return true; + } return isValidPhoneNumber(value) || translations.phone.invalid; }, }, diff --git a/frontend/src/services/API.tsx b/frontend/src/services/API.tsx index 58ad5f251..7e1ebed91 100644 --- a/frontend/src/services/API.tsx +++ b/frontend/src/services/API.tsx @@ -54,14 +54,14 @@ const AxiosInterceptor = ({ children }: AxiosInterceptorProps) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any async (error: any) => { // Redirect to login once we have restricted access - if (error.response.status === 401) { + if (error.response?.status === 401) { await signOut(); // set initial application state setAuthState({ isAuthenticated: false, isRestricted: false, restrictedReason: '' }); } // If use doesn't have access to resource redirect to home - if (error.response.status === 403) { + if (error.response?.status === 403) { // this will trigger the redirect to restricted page setAuthState({ isAuthenticated: true, diff --git a/frontend/src/services/application/Application.queries.ts b/frontend/src/services/application/Application.queries.ts index 088dfa581..eeb554627 100644 --- a/frontend/src/services/application/Application.queries.ts +++ b/frontend/src/services/application/Application.queries.ts @@ -5,9 +5,7 @@ import { ApplicationTypeEnum } from '../../pages/apps-store/constants/Applicatio import { OngApplicationStatus } from '../../pages/requests/interfaces/OngApplication.interface'; import useStore from '../../store/store'; import { - activateApplication, createApplication, - deactivateApplication, getApplicationById, getApplicationOrganizations, getApplications, @@ -143,18 +141,6 @@ export const useUpdateApplicationMutation = () => { ); }; -export const useActivateApplication = () => { - return useMutation(({ applicationId }: { applicationId: string }) => - activateApplication(applicationId), - ); -}; - -export const useDectivateApplication = () => { - return useMutation(({ applicationId }: { applicationId: string }) => - deactivateApplication(applicationId), - ); -}; - export const useRestrictApplicationMutation = () => { return useMutation( ({ applicationId, organizationId }: { applicationId: number; organizationId: string }) => diff --git a/frontend/src/services/application/Application.service.ts b/frontend/src/services/application/Application.service.ts index 522a5abf8..f4de9789a 100644 --- a/frontend/src/services/application/Application.service.ts +++ b/frontend/src/services/application/Application.service.ts @@ -1,6 +1,6 @@ import { AxiosResponse } from 'axios'; import { OrderDirection } from '../../common/enums/sort-direction.enum'; -import { cleanupPayload } from '../../common/helpers/format.helper'; +import { cleanupPayload, mapSelectToApplicationLabel } from '../../common/helpers/format.helper'; import { PaginatedEntity } from '../../common/interfaces/paginated-entity.interface'; import { ApplicationTypeEnum } from '../../pages/apps-store/constants/ApplicationType.enum'; import { OngApplicationStatus } from '../../pages/requests/interfaces/OngApplication.interface'; @@ -14,6 +14,7 @@ import { ApplicationStatus, ApplicationWithOngStatus, } from './interfaces/Application.interface'; +import { mapEntityToFormData } from '../organization/OrganizationFormDataMapper.service'; export const createApplication = ( createApplicationDto: CreateApplicationDto, @@ -137,9 +138,9 @@ const generateApplicationFormDataPayload = ( logo?: File, ): FormData => { // we remove the logo and steps - const { steps, pullingType, ...data } = applicationDto; + const { steps, pullingType, applicationLabel, ...data } = applicationDto; // create form data payload - const payload = new FormData(); + let payload = new FormData(); for (const prop in cleanupPayload(data)) { // eslint-disable-next-line @typescript-eslint/no-explicit-any payload.append(prop, (data as any)[prop] as string); @@ -161,5 +162,13 @@ const generateApplicationFormDataPayload = ( payload.append('logo', logo); } + if (applicationLabel) { + payload = mapEntityToFormData( + payload, + 'applicationLabel', + mapSelectToApplicationLabel(applicationLabel), + ); + } + return payload; }; diff --git a/frontend/src/services/application/interfaces/Application.dto.ts b/frontend/src/services/application/interfaces/Application.dto.ts index db046df80..3e2763d66 100644 --- a/frontend/src/services/application/interfaces/Application.dto.ts +++ b/frontend/src/services/application/interfaces/Application.dto.ts @@ -11,4 +11,5 @@ export interface CreateApplicationDto { website: string; logo: string; steps: { item: string }[]; + applicationLabel: { label: string; value: string }; } diff --git a/frontend/src/services/application/interfaces/Application.interface.ts b/frontend/src/services/application/interfaces/Application.interface.ts index 531a2ae4c..b81812267 100644 --- a/frontend/src/services/application/interfaces/Application.interface.ts +++ b/frontend/src/services/application/interfaces/Application.interface.ts @@ -1,3 +1,4 @@ +import { ApplicationLabel } from '../../../common/interfaces/application-label.interface'; import { ApplicationTypeEnum } from '../../../pages/apps-store/constants/ApplicationType.enum'; import { ApplicationPullingType } from '../../../pages/apps-store/enums/application-pulling-type.enum'; import { @@ -38,6 +39,7 @@ export interface ApplicationWithOngStatus { pullingType?: ApplicationPullingType; website: string; createdOn: Date; + applicationLabel: string; } export interface ApplicationAccess { diff --git a/frontend/src/services/nomenclature/Nomenclature.queries.ts b/frontend/src/services/nomenclature/Nomenclature.queries.ts index 3781ebee2..ce471168b 100644 --- a/frontend/src/services/nomenclature/Nomenclature.queries.ts +++ b/frontend/src/services/nomenclature/Nomenclature.queries.ts @@ -17,6 +17,7 @@ import { getServiceDomains, getBeneficiaries, getIssuers, + getApplicationLabels, } from './Nomenclatures.service'; import { Coalition } from '../../common/interfaces/coalitions.interface'; import { Federation } from '../../common/interfaces/federations.interface'; @@ -129,3 +130,12 @@ export const useFacultiesQuery = () => { }, }); }; + +export const useApplicationLabelsQuery = () => { + const { setApplicationLabels } = useStore(); + return useQuery('application-labels', () => getApplicationLabels(), { + onSuccess: (data) => { + setApplicationLabels(data); + }, + }); +}; diff --git a/frontend/src/services/nomenclature/Nomenclatures.service.ts b/frontend/src/services/nomenclature/Nomenclatures.service.ts index 543fa81cd..5e80752cb 100644 --- a/frontend/src/services/nomenclature/Nomenclatures.service.ts +++ b/frontend/src/services/nomenclature/Nomenclatures.service.ts @@ -64,3 +64,7 @@ export const getFaculties = (): Promise => { export const getIssuers = (): Promise<{ id: number; name: string }[]> => { return API.get(`/nomenclatures/issuers`).then((res) => res.data); }; + +export const getApplicationLabels = (): Promise<{ id: number; name: string }[]> => { + return API.get(`/nomenclatures/application-labels`).then((res) => res.data); +}; diff --git a/frontend/src/services/organization/Organization.queries.ts b/frontend/src/services/organization/Organization.queries.ts index 79c56ec76..b67471818 100644 --- a/frontend/src/services/organization/Organization.queries.ts +++ b/frontend/src/services/organization/Organization.queries.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from 'react-query'; +import { useMutation, useQuery, useQueryClient } from 'react-query'; import { OrderDirection } from '../../common/enums/sort-direction.enum'; import { PaginatedEntity } from '../../common/interfaces/paginated-entity.interface'; import { Person } from '../../common/interfaces/person.interface'; @@ -31,6 +31,7 @@ import { getOrganizationApplicationRequests, getOrganizationApplications, getOrganizationByProfile, + getOrganizationReportsStatus, getOrganizations, patchOrganization, patchOrganizationByProfile, @@ -43,7 +44,7 @@ import { uploadPartnersByProfile, } from './Organization.service'; -interface OrganizationPayload { +export interface OrganizationPayload { id?: number; organization: { general?: IOrganizationGeneral; @@ -286,6 +287,13 @@ export const useOrganizationByProfileQuery = () => { }); }; +// Used to display errored Reports banners only for Organization Admins +export const useOrganizationReportsStatus = (isAdmin: boolean) => { + return useQuery(['organization-reports-status'], () => getOrganizationReportsStatus(), { + enabled: isAdmin, + }); +}; + export const useOrganizationByProfileMutation = () => { const { setOrganizationGeneral, @@ -345,39 +353,55 @@ export const useOrganizationByProfileMutation = () => { export const useUploadPartnersListByProfile = () => { const { setOrganizationReport } = useStore(); + const queryClient = useQueryClient(); return useMutation( ({ partnerId, data }: { partnerId: number; data: FormData }) => uploadPartnersByProfile(partnerId, data), { - onSuccess: (data: IOrganizationReport) => setOrganizationReport(data), + onSuccess: (data: IOrganizationReport) => { + queryClient.invalidateQueries({ queryKey: ['organization-reports-status'] }); + setOrganizationReport(data); + }, }, ); }; export const useUploadInvestorsByProfileList = () => { const { setOrganizationReport } = useStore(); + const queryClient = useQueryClient(); return useMutation( ({ investorId, data }: { investorId: number; data: FormData }) => uploadInvestorsByProfile(investorId, data), { - onSuccess: (data: IOrganizationReport) => setOrganizationReport(data), + onSuccess: (data: IOrganizationReport) => { + queryClient.invalidateQueries({ queryKey: ['organization-reports-status'] }); + setOrganizationReport(data); + }, }, ); }; export const useDeletePartnerByProfileMutation = () => { + const queryClient = useQueryClient(); const { setOrganizationReport } = useStore(); return useMutation(({ partnerId }: { partnerId: number }) => deletePartnersByProfile(partnerId), { - onSuccess: (data: IOrganizationReport) => setOrganizationReport(data), + onSuccess: (data: IOrganizationReport) => { + queryClient.invalidateQueries({ queryKey: ['organization-reports-status'] }); + setOrganizationReport(data); + }, }); }; export const useDeleteInvestorByProfileMutation = () => { + const queryClient = useQueryClient(); const { setOrganizationReport } = useStore(); return useMutation( ({ investorId }: { investorId: number }) => deleteInvestorsByProfile(investorId), { - onSuccess: (data: IOrganizationReport) => setOrganizationReport(data), + onSuccess: (data: IOrganizationReport) => { + queryClient.invalidateQueries({ queryKey: ['organization-reports-status'] }); + setOrganizationReport(data); + }, }, ); }; diff --git a/frontend/src/services/organization/Organization.service.ts b/frontend/src/services/organization/Organization.service.ts index 690054c20..55165da40 100644 --- a/frontend/src/services/organization/Organization.service.ts +++ b/frontend/src/services/organization/Organization.service.ts @@ -17,12 +17,17 @@ import { mapOrganizationLegalToFormData, } from './OrganizationFormDataMapper.service'; import { IOrganizationFinancial } from '../../pages/organization/interfaces/OrganizationFinancial.interface'; +import { OrganizationReportsStatusAPI } from './interfaces/organization-reports-status.interface'; /**EMPLOYEE && ADMIN */ export const getOrganizationByProfile = (): Promise => { return API.get(`/organization-profile`).then((res) => res.data); }; +export const getOrganizationReportsStatus = (): Promise => { + return API.get(`/organization-profile/reports-status`).then((res) => res.data); +}; + export const patchOrganizationByProfile = ( update: any, logo?: File | null, diff --git a/frontend/src/services/organization/interfaces/organization-reports-status.interface.ts b/frontend/src/services/organization/interfaces/organization-reports-status.interface.ts new file mode 100644 index 000000000..cc7c3bb8d --- /dev/null +++ b/frontend/src/services/organization/interfaces/organization-reports-status.interface.ts @@ -0,0 +1,4 @@ +export interface OrganizationReportsStatusAPI { + numberOfErroredFinancialReports: number; + numberOfErroredReportsInvestorsPartners: number; +} diff --git a/frontend/src/services/statistics/statistics.service.ts b/frontend/src/services/statistics/statistics.service.ts index 3202f5bf9..f8d0c4410 100644 --- a/frontend/src/services/statistics/statistics.service.ts +++ b/frontend/src/services/statistics/statistics.service.ts @@ -1,15 +1,16 @@ import { ChartOption } from '../../components/line-chart/chart.config'; +import { IOrganizationStatistics } from '../../pages/organization/interfaces/OrganizationStatistics.interface'; import API from '../API'; export const getOrganizationsStatistics = (): Promise => { return API.get(`statistics`).then((res) => res.data); }; -export const getOneOrganizationStatistics = (id: number): Promise => { +export const getOneOrganizationStatistics = (id: number): Promise => { return API.get(`statistics/organization/${id}`).then((res) => res.data); }; -export const getOrganizationProfileStatistics = (): Promise => { +export const getOrganizationProfileStatistics = (): Promise => { return API.get(`statistics/organization`).then((res) => res.data); }; diff --git a/frontend/src/store/nomenclature/nomenclature.selectors.ts b/frontend/src/store/nomenclature/nomenclature.selectors.ts index f11fe9be8..b12cdc1c2 100644 --- a/frontend/src/store/nomenclature/nomenclature.selectors.ts +++ b/frontend/src/store/nomenclature/nomenclature.selectors.ts @@ -10,6 +10,7 @@ export const useNomenclature = () => { const skills = useStore((state) => state.skills); const faculties = useStore((state) => state.faculties); const issuers = useStore((state) => state.issuers); + const applicationLabels = useStore((state) => state.applicationLabels); return { counties, cities, @@ -20,5 +21,6 @@ export const useNomenclature = () => { skills, faculties, issuers, + applicationLabels, }; }; diff --git a/frontend/src/store/nomenclature/nomenclature.slice.ts b/frontend/src/store/nomenclature/nomenclature.slice.ts index c148e2b79..5a1ad5a96 100644 --- a/frontend/src/store/nomenclature/nomenclature.slice.ts +++ b/frontend/src/store/nomenclature/nomenclature.slice.ts @@ -1,3 +1,4 @@ +import { ApplicationLabel } from '../../common/interfaces/application-label.interface'; import { City } from '../../common/interfaces/city.interface'; import { Coalition } from '../../common/interfaces/coalitions.interface'; import { County } from '../../common/interfaces/county.interface'; @@ -19,6 +20,7 @@ export const nomenclatureSlice = (set: any) => ({ skills: [], faculties: [], issuers: [], + applicationLabels: [], setCounties: (counties: County[]) => { set({ counties }); }, @@ -46,6 +48,9 @@ export const nomenclatureSlice = (set: any) => ({ setIssuers: (issuers: Issuer[]) => { set({ issuers }); }, + setApplicationLabels: (applicationLabels: ApplicationLabel[]) => { + set({ applicationLabels }); + }, }); export default { nomenclatureSlice }; diff --git a/frontend/src/store/store.ts b/frontend/src/store/store.ts index 5993583bd..06dada152 100644 --- a/frontend/src/store/store.ts +++ b/frontend/src/store/store.ts @@ -47,6 +47,7 @@ import { Faculty } from '../common/interfaces/faculty.interface'; import { IFeedback } from '../pages/civic-center-service/interfaces/Feedback.interface'; import { feedbacksSlice } from './civic-center-service/Feedback.slice'; import { Issuer } from '../common/interfaces/issuer.interface'; +import { ApplicationLabel } from '../common/interfaces/application-label.interface'; interface OrganizationState { organizations: PaginatedEntity; @@ -74,6 +75,7 @@ interface NomenclatureState { skills: Skill[]; faculties: Faculty[]; issuers: Issuer[]; + applicationLabels: ApplicationLabel[]; setCounties: (counties: County[]) => void; setCities: (cities: City[]) => void; setDomains: (domains: Domain[]) => void; @@ -83,6 +85,7 @@ interface NomenclatureState { setSkills: (skills: Skill[]) => void; setFaculties: (faculties: Faculty[]) => void; setIssuers: (issuers: Issuer[]) => void; + setApplicationLabels: (applicationLabels: ApplicationLabel[]) => void; } interface ProfileState { diff --git a/frontend/tailwind.config.js b/frontend/tailwind.config.js index 75a4e663c..9d52c0a1b 100644 --- a/frontend/tailwind.config.js +++ b/frontend/tailwind.config.js @@ -37,6 +37,19 @@ module.exports = { 800: '#F9A825', 900: '#F57F17', }, + amber: { + 50: '#fffbeb', + 100: '#fef3c7', + 200: '#fde68a', + 300: '#fcd34d', + 400: '#fbbf24', + 500: '#f59e0b', + 600: '#d97706', + 700: '#b45309', + 800: '#92400e', + 900: '#78350f', + 950: '#451a03', + }, blue: { 50: '#EEF2FF', 100: '#E0E7FF',