Skip to content

Commit

Permalink
feat(Goal): email notifications
Browse files Browse the repository at this point in the history
  • Loading branch information
awinogradov committed Jul 25, 2023
1 parent 6de2105 commit ff8da02
Show file tree
Hide file tree
Showing 8 changed files with 884 additions and 235 deletions.
478 changes: 323 additions & 155 deletions package-lock.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"cyrillic-to-translit-js": "3.2.1",
"easy-typed-intl": "1.0.3",
"gray-matter": "4.0.3",
"markdown-it": "13.0.1",
"md5": "2.3.0",
"multer": "1.4.5-lts.1",
"nanoid": "4.0.2",
Expand Down Expand Up @@ -76,6 +77,7 @@
"@svgr/webpack": "7.0.0",
"@types/cors": "2.8.13",
"@types/jest": "29.5.2",
"@types/markdown-it": "12.2.3",
"@types/md5": "2.3.2",
"@types/multer": "1.4.7",
"@types/node": "18.16.3",
Expand Down
1 change: 1 addition & 0 deletions src/types/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export type FieldDiff = [string | undefined | null, string | undefined | null];
4 changes: 4 additions & 0 deletions src/utils/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@ export const createGoal = async (activityId: string, input: GoalCommon) => {
connect: [{ id: activityId }, { id: input.owner.id }],
},
},
include: {
activity: { include: { user: true, ghost: true } },
owner: { include: { user: true, ghost: true } },
},
});

