Added mailchimp feature + cron services

This commit is contained in:
Vins 2023-07-17 15:14:35 +02:00
parent 399ed9ac3c
commit 8fd59a13d8
13 changed files with 463 additions and 7 deletions

46
Dockerfile-Cron Normal file
View File

@ -0,0 +1,46 @@
# Install dependencies only when needed
FROM node:19-alpine AS deps
WORKDIR leCoffre
RUN npm install -D prisma@4.11.0
COPY package.json ./
RUN apk update && apk add openssh-client git
COPY id_rsa /root/.ssh/id_rsa
RUN chmod 600 ~/.ssh/id_rsa
RUN eval "$(ssh-agent -s)" && ssh-add /root/.ssh/id_rsa
RUN ssh-keyscan github.com smart-chain-fr/leCoffre-resources.git >> /root/.ssh/known_hosts
RUN npm install --frozen-lockfile
# Rebuild the source code only when needed
FROM node:19-alpine AS builder
WORKDIR leCoffre
COPY --from=deps leCoffre/node_modules ./node_modules
COPY --from=deps leCoffre/package.json package.json
COPY tsconfig.json tsconfig.json
COPY src src
RUN npx prisma generate
RUN npm run build
# Production image, copy all the files and run next
FROM node:19-alpine AS production
WORKDIR leCoffre
RUN adduser -D lecoffreuser --uid 10000 && chown -R lecoffreuser .
COPY --from=builder --chown=lecoffreuser leCoffre/node_modules ./node_modules
COPY --from=builder --chown=lecoffreuser leCoffre/dist dist
COPY --from=builder --chown=lecoffreuser leCoffre/package.json ./package.json
COPY --from=builder --chown=lecoffreuser leCoffre/src/common/databases ./src/common/databases
USER lecoffreuser
CMD ["npm", "run", "cron"]
EXPOSE 3001

61
package-lock.json generated
View File

