From 4fde81dfbee2e6b8a55ed7ff8ce64e9e43873930 Mon Sep 17 00:00:00 2001 From: Anton Sabotovich Date: Thu, 27 Jun 2024 20:09:23 +0300 Subject: [PATCH] feat: support email notification --- .../FormControlEditor/FormControlEditor.tsx | 2 +- .../ProjectTeamPage/ProjectTeamPage.tsx | 2 +- .../TeamMemberListItem/TeamMemberListItem.tsx | 2 +- src/components/UserDropdown/UserDropdown.tsx | 32 ++- src/hooks/useUserResource.ts | 17 +- src/utils/crew.ts | 13 ++ src/utils/db/createComment.ts | 14 ++ src/utils/db/crewIntegration.ts | 206 ++++++++++++++++++ src/{types/crew.ts => utils/db/types.ts} | 0 src/utils/getUserName.ts | 3 + src/utils/url.ts | 8 + src/utils/worker/mail/templates.ts | 58 +++++ trpc/router/crew.ts | 15 +- trpc/router/goal.ts | 12 + trpc/router/user.ts | 48 +--- 15 files changed, 358 insertions(+), 74 deletions(-) create mode 100644 src/utils/db/crewIntegration.ts rename src/{types/crew.ts => utils/db/types.ts} (100%) create mode 100644 src/utils/url.ts diff --git a/src/components/FormControlEditor/FormControlEditor.tsx b/src/components/FormControlEditor/FormControlEditor.tsx index 659a3cd95..ab90f559c 100644 --- a/src/components/FormControlEditor/FormControlEditor.tsx +++ b/src/components/FormControlEditor/FormControlEditor.tsx @@ -54,7 +54,7 @@ export const FormControlEditor = React.forwardRef {} + export interface UserDropdownValue { id: string; user?: UserValue; @@ -49,9 +47,9 @@ export const UserDropdown = ({ }: UserDropdownProps) => { const [inputState, setInputState] = useState(query); - const { createUserByCrew } = useUserResource(); + const { getUsersByCrew } = useUserResource(); - const { data: crewUsers } = trpc.crew.getUsers.useQuery( + const { data: crewUsers } = trpc.crew.searchUsers.useQuery( { query: inputState, filter }, { enabled: inputState.length >= 2, @@ -88,12 +86,22 @@ export const UserDropdown = ({ if (!lastAddedUser?.user) return; - const goalsUser = await createUserByCrew(lastAddedUser.user); + const { + items: [{ user: goalsUser }], + } = await getUsersByCrew([lastAddedUser.user]); + + if (!goalsUser?.activityId) { + return; + } const newUser = { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - id: goalsUser.activityId!, - user: goalsUser, + id: goalsUser.activityId, + user: { + id: goalsUser.id, + email: goalsUser.email, + name: goalsUser.name || undefined, + image: goalsUser.image || undefined, + }, }; if (mode === 'single') { @@ -105,7 +113,7 @@ export const UserDropdown = ({ onChange?.([...crewUsers, newUser]); }, - [createUserByCrew, mode, onChange], + [getUsersByCrew, mode, onChange], ); return ( diff --git a/src/hooks/useUserResource.ts b/src/hooks/useUserResource.ts index c9b6e8bbf..8f9637422 100644 --- a/src/hooks/useUserResource.ts +++ b/src/hooks/useUserResource.ts @@ -1,22 +1,17 @@ import { useCallback } from 'react'; import { trpc } from '../utils/trpcClient'; - -interface CrewUser { - email: string; - name?: string; - login?: string; -} +import { CrewUser } from '../utils/db/types'; export const useUserResource = () => { - const createUserMutation = trpc.user.сreateUserByCrew.useMutation(); + const getUsersByCrewMutation = trpc.user.getLocalUsersByCrew.useMutation(); - const createUserByCrew = useCallback( - async (user: CrewUser) => createUserMutation.mutateAsync(user), - [createUserMutation], + const getUsersByCrew = useCallback( + async (users: CrewUser[]) => getUsersByCrewMutation.mutateAsync(users), + [getUsersByCrewMutation], ); return { - createUserByCrew, + getUsersByCrew, }; }; diff --git a/src/utils/crew.ts b/src/utils/crew.ts index 436582394..dd1d0215c 100644 --- a/src/utils/crew.ts +++ b/src/utils/crew.ts @@ -1,5 +1,18 @@ +import { getUrlsFromString } from './url'; + const crewLinkRegExp = new RegExp(`^${process.env.NEXT_PUBLIC_CREW_URL}(ru/|en/)?(?[^/]+)/?$`); export const parseCrewLink = (link: string): string => { return link.match(crewLinkRegExp)?.groups?.login ?? ''; }; + +export const parseCrewLoginFromText = (text: string) => + getUrlsFromString(text).reduce((acum, url) => { + const login = parseCrewLink(url); + + if (login) { + acum.push(login); + } + + return acum; + }, []); diff --git a/src/utils/db/createComment.ts b/src/utils/db/createComment.ts index 464527998..80560c937 100644 --- a/src/utils/db/createComment.ts +++ b/src/utils/db/createComment.ts @@ -4,9 +4,11 @@ import { prisma } from '../prisma'; import { goalIncludeCriteriaParams, recalculateCriteriaScore } from '../recalculateCriteriaScore'; import { prepareRecipients } from '../prepareRecipients'; import { createEmail } from '../createEmail'; +import { parseCrewLoginFromText } from '../crew'; import { updateProjectUpdatedAt } from './updateProjectUpdatedAt'; import { addCalculatedGoalsFields } from './calculatedGoalsFields'; +import { getLocalUsersByCrewLogin } from './crewIntegration'; export const createComment = async ({ description, @@ -147,6 +149,18 @@ export const createComment = async ({ body: newComment.description, }); } + + const { items: localUsers } = await getLocalUsersByCrewLogin(parseCrewLoginFromText(description)); + + await createEmail('mentionedInComment', { + to: await prepareRecipients(localUsers), + shortId: _shortId, + title: actualGoal.title, + commentId: newComment.id, + author: newComment.activity.user?.name || newComment.activity.user?.email, + authorEmail: newComment.activity.user?.email, + body: newComment.description, + }); } return newComment; diff --git a/src/utils/db/crewIntegration.ts b/src/utils/db/crewIntegration.ts new file mode 100644 index 000000000..091bb5486 --- /dev/null +++ b/src/utils/db/crewIntegration.ts @@ -0,0 +1,206 @@ +import { Activity, User } from '@prisma/client'; +import { TRPCError } from '@trpc/server'; + +import { prisma } from '../prisma'; + +import { CrewUser } from './types'; + +export const getToken = () => { + const authorization = process.env.CREW_API_TOKEN; + + if (!authorization) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'No api token for crew' }); + } + + return authorization; +}; + +type UserActivity = Activity & { user?: User | null }; + +export const getLocalUsersByCrew = async (crewUsers: CrewUser[]) => { + const logins = crewUsers.reduce((acum, { login }) => { + if (login) { + acum.push(login); + } + + return acum; + }, []); + + const emails = crewUsers.map(({ email }) => email); + + const existedActivities = await prisma.user.findMany({ + where: { + OR: [ + { + nickname: { + in: logins, + }, + }, + { + email: { + in: emails, + }, + }, + ], + }, + include: { + activity: { + include: { + user: true, + ghost: true, + }, + }, + }, + }); + + const activityMap = existedActivities.reduce<{ + byEmail: Record; + byLogin: Record; + }>( + (acum, { nickname, email, activity }) => { + if (activity) { + if (nickname) { + acum.byLogin[nickname] = activity; + } + acum.byEmail[email] = activity; + } + + return acum; + }, + { + byEmail: {}, + byLogin: {}, + }, + ); + + const newCrewUsers = crewUsers.filter(({ login, email }) => { + const hasLogin = login && activityMap.byLogin[login]; + const hasEmail = activityMap.byEmail[email]; + + return !hasLogin && !hasEmail; + }); + + const newActivities = await prisma.$transaction( + newCrewUsers.map((item) => + prisma.user.create({ + data: { + email: item.email, + name: item.name, + nickname: item.login, + activity: { + create: { + settings: { + create: {}, + }, + }, + }, + }, + include: { + activity: { + include: { user: true, ghost: true }, + }, + }, + }), + ), + ); + + newActivities.forEach(({ activity, email }) => { + if (activity) { + activityMap.byEmail[email] = activity; + } + }); + + return crewUsers.reduce<{ + items: UserActivity[]; + activityByCrewId: Record; + }>( + (acum, item) => { + const activity = (item.login && activityMap.byLogin[item.login]) || activityMap.byEmail[item.email]; + + acum.items.push(activity); + acum.activityByCrewId[item.id] = activity; + + return acum; + }, + { + items: [], + activityByCrewId: {}, + }, + ); +}; + +export const getCrewUserByLogin = async (login: string) => { + if (!process.env.NEXT_PUBLIC_CREW_URL) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: 'No crew integration url provided' }); + } + + const response = await fetch( + `${process.env.NEXT_PUBLIC_CREW_URL}/api/rest/users/get-by-field?${new URLSearchParams({ + login, + })}`, + { + method: 'GET', + headers: { + authorization: getToken(), + 'Content-Type': 'application/json', + }, + }, + ); + + if (!response.ok) { + throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR', message: response.statusText }); + } + + const data: CrewUser = await response.json(); + + return data; +}; + +export const getCrewUsersByLogin = async (logins: string[]) => { + if (!process.env.NEXT_PUBLIC_CREW_URL) { + return []; + } + + return Promise.all(logins.map((login) => getCrewUserByLogin(login))); +}; + +export const getLocalUsersByCrewLogin = async (logins: string[]) => { + const localUsers = await prisma.user.findMany({ + where: { + nickname: { + in: logins, + }, + }, + include: { + activity: { + include: { + user: true, + ghost: true, + }, + }, + }, + }); + + const userByLogin = localUsers.reduce>((acum, u) => { + if (u.activity && u.nickname) { + acum[u.nickname] = u.activity; + } + return acum; + }, {}); + + const newCrewUsers = await getCrewUsersByLogin(logins.filter((login) => !userByLogin[login])); + const { activityByCrewId } = await getLocalUsersByCrew(newCrewUsers); + + newCrewUsers.forEach(({ login, id }) => { + const localUser = activityByCrewId[id]; + + if (login && localUser) { + userByLogin[login] = localUser; + } + }); + + return { + items: logins.map((login) => userByLogin[login]), + userByLogin, + }; +}; diff --git a/src/types/crew.ts b/src/utils/db/types.ts similarity index 100% rename from src/types/crew.ts rename to src/utils/db/types.ts diff --git a/src/utils/getUserName.ts b/src/utils/getUserName.ts index 410e134f4..7adec9421 100644 --- a/src/utils/getUserName.ts +++ b/src/utils/getUserName.ts @@ -1,4 +1,5 @@ export interface UserData { + id: string; email: string; name?: string | null; nickname?: string | null; @@ -38,6 +39,7 @@ export const prepareUserDataFromActivity = < return { ...target, + id: target.id, email: target.email, name: target.name, nickname: target.nickname, @@ -95,6 +97,7 @@ export const safeUserData = { + const urls = data.match(urlRegExp) ?? []; + + return urls.map((url) => url.replace(/\.$/g, '')); +}; diff --git a/src/utils/worker/mail/templates.ts b/src/utils/worker/mail/templates.ts index 12f305bbf..ca194136b 100644 --- a/src/utils/worker/mail/templates.ts +++ b/src/utils/worker/mail/templates.ts @@ -619,3 +619,61 @@ ${footer}`); text: subject, }; }; + +interface GoalAssignedEmailProps { + to: SendMailProps['to']; + shortId: string; + commentId: string; + body: string; + title: string; + authorEmail: string; + author?: string; +} + +export const mentionedInComment = async ({ + to, + author = 'Somebody', + shortId, + commentId, + title, + body, +}: GoalAssignedEmailProps) => { + const goalUrl = absUrl(`/goals/${shortId}`); + const replyUrl = `${goalUrl}#comment-${commentId}`; + const subject = `🧑‍💻 ${author} mention you on #${shortId}`; + const html = md.render(` + 🧑‍💻 **${author}** mention on comment to **[${shortId}: ${title}](${replyUrl})**: + + ${renderQuote(body)} + + 🗣 [Reply](${replyUrl}) to this comment. + + ${footer}`); + + return { + to, + subject, + html: withBaseTmplStyles(html), + text: subject, + }; +}; + +export const mentionedInGoal = async ({ to, author = 'Somebody', shortId, title, body }: GoalAssignedEmailProps) => { + const goalUrl = absUrl(`/goals/${shortId}`); + const subject = `🧑‍💻 ${author} mention you on #${shortId}`; + const html = md.render(` + 🧑‍💻 **${author}** mention on **[${shortId}: ${title}](${goalUrl})**: + + ${renderQuote(body)} + + ${notice} + + ${footer}`); + + return { + to, + subject, + html: withBaseTmplStyles(html), + text: subject, + }; +}; diff --git a/trpc/router/crew.ts b/trpc/router/crew.ts index 12aff071a..a15e8f6f1 100644 --- a/trpc/router/crew.ts +++ b/trpc/router/crew.ts @@ -2,19 +2,10 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { getGroupListSchema } from '../../src/schema/crew'; -import { CrewUser, Team } from '../../src/types/crew'; +import { Team, CrewUser } from '../../src/utils/db/types'; import { protectedProcedure, router } from '../trpcBackend'; import { prisma } from '../../src/utils/prisma'; - -const getToken = () => { - const authorization = process.env.CREW_API_TOKEN; - - if (!authorization) { - throw new TRPCError({ code: 'FORBIDDEN', message: 'No api token for crew' }); - } - - return authorization; -}; +import { getToken } from '../../src/utils/db/crewIntegration'; export const crew = router({ teamSuggetions: protectedProcedure.input(getGroupListSchema).query(async ({ input }) => { @@ -60,7 +51,7 @@ export const crew = router({ }; }); }), - getUsers: protectedProcedure + searchUsers: protectedProcedure .input( z.object({ query: z.string(), diff --git a/trpc/router/goal.ts b/trpc/router/goal.ts index a6147af78..e3243ec3c 100644 --- a/trpc/router/goal.ts +++ b/trpc/router/goal.ts @@ -56,6 +56,8 @@ import { prepareRecipients } from '../../src/utils/prepareRecipients'; import { updateProjectUpdatedAt } from '../../src/utils/db/updateProjectUpdatedAt'; import { addCalculatedGoalsFields } from '../../src/utils/db/calculatedGoalsFields'; import { createComment } from '../../src/utils/db/createComment'; +import { parseCrewLoginFromText } from '../../src/utils/crew'; +import { getLocalUsersByCrewLogin } from '../../src/utils/db/crewIntegration'; import { getShortId } from '../../src/utils/getShortId'; import { criteriaQuery } from '../queries/criteria'; import { extendQuery } from '../utils'; @@ -407,7 +409,17 @@ export const goal = router({ actualProject.activity, ]); + const { items: localUsers } = await getLocalUsersByCrewLogin(parseCrewLoginFromText(newGoal.description)); + await Promise.all([ + createEmail('mentionedInGoal', { + to: await prepareRecipients(localUsers), + shortId: newGoal._shortId, + title: newGoal.title, + author: ctx.session.user.name || ctx.session.user.email, + authorEmail: ctx.session.user.email, + body: newGoal.description, + }), createEmail('goalCreated', { to: recipients, projectKey: actualProject.id, diff --git a/trpc/router/user.ts b/trpc/router/user.ts index 7c2bad622..aa9608d8a 100644 --- a/trpc/router/user.ts +++ b/trpc/router/user.ts @@ -5,6 +5,7 @@ import { prisma } from '../../src/utils/prisma'; import { protectedProcedure, router } from '../trpcBackend'; import { settingsUserSchema, suggestionsUserSchema, updateUserSchema } from '../../src/schema/user'; import { safeUserData } from '../../src/utils/getUserName'; +import { getLocalUsersByCrew } from '../../src/utils/db/crewIntegration'; export const user = router({ suggestions: protectedProcedure @@ -169,46 +170,21 @@ export const user = router({ return users.reduce<{ id: string; user: NonNullable> }[]>((acc, cur) => { const userData = safeUserData(cur.activity); + if (userData && cur.activity) acc.push({ id: cur.activity.id, user: userData }); return acc; }, []); }), - сreateUserByCrew: protectedProcedure + getLocalUsersByCrew: protectedProcedure .input( - z.object({ - email: z.string(), - name: z.string().optional(), - login: z.string().optional(), - }), + z.array( + z.object({ + email: z.string(), + name: z.string().optional(), + login: z.string().optional(), + id: z.string(), + }), + ), ) - .mutation(async ({ input }) => { - const user = await prisma.user.findFirst({ - where: { - OR: [{ nickname: input.login }, { email: input.email }], - }, - }); - - if (user) return { ...user, name: user.name || undefined, image: user.image || undefined }; - - const newUser = await prisma.user.create({ - data: { - email: input.email, - name: input.name, - nickname: input.login, - activity: { - create: { - settings: { - create: {}, - }, - }, - }, - }, - }); - - return { - ...newUser, - name: newUser.name || undefined, - image: newUser.image || undefined, - }; - }), + .mutation(async ({ input }) => getLocalUsersByCrew(input)), });