Skip to content

Commit

Permalink
feat: support email notification
Browse files Browse the repository at this point in the history
  • Loading branch information
asabotovich committed Jul 2, 2024
1 parent 2b88aef commit 4fde81d
Show file tree
Hide file tree
Showing 15 changed files with 358 additions and 74 deletions.
2 changes: 1 addition & 1 deletion src/components/FormControlEditor/FormControlEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export const FormControlEditor = React.forwardRef<HTMLDivElement, React.Componen
return emptySuggestions;
}

const users = await utils.crew.getUsers.fetch({
const users = await utils.crew.searchUsers.fetch({
query,
});

Expand Down
2 changes: 1 addition & 1 deletion src/components/ProjectTeamPage/ProjectTeamPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { nullable } from '@taskany/bricks';
import { Link, Table, TreeView, TreeViewElement, TreeViewNode } from '@taskany/bricks/harmony';
import { useCallback, useMemo } from 'react';

import { Team } from '../../utils/db/types';
import { ExternalPageProps } from '../../utils/declareSsrProps';
import { routes } from '../../hooks/router';
import { useProjectResource } from '../../hooks/useProjectResource';
Expand All @@ -13,7 +14,6 @@ import { TeamListItem } from '../TeamListItem/TeamListItem';
import { TeamComboBox } from '../TeamComboBox/TeamComboBox';
import { CommonHeader } from '../CommonHeader';
import { TeamMemberListItem } from '../TeamMemberListItem/TeamMemberListItem';
import { Team } from '../../types/crew';
import { ProjectContext } from '../ProjectContext/ProjectContext';

import { tr } from './ProjectTeamPage.i18n';
Expand Down
2 changes: 1 addition & 1 deletion src/components/TeamMemberListItem/TeamMemberListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { FC } from 'react';
import { CircleProgressBar, Text } from '@taskany/bricks/harmony';
import { nullable } from '@taskany/bricks';

import { CrewUserRole } from '../../types/crew';
import { CrewUserRole } from '../../utils/db/types';
import { UserBadge } from '../UserBadge/UserBadge';
import { TableListItem, TableListItemElement } from '../TableListItem/TableListItem';

Expand Down
32 changes: 20 additions & 12 deletions src/components/UserDropdown/UserDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,13 @@ import { safeUserData } from '../../utils/getUserName';
import { trpc } from '../../utils/trpcClient';
import { Dropdown, DropdownTrigger, DropdownPanel, DropdownGuardedProps } from '../Dropdown/Dropdown';
import { useUserResource } from '../../hooks/useUserResource';
import { CrewUser } from '../../utils/db/types';

import s from './UserDropdown.module.css';
import { tr } from './UserDropdown.i18n';

interface UserValue {
name?: string;
email: string;
image?: string;
}
interface UserValue extends Omit<CrewUser, 'login'> {}

export interface UserDropdownValue {
id: string;
user?: UserValue;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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') {
Expand All @@ -105,7 +113,7 @@ export const UserDropdown = ({

onChange?.([...crewUsers, newUser]);
},
[createUserByCrew, mode, onChange],
[getUsersByCrew, mode, onChange],
);

return (
Expand Down
17 changes: 6 additions & 11 deletions src/hooks/useUserResource.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
13 changes: 13 additions & 0 deletions src/utils/crew.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { getUrlsFromString } from './url';

const crewLinkRegExp = new RegExp(`^${process.env.NEXT_PUBLIC_CREW_URL}(ru/|en/)?(?<login>[^/]+)/?$`);

export const parseCrewLink = (link: string): string => {
return link.match(crewLinkRegExp)?.groups?.login ?? '';
};

export const parseCrewLoginFromText = (text: string) =>
getUrlsFromString(text).reduce<string[]>((acum, url) => {
const login = parseCrewLink(url);

if (login) {
acum.push(login);
}

return acum;
}, []);
14 changes: 14 additions & 0 deletions src/utils/db/createComment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
206 changes: 206 additions & 0 deletions src/utils/db/crewIntegration.ts
Original file line number Diff line number Diff line change
@@ -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<string[]>((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<string, UserActivity>;
byLogin: Record<string, UserActivity>;
}>(
(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<string, UserActivity>;
}>(
(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<Record<string, UserActivity>>((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,
};
};
File renamed without changes.
Loading

0 comments on commit 4fde81d

Please sign in to comment.