@ -9,12 +9,14 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@mailchimp/mailchimp_transactional": "^1.0.50",
"@pinata/sdk": "^2.1.0",
"@prisma/client": "^4.11.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"classnames": "^2.3.2",
"cors": "^2.8.5",
"cron": "^2.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.57",
@ -35,9 +37,12 @@
},
"devDependencies": {
"@types/cors": "^2.8.13",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.16",
"@types/jest": "^29.5.0",
"@types/jsonwebtoken": "^9.0.1",
"@types/mailchimp__mailchimp_transactional": "^1.0.5",
"@types/module-alias": "^2.0.1",
"@types/multer": "^1.4.7",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.3",
@ -1044,6 +1049,17 @@
"integrity": "sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw==",
"dev": true
},
"node_modules/@mailchimp/mailchimp_transactional": {
"version": "1.0.50",
"resolved": "https://registry.npmjs.org/@mailchimp/mailchimp_transactional/-/mailchimp_transactional-1.0.50.tgz",
"integrity": "sha512-SaNFseFPSDQlOYM9JTyYY6wauMu6qJ8eExo+jssFyb20ZaVvxKX1eTb3Gm5aW/4aWuxn6nofU+02sCk51//wdw==",
"dependencies": {
"axios": "^0.21.1"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/@next/env": {
"version": "13.4.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-13.4.10.tgz",
@ -1348,6 +1364,16 @@
"@types/node": "*"
}
},
"node_modules/@types/cron": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/cron/-/cron-2.0.1.tgz",
"integrity": "sha512-WHa/1rtNtD2Q/H0+YTTZoty+/5rcE66iAFX2IY+JuUoOACsevYyFkSYu/2vdw+G5LrmO7Lxowrqm0av4k3qWNQ==",
"dev": true,
"dependencies": {
"@types/luxon": "*",
"@types/node": "*"
}
},
"node_modules/@types/express": {
"version": "4.17.17",
"resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.17.tgz",
@ -1430,12 +1456,33 @@
"@types/node": "*"
}
},
"node_modules/@types/luxon": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.3.1.tgz",
"integrity": "sha512-XOS5nBcgEeP2PpcqJHjCWhUCAzGfXIU8ILOSLpx2FhxqMW9KdxgCGXNOEKGVBfveKtIpztHzKK5vSRVLyW/NqA==",
"dev": true
},
"node_modules/@types/mailchimp__mailchimp_transactional": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/mailchimp__mailchimp_transactional/-/mailchimp__mailchimp_transactional-1.0.5.tgz",
"integrity": "sha512-5LYI3dZcyVBtg+lNxhKBHrHnNeAVvlpPM0kO6FZcjrrMALK7wistwvqI8PAns2mnveC67OSN43y6wQkK6KeTNQ==",
"dev": true,
"dependencies": {
"axios": "^0.21.1"
}
},
"node_modules/@types/mime": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.2.tgz",
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"node_modules/@types/module-alias": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/module-alias/-/module-alias-2.0.1.tgz",
"integrity": "sha512-DN/CCT1HQG6HquBNJdLkvV+4v5l/oEuwOHUPLxI+Eub0NED+lk0YUfba04WGH90EINiUrNgClkNnwGmbICeWMQ==",
"dev": true
},
"node_modules/@types/multer": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
@ -1446,9 +1493,9 @@
}
},
"node_modules/@types/node": {
"version": "18.16.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.19.tgz",
"integrity": "sha512-IXl7o+R9iti9eBW4Wg2hx1xQDig183jj7YLn8F7udNceyfkbn1ZxmzZXuak20gR40D7pIkIY1kYGx5VIGbaHKA=="
"version": "18.17.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.17.0.tgz",
"integrity": "sha512-GXZxEtOxYGFchyUzxvKI14iff9KZ2DI+A6a37o6EQevtg6uO9t+aUZKcaC1Te5Ng1OnLM7K9NVVj+FbecD9cJg=="
},
"node_modules/@types/node-fetch": {
"version": "2.6.4",
@ -2287,6 +2334,14 @@
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="
},
"node_modules/cron": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/cron/-/cron-2.3.1.tgz",
"integrity": "sha512-1eRRlIT0UfIqauwbG9pkg3J6CX9A6My2ytJWqAXoK0T9oJnUZTzGBNPxao0zjodIbPgf8UQWjE62BMb9eVllSQ==",
"dependencies": {
"luxon": "^3.2.1"
}
},
"node_modules/cron-parser": {
"version": "4.8.1",
"resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.8.1.tgz",

View File

@ -20,6 +20,7 @@
"build-db": "npx prisma migrate dev",
"build": "tsc",
"start": "node ./dist/entries/App.js",
"cron": "node ./dist/entries/Cron.js",
"api:start": "npm run migrate && npm run start",
"dev": "nodemon -V",
"format": "prettier --write src",
@ -41,12 +42,14 @@
},
"homepage": "https://github.com/smart-chain-fr/leCoffre-back#readme",
"dependencies": {
"@mailchimp/mailchimp_transactional": "^1.0.50",
"@pinata/sdk": "^2.1.0",
"@prisma/client": "^4.11.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.0",
"classnames": "^2.3.2",
"cors": "^2.8.5",
"cron": "^2.3.1",
"express": "^4.18.2",
"jsonwebtoken": "^9.0.0",
"le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.58",
@ -67,9 +70,12 @@
},
"devDependencies": {
"@types/cors": "^2.8.13",
"@types/cron": "^2.0.1",
"@types/express": "^4.17.16",
"@types/jest": "^29.5.0",
"@types/jsonwebtoken": "^9.0.1",
"@types/mailchimp__mailchimp_transactional": "^1.0.5",
"@types/module-alias": "^2.0.1",
"@types/multer": "^1.4.7",
"@types/node": "^18.11.18",
"@types/node-fetch": "^2.6.3",
@ -84,6 +90,6 @@
},
"prisma": {
"schema": "src/common/databases/schema.prisma",
"seed": "ts-node src/common/databases/seeders/seeder2.ts"
"seed": "ts-node src/common/databases/seeders/seeder.ts"
}
}