return {
Expand Down
8 changes: 5 additions & 3 deletions src/utils/worker/create.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { prisma } from '../prisma';

import { EmailTemplatesPropsMap } from './mail/templates';
import * as templates from './mail/templates';

export enum jobState {
scheduled = 'scheduled',
Expand All @@ -12,9 +12,11 @@ export enum jobKind {
email = 'email',
}

type Templates = typeof templates;

export interface JobDataMap {
email: {
template: keyof EmailTemplatesPropsMap;
template: keyof Templates;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
data: any;
};
Expand Down Expand Up @@ -42,7 +44,7 @@ export function createJob<K extends keyof JobDataMap>(kind: K, { data, priority,
});
}

export function createEmailJob<T extends keyof EmailTemplatesPropsMap>(template: T, data: EmailTemplatesPropsMap[T]) {
export function createEmailJob<T extends keyof typeof templates>(template: T, data: Parameters<Templates[T]>[number]) {
return createJob('email', {
data: {
template,
Expand Down
323 changes: 311 additions & 12 deletions src/utils/worker/mail/templates.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,322 @@
import mdit from 'markdown-it';

import type { FieldDiff } from '../../../types/common';

import { SendMailProps } from '.';

const md = mdit('default', {
typographer: true,
});
const withBaseTmplStyles = (html: string) =>
`${html} <style>blockquote { padding: 5px 10px; margin: 0 0 20px; border-left: 5px solid #eee }</style>`;
const absUrl = (s: string) => `${process.env.NEXTAUTH_URL}${s}`;
const renderQuote = (quote: string) =>
quote
.split('\n')
.map((part: string) => `> ${part}`)
.join('\n');
const renderNotice = () =>
'_NB: you got this email because your are owner/issuer/participant/watcher of this goal or project._';
const renderFooter = () => `
____
interface NewCommentEmailProps {
© 2023 Taskany inc.
`;

interface GoalCommentedEmailProps {
to: SendMailProps['to'];
goalId: string;
shortId: string;
title: string;
body: string;
commentId: string;
author?: string;
}

export const newComment = ({ to, goalId, commentId }: NewCommentEmailProps): SendMailProps => ({
export const goalCommented = async ({
to,
subject: `New comment on [${goalId}](${absUrl(`/goals/${goalId}`)})`,
text: `new comment for ${absUrl(`/goals/${goalId}#comment-${commentId}`)}`,
html: `<a href="${absUrl(`/goals/${goalId}#comment-${commentId}`)}">new comment</a> for <a href="${absUrl(
`/goals/${goalId}`,
)}">${goalId}</a>`,
});
shortId,
title,
author = 'Somebody',
body,
commentId,
}: GoalCommentedEmailProps) => {
const goalUrl = absUrl(`/goals/${shortId}`);
const replyUrl = `${goalUrl}#comment-${commentId}`;
const subject = `🧑‍💻 ${author} commented on #${shortId}`;
const html = md.render(`
🧑‍💻 **${author}** commented on **[${shortId}: ${title}](${goalUrl})**:
${renderQuote(body)}
🗣 [Reply](${replyUrl}) to this comment.
${renderNotice()}
${renderFooter()}`);

return {
to,
subject,
html: withBaseTmplStyles(html),
text: subject,
};
};

interface GoalStateUpdatedEmailProps {
to: SendMailProps['to'];
stateTitleBefore?: string;
stateTitleAfter?: string;
shortId: string;
title: string;
author?: string;
}

export const goalStateUpdated = async ({
to,
shortId,
stateTitleBefore = 'Unknown',
stateTitleAfter = 'Unknown',
title,
author = 'Somebody',
}: GoalStateUpdatedEmailProps) => {
const subject = `ℹ️ Goal state was changed on #${shortId}`;
const html = md.render(`
🧑‍💻 **${author}** changed goal state on **[${shortId}: ${title}](${absUrl(
`/goals/${shortId}`,
)})** from ~~\`${stateTitleBefore}\`~~ to \`${stateTitleAfter}\`.
${renderNotice()}
${renderFooter()}`);

return {
to,
subject,
html: withBaseTmplStyles(html),
text: subject,
};
};

interface GoalStateUpdatedWithCommentEmailProps {
to: SendMailProps['to'];
stateTitleBefore?: string;
stateTitleAfter?: string;
shortId: string;
title: string;
body: string;
commentId: string;
author?: string;
}

export const goalStateUpdatedWithComment = async ({
to,
shortId,
stateTitleBefore = 'Unknown',
stateTitleAfter = 'Unknown',
title,
author = 'Somebody',
body,
commentId,
}: GoalStateUpdatedWithCommentEmailProps) => {
const goalUrl = absUrl(`/goals/${shortId}`);
const replyUrl = `${goalUrl}#comment-${commentId}`;
const subject = `ℹ️ Goal state was changed with a comment on #${shortId}`;
const html = md.render(`
🧑‍💻 **${author}** changed goal state on **[${shortId}: ${title}](${goalUrl})** from ~~\`${stateTitleBefore}\`~~ to \`${stateTitleAfter}\`.
${renderQuote(body)}
📍 [Jump to the comment](${replyUrl}).
${renderNotice()}
${renderFooter()}`);

return {
to,
subject,
html: withBaseTmplStyles(html),
text: subject,
};
};

interface GoalUpdatedEmailProps {
to: SendMailProps['to'];
shortId: string;
title: string;
updatedFields: {
title?: FieldDiff;
description?: FieldDiff;
estimate?: FieldDiff;
priority?: FieldDiff;
};
author?: string;
}

export const goalUpdated = async ({
to,
shortId,
title,
updatedFields,
author = 'Somebody',
}: GoalUpdatedEmailProps) => {
const subject = `ℹ️ Goal #${shortId} was updated`;
const html = md.render(`
🧑‍💻 **${author}** updated goal **[${shortId}: ${title}](${absUrl(`/goals/${shortId}`)})**.
// TODO: must be solved automatically
export interface EmailTemplatesPropsMap {
newComment: NewCommentEmailProps;
${
updatedFields.title
? `
Title:
\`\`\` diff
- ${updatedFields.title[0]}
+ ${updatedFields.title[1]}
\`\`\`
`
: ''
}
${
updatedFields.description
? `
Description:
\`\`\` diff
- ${updatedFields.description[0]}
+ ${updatedFields.description[1]}
\`\`\`
`
: ''
}
${
updatedFields.priority
? `
Priority:
\`\`\` diff
- ${updatedFields.priority[0]}
+ ${updatedFields.priority[1]}
\`\`\`
`
: ''
}
${renderNotice()}
${renderFooter()}`);

return {
to,
subject,
html: withBaseTmplStyles(html),
text: subject,
};
};

interface GoalArchivedEmailProps {
to: SendMailProps['to'];
shortId: string;
title: string;
author?: string;
}

export const goalArchived = async ({ to, shortId, title, author = 'Somebody' }: GoalArchivedEmailProps) => {
const subject = `ℹ️ Goal #${shortId} was archived`;
const html = md.render(`
🧑‍💻 **${author}** archived goal **[${shortId}: ${title}](${absUrl(`/goals/${shortId}`)})**.
${renderNotice()}
${renderFooter()}`);

return {
to,
subject,
html: withBaseTmplStyles(html),
text: subject,
};
};

interface GoalAssignedEmailProps {
to: SendMailProps['to'];
shortId: string;
title: string;
author?: string;
}

// TODO: send notification to issuer if he is not author of changes
export const goalAssigned = async ({ to, shortId, title, author = 'Somebody' }: GoalAssignedEmailProps) => {
const subject = `ℹ️ You was assigned to #${shortId}`;
const html = md.render(`
🧑‍💻 **${author}** assigned goal **[${shortId}: ${title}](${absUrl(
`/goals/${shortId}`,
)})** on you. Congrats and good luck! 🎉
${renderFooter()}`);

return {
to,
subject,
html: withBaseTmplStyles(html),
text: subject,
};
};

interface GoalUnassignedEmailProps {
to: SendMailProps['to'];
shortId: string;
title: string;
author?: string;
}

// TODO: send notification to issuer if he is not author of changes
export const goalUnassigned = async ({ to, shortId, title, author = 'Somebody' }: GoalUnassignedEmailProps) => {
const subject = `ℹ️ You was unassigned from #${shortId}`;
const html = md.render(`
🧑‍💻 **${author}** unassigned you from goal **[${shortId}: ${title}](${absUrl(
`/goals/${shortId}`,
)})**. So sad and c u on the next goal! 🤗
${renderFooter()}`);

return {
to,
subject,
html: withBaseTmplStyles(html),
text: subject,
};
};

interface GoalCreatedEmailProps {
to: SendMailProps['to'];
projectKey: string;
projectTitle: string;
shortId: string;
title: string;
author?: string;
}

export const goalCreated = async ({
to,
projectKey,
projectTitle,
shortId,
title,
author = 'Somebody',
}: GoalCreatedEmailProps) => {
const subject = `🎉 New goal in project #${projectKey}: ${projectTitle}`;
const html = md.render(`
🧑‍💻 **${author}** created new goal **[${shortId}: ${title}](${absUrl(
`/goals/${shortId}`,
)})** in **[#${projectKey}: ${projectTitle}](${absUrl(`/projects/${projectKey}`)})**.
${renderNotice()}
${renderFooter()}`);

return {
to,
subject,
html: withBaseTmplStyles(html),
text: subject,
};
};
Loading

0 comments on commit ff8da02

Please sign in to comment.