View File

@ -9,11 +9,12 @@ import { validateOrReject } from "class-validator";
import authHandler from "@App/middlewares/AuthHandler";
import ruleHandler from "@App/middlewares/RulesHandler";
import documentHandler from "@App/middlewares/OfficeMembershipHandlers/DocumentHandler";
import EmailBuilder from "@Common/emails/EmailBuilder";
@Controller()
@Service()
export default class DocumentsController extends ApiController {
constructor(private documentsService: DocumentsService) {
constructor(private documentsService: DocumentsService, private emailBuilder: EmailBuilder) {
super();
}
@ -64,6 +65,9 @@ export default class DocumentsController extends ApiController {
//call service to get prisma entity
const documentEntityCreated = await this.documentsService.create(documentEntity);
//create email for asked document
this.emailBuilder.sendDocumentEmails(documentEntityCreated);
//Hydrate ressource with prisma entity
const document = Document.hydrate<Document>(documentEntityCreated, {
strategy: "excludeAll",
@ -105,6 +109,9 @@ export default class DocumentsController extends ApiController {
//call service to get prisma entity
const documentEntityUpdated: Documents = await this.documentsService.update(uid, documentEntity, req.body.refused_reason);
//create email for asked document
this.emailBuilder.sendDocumentEmails(documentEntityUpdated);
//Hydrate ressource with prisma entity
const document = Document.hydrate<Document>(documentEntityUpdated, { strategy: "excludeAll" });

View File

@ -0,0 +1,43 @@
-- DropForeignKey
ALTER TABLE "users" DROP CONSTRAINT "users_contact_uid_fkey";
-- DropForeignKey
ALTER TABLE "users" DROP CONSTRAINT "users_office_role_uid_fkey";
-- DropForeignKey
ALTER TABLE "users" DROP CONSTRAINT "users_office_uid_fkey";
-- DropForeignKey
ALTER TABLE "users" DROP CONSTRAINT "users_roles_uid_fkey";
-- CreateTable
CREATE TABLE "email" (
"uid" TEXT NOT NULL,
"templateName" VARCHAR(255) NOT NULL,
"from" VARCHAR(255),
"to" VARCHAR(255) NOT NULL,
"subject" VARCHAR(255) NOT NULL,
"templateVariables" JSON NOT NULL DEFAULT '{}',
"cc" VARCHAR(255)[],
"cci" VARCHAR(255)[],
"sentAt" TIMESTAMP(3),
"nbTrySend" INTEGER DEFAULT 0,
"lastTrySendDate" TIMESTAMP(3),
CONSTRAINT "email_pkey" PRIMARY KEY ("uid")
);
-- CreateIndex
CREATE UNIQUE INDEX "email_uid_key" ON "email"("uid");
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_contact_uid_fkey" FOREIGN KEY ("contact_uid") REFERENCES "contacts"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_roles_uid_fkey" FOREIGN KEY ("roles_uid") REFERENCES "roles"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_office_role_uid_fkey" FOREIGN KEY ("office_role_uid") REFERENCES "office_roles"("uid") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "users" ADD CONSTRAINT "users_office_uid_fkey" FOREIGN KEY ("office_uid") REFERENCES "offices"("uid") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -270,6 +270,22 @@ model Rules {
@@map("rules")
}
model Emails {
uid String @id @unique @default(uuid())
templateName String @db.VarChar(255)
from String? @db.VarChar(255)
to String @db.VarChar(255)
subject String @db.VarChar(255)
templateVariables Json @default("{}") @db.Json
cc String[] @db.VarChar(255)
cci String[] @db.VarChar(255)
sentAt DateTime?
nbTrySend Int? @default(0)
lastTrySendDate DateTime?
@@map("email")
}
enum ECivility {
MALE
FEMALE

View File

@ -0,0 +1,50 @@
import DocumentsService from "@Services/super-admin/DocumentsService/DocumentsService";
import { Documents } from "@prisma/client";
import { Document } from "le-coffre-resources/dist/SuperAdmin";
import { Service } from "typedi";
import { ETemplates } from "./Templates/EmailTemplates";
import MailchimpService from "@Services/common/MailchimpService/MailchimpService";
@Service()
export default class EmailBuilder {
public constructor(private mailchimpService: MailchimpService ,private documentsService: DocumentsService){}
public async sendDocumentEmails(documentEntity: Documents){
if(documentEntity.document_status !== "ASKED" && documentEntity.document_status !== "REFUSED") return;
const documentPrisma = await this.documentsService.getByUid(documentEntity.uid, { depositor: {include: {contact: true}}, folder:{include:{ office: true}} });
if(!documentPrisma) throw new Error("Document not found");
const document = Document.hydrate<Document>(documentPrisma);
const to = document.depositor!.contact!.email;
const templateVariables = {
civility: document.depositor!.contact!.civility,
last_name: document.depositor!.contact!.last_name,
office_name: document.folder!.office!.name,
link: "http://localhost:3000"
};
let templateName = ETemplates.DOCUMENT_ASKED;
let subject = "Document Asked";
if(documentEntity.document_status === "REFUSED"){
templateName = ETemplates.DOCUMENT_REFUSED;
subject = "Document Refused";
}
this.mailchimpService.create({
templateName,
to,
subject,
templateVariables,
uid: "",
from: null,
cc: [],
cci: [],
sentAt: null,
nbTrySend: null,
lastTrySendDate: null,
});
}
}

View File

@ -0,0 +1,4 @@
export const ETemplates = {
DOCUMENT_ASKED: "DOCUMENT_ASKED",
DOCUMENT_REFUSED: "DOCUMENT_REFUSED",
};

View File

@ -0,0 +1,75 @@
import Database from "@Common/databases/database";
import BaseRepository from "@Repositories/BaseRepository";
import { Service } from "typedi";
import { Emails, Prisma } from "prisma/prisma-client";
@Service()
export default class EmailRepository extends BaseRepository {
constructor(private database: Database) {
super();
}
protected get model() {
return this.database.getClient().emails;
}
protected get instanceDb() {
return this.database.getClient();
}
/**
* @description : Find many emails
*/
public async findMany(query: Prisma.EmailsFindManyArgs) {
query.take = Math.min(query.take || this.defaultFetchRows, this.maxFetchRows);
return this.model.findMany(query);
}
/**
* @description : Create an email
*/
public async create(email: Emails): Promise<Emails> {
const createArgs: Prisma.EmailsCreateArgs = {
data: {
templateName: email.templateName,
from: email.from,
to: email.to,
subject: email.subject,
templateVariables: email.templateVariables!,
cc: email.cc,
cci: email.cci,
},
};
return this.model.create(createArgs);
}
/**
* @description : update given email
*/
public async update(uid: string, email: Emails): Promise<Emails> {
const updateArgs: Prisma.EmailsUpdateArgs = {
where: {
uid: uid,
},
data: {
sentAt: email.sentAt,
nbTrySend: email.nbTrySend,
lastTrySendDate: email.lastTrySendDate,
},
};
return this.model.update(updateArgs);
}
/**
* @description : find unique email
*/
public async findOneByUid(uid: string) {
return this.model.findUnique({
where: {
uid: uid,
},
});
}
}

14
src/entries/Cron.ts Normal file
View File

@ -0,0 +1,14 @@
import "module-alias/register";
import "reflect-metadata";
import { Container } from "typedi";
import CronService from "@Services/common/CronService/CronService";
(async () => {
try {
if(process.env["MAILCHIMP_API_KEY"] === "ppd"){
Container.get(CronService).sendMails();
}
} catch (e) {
console.error(e);
}
})();

View File

@ -0,0 +1,24 @@
import { Service } from "typedi";
import { CronJob } from "cron";
import MailchimpService from "../MailchimpService/MailchimpService";
@Service()
export default class CronService {
constructor(private mailchimpService: MailchimpService) {}
public async sendMails() {
const cronJob = new CronJob("*/15 * * * * *", async () => {
try {
await this.mailchimpService.sendEmails();
} catch (e) {
console.error(e);
}
});
// Start job
if (!cronJob.running) {
cronJob.start();
}
}
}

View File

@ -0,0 +1,116 @@
import EmailRepository from "@Repositories/EmailRepository";
import BaseService from "@Services/BaseService";
import { Emails } from "@prisma/client";
import { Service } from "typedi";
import MailchimpClient from "@mailchimp/mailchimp_transactional";
@Service()
export default class MailchimpService extends BaseService {
// private static readonly mailchimp = new Mailchimp(process.env.MAILCHIMP_API_KEY);
private static readonly from = "vincent.alamelle@smart-chain.fr";
constructor(private emailRepository: EmailRepository) {
super();
}
/**
* @description : Get all emails
* @throws {Error} If emails cannot be get
*/
public async get(query: any): Promise<Emails[]> {
return this.emailRepository.findMany(query);
}
/**
* @description : Create a new email
* @throws {Error} If email cannot be created
*/
public async create(emailEntity: Emails): Promise<Emails> {
emailEntity.from = MailchimpService.from;
return this.emailRepository.create(emailEntity);
}
/**
* @description : Modify an email
* @throws {Error} If email cannot be modified
*/
public async update(uid: string, emailEntity: Emails): Promise<Emails> {
return this.emailRepository.update(uid, emailEntity);
}
/**
* @description : Get a email by uid
* @throws {Error} If email cannot be get
*/
public async getByUid(uid: string, query?: any): Promise<Emails | null> {
return this.emailRepository.findOneByUid(uid);
}
/**
* @description : Function called by cron to send emails
* @throws {Error} If email cannot be sent
*/
public async sendEmails() {
const emailsToSend = await this.get({ where: { sentAt: null } });
const currentDate = new Date();
let nextTrySendDate = null;
for (const email of emailsToSend) {
//If tries exceed 10, we stop trying to send the email
if (email.nbTrySend && email.nbTrySend > 9) continue;
//If email has never been sent, we send it
if (email.nbTrySend == 0) {
nextTrySendDate = currentDate;
}
//If email has already been sent, we send it again every nbTrySend^2 minutes
else {
nextTrySendDate = new Date(email.lastTrySendDate!);
nextTrySendDate.setMinutes(nextTrySendDate.getMinutes() + Math.pow(email.nbTrySend!, 2));
}
//If the next try send date is passed, we send the email
if (currentDate >= nextTrySendDate) {
try {
await this.sendEmail(email);
email.sentAt = currentDate;
} catch (error) {
email.lastTrySendDate = currentDate;
email.nbTrySend = email.nbTrySend! + 1;
}
await this.update(email.uid, email);
}
}
}
private async sendEmail(email: Emails) {
const apiKey = process.env["MAILCHIMP_API_KEY"];
const mailchimpApiClient = MailchimpClient(apiKey!);
await mailchimpApiClient.messages.sendTemplate({
template_name: email.templateName,
template_content: [],
message: {
global_merge_vars: this.buildVariables(email.templateVariables),
from_email: email.from!,
from_name: "LeCoffre.io",
subject: email.subject,
to: [
{
email: email.to,
type: "to",
},
],
},
});
}
private buildVariables(templateVariables: any) {
return Object.keys(templateVariables).map((key) => {
return {
name: key,
content: templateVariables[key],
};
});
}
}

View File

@ -90,11 +90,11 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"allowJs": true,
"isolatedModules": true
"isolatedModules": true,
},
"include": [
"**/*.ts",
"**/*.tsx",
"**/*.tsx", "src/services/common/TestService",
],
"exclude": [
"node_modules"