Skip to content

Commit 76c6988

Browse files
authored
feat/add: добавить модуль TeamInvitationsModule — приглашения (#120) (#215)
* feat(pack): добавить схему для отправки приглашения в команду * feat(api): добавить DTO для отправки приглашений и ответа на них * feat(api): добавить endpoint для управления приглашениями в команду * refactor(api): обновить RolesGuard для поддержки разных параметров команды * feat(api): добавить защиту и подключить модуль приглашений в команды * feat(api): добавить email шаблон * feat(api): добавить TeamInvitationsService * feat(api): добавить Cron * test(api): добавить unit тесты * test(api): добавить e2e тесты * chore(docs): обновить документацию * refactor(repo): зафиксировать версию typescript на всём проекте
1 parent a0a1edb commit 76c6988

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

41 files changed

+2191
-604
lines changed

.vscode/settings.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
{
2-
"liveServer.settings.port": 5501
2+
"liveServer.settings.port": 5501,
3+
"typescript.enablePromptUseWorkspaceTsdk": true,
4+
"typescript.tsdk": "node_modules/typescript/lib"
35
}

apps/api/.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ DIRECT_URL="postgresql://postgres:password@host:port/mydb"
1717

1818
# Node Environment
1919
NODE_ENV="development"
20+
WEB_APP_URL=http://localhost:3001
2021

2122
# Mail configuration
2223
RESEND_API_KEY=your_api_key

apps/api/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@nestjs/jwt": "^11.0.2",
3030
"@nestjs/passport": "^11.0.5",
3131
"@nestjs/platform-express": "^11.1.11",
32+
"@nestjs/schedule": "^6.1.1",
3233
"@nestjs/swagger": "^11.2.6",
3334
"@prisma/adapter-pg": "^7.4.0",
3435
"@prisma/client": "^7.4.0",
@@ -76,7 +77,7 @@
7677
"ts-loader": "^9.5.4",
7778
"ts-node": "^10.9.2",
7879
"tsconfig-paths": "^4.2.0",
79-
"typescript": "5.5.4",
80+
"typescript": "5.9.3",
8081
"unplugin-swc": "^1.5.9",
8182
"vite-tsconfig-paths": "^6.1.1",
8283
"vitest": "^4.0.18"

apps/api/src/app.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Module } from '@nestjs/common'
22
import { APP_PIPE, APP_INTERCEPTOR } from '@nestjs/core'
3+
import { ScheduleModule } from '@nestjs/schedule'
34
import { ZodSerializerInterceptor } from 'nestjs-zod'
45

56
import { AppService } from './app.service'
@@ -15,6 +16,7 @@ import { RedisModule } from './common/redis/redis.module'
1516
@Module({
1617
imports: [
1718
ConfigModule.forRoot({ isGlobal: true }),
19+
ScheduleModule.forRoot(),
1820
PrismaModule,
1921
RedisModule,
2022
AuthModule,
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export const TEAM_INVITATION_EXPIRES_IN_HOURS = 48
2+
export const TEAM_INVITATION_EXPIRES_IN_LABEL = '48 часов'
3+
export const TEAM_INVITATION_EMAIL_SUBJECT = 'Вас пригласили в команду в Tracker Task'
4+
export const TEAM_INVITATION_EXPIRE_CRON = '0 */6 * * *'
5+
6+
export const INVITATION_ERROR_MESSAGES = {
7+
TEAM_NOT_FOUND: 'Команда не найдена',
8+
MANAGE_FORBIDDEN: 'Недостаточно прав для управления приглашениями',
9+
ALREADY_MEMBER: 'Пользователь уже состоит в команде',
10+
DUPLICATE_PENDING: 'Для этого email уже есть активное приглашение в команду',
11+
INVITATION_NOT_FOUND: 'Приглашение не найдено',
12+
INVITATION_ALREADY_PROCESSED: 'Приглашение уже обработано',
13+
INVITATION_EXPIRED: 'Срок действия приглашения истёк',
14+
INVITATION_EMAIL_MISMATCH: 'Это приглашение предназначено для другого email',
15+
} as const
16+
17+
export const INVITATION_LOG_MESSAGES = {
18+
SEND_EMAIL_FAILED: 'Не удалось отправить письмо с приглашением в команду',
19+
} as const

apps/api/src/guards/roles.guard.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ export class RolesGuard implements CanActivate {
1717
private readonly prisma: PrismaService,
1818
) {}
1919

20+
private getTeamId(request: Request): string | null {
21+
return request.params['teamId'] ?? request.params['id'] ?? null
22+
}
23+
2024
async canActivate(context: ExecutionContext): Promise<boolean> {
2125
const requiredRoles = this.reflector.getAllAndOverride<TeamRole[]>(ROLES_KEY, [
2226
context.getHandler(),
@@ -29,7 +33,7 @@ export class RolesGuard implements CanActivate {
2933

3034
const request = context.switchToHttp().getRequest<Request>()
3135
const user = request.user as User
32-
const teamId = request.params['teamId']
36+
const teamId = this.getTeamId(request)
3337

3438
if (!user || !teamId) {
3539
throw new ForbiddenException('Недостаточно прав')

apps/api/src/mail/mail.service.ts

Lines changed: 38 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import type { MailProvider } from './mail.provider'
33
import { MAIL_PROVIDER } from './mail.constants'
44
import { ConfigService } from '@nestjs/config'
55
import { render } from '@react-email/render'
6+
import {
7+
TEAM_INVITATION_EMAIL_SUBJECT,
8+
TEAM_INVITATION_EXPIRES_IN_LABEL,
9+
} from '../common/constants/invitations.constants'
10+
import { TeamInvitationEmail } from './templates/team-invitation.email'
611
import { WELCOME_EMAIL_SUBJECT, WelcomeEmail } from './templates/welcome.email'
712

813
@Injectable()
@@ -13,17 +18,48 @@ export class MailService {
1318
private readonly configService: ConfigService,
1419
) {}
1520

16-
async sendWelcomeEmail(email: string, name: string) {
21+
private getMailFrom() {
1722
const mailName = this.configService.getOrThrow('MAIL_FROM_NAME')
1823
const mailAddress = this.configService.getOrThrow('MAIL_FROM')
1924

25+
return `${mailName} <${mailAddress}>`
26+
}
27+
28+
async sendWelcomeEmail(email: string, name: string) {
2029
const html = await render(WelcomeEmail({ name }))
2130

2231
await this.mailProvider.send({
23-
from: `${mailName} <${mailAddress}>`,
32+
from: this.getMailFrom(),
2433
to: email,
2534
subject: WELCOME_EMAIL_SUBJECT,
2635
html,
2736
})
2837
}
38+
39+
async sendTeamInvitationEmail(
40+
email: string,
41+
teamName: string,
42+
inviterName: string | null,
43+
token: string,
44+
) {
45+
const webAppUrl = this.configService.getOrThrow<string>('WEB_APP_URL')
46+
const invitationLink = new URL(`/invitations/${token}`, webAppUrl).toString()
47+
const inviterDisplayName = inviterName ?? 'Участник команды'
48+
49+
const html = await render(
50+
TeamInvitationEmail({
51+
teamName,
52+
inviterName: inviterDisplayName,
53+
invitationLink,
54+
expiresIn: TEAM_INVITATION_EXPIRES_IN_LABEL,
55+
}),
56+
)
57+
58+
await this.mailProvider.send({
59+
from: this.getMailFrom(),
60+
to: email,
61+
subject: TEAM_INVITATION_EMAIL_SUBJECT,
62+
html,
63+
})
64+
}
2965
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import {
2+
Body,
3+
Button,
4+
Container,
5+
Head,
6+
Heading,
7+
Hr,
8+
Html,
9+
Preview,
10+
Section,
11+
Text,
12+
} from '@react-email/components'
13+
14+
interface Props {
15+
teamName: string
16+
inviterName: string
17+
invitationLink: string
18+
expiresIn: string
19+
}
20+
21+
export const TeamInvitationEmail = ({
22+
teamName,
23+
inviterName,
24+
invitationLink,
25+
expiresIn,
26+
}: Props) => (
27+
<Html lang='ru'>
28+
<Head />
29+
<Preview>Приглашение в команду {teamName} ждёт вашего подтверждения</Preview>
30+
<Body style={body}>
31+
<Container style={container}>
32+
<Section style={logoSection}>
33+
<Text style={logo}>Tracker Task</Text>
34+
</Section>
35+
36+
<Section style={content}>
37+
<Heading style={heading}>Приглашение в команду</Heading>
38+
39+
<Text style={paragraph}>
40+
{inviterName} приглашает вас присоединиться к команде {teamName}.
41+
</Text>
42+
43+
<Text style={paragraph}>
44+
Откройте приглашение по кнопке ниже. Оно действует {expiresIn}.
45+
</Text>
46+
47+
<Section style={buttonSection}>
48+
<Button style={button} href={invitationLink}>
49+
Открыть приглашение
50+
</Button>
51+
</Section>
52+
53+
<Text style={linkText}>{invitationLink}</Text>
54+
55+
<Hr style={hr} />
56+
57+
<Text style={footer}>
58+
Если вы не ожидали это письмо, просто проигнорируйте его.
59+
</Text>
60+
</Section>
61+
</Container>
62+
</Body>
63+
</Html>
64+
)
65+
66+
const body = {
67+
backgroundColor: '#f6f9fc',
68+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
69+
}
70+
71+
const container = {
72+
margin: '40px auto',
73+
maxWidth: '560px',
74+
}
75+
76+
const logoSection = {
77+
padding: '32px 40px 0',
78+
}
79+
80+
const logo = {
81+
fontSize: '20px',
82+
fontWeight: '700',
83+
color: '#1a1a1a',
84+
margin: '0',
85+
}
86+
87+
const content = {
88+
backgroundColor: '#ffffff',
89+
borderRadius: '12px',
90+
padding: '40px',
91+
marginTop: '16px',
92+
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
93+
}
94+
95+
const heading = {
96+
fontSize: '24px',
97+
fontWeight: '700',
98+
color: '#1a1a1a',
99+
margin: '0 0 20px',
100+
}
101+
102+
const paragraph = {
103+
fontSize: '16px',
104+
lineHeight: '24px',
105+
color: '#4a5568',
106+
margin: '0 0 16px',
107+
}
108+
109+
const buttonSection = {
110+
margin: '28px 0',
111+
}
112+
113+
const button = {
114+
backgroundColor: '#1a1a1a',
115+
borderRadius: '8px',
116+
color: '#ffffff',
117+
fontSize: '15px',
118+
fontWeight: '600',
119+
padding: '14px 28px',
120+
textDecoration: 'none',
121+
display: 'inline-block',
122+
}
123+
124+
const linkText = {
125+
fontSize: '13px',
126+
lineHeight: '20px',
127+
color: '#718096',
128+
wordBreak: 'break-all' as const,
129+
margin: '0 0 16px',
130+
}
131+
132+
const hr = {
133+
border: 'none',
134+
borderTop: '1px solid #e2e8f0',
135+
margin: '28px 0 20px',
136+
}
137+
138+
const footer = {
139+
fontSize: '13px',
140+
color: '#a0aec0',
141+
margin: '0',
142+
}
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'
2+
import { TeamRole } from '@repo/types'
3+
4+
export class InvitationInvitedByResponse {
5+
@ApiProperty({ example: 'uuid-user-1' })
6+
id: string
7+
8+
@ApiProperty({ example: 'Иван Иванов', nullable: true })
9+
name: string | null
10+
11+
@ApiProperty({ example: 'owner@example.com' })
12+
email: string
13+
}
14+
15+
export class InvitationTeamSummaryResponse {
16+
@ApiProperty({ example: 'uuid-team-1' })
17+
id: string
18+
19+
@ApiProperty({ example: 'Dream Team' })
20+
name: string
21+
22+
@ApiPropertyOptional({ example: null, nullable: true })
23+
avatarUrl: string | null
24+
}
25+
26+
export class TeamInvitationResponse {
27+
@ApiProperty({ example: 'uuid-invitation-1' })
28+
id: string
29+
30+
@ApiProperty({ example: 'uuid-team-1' })
31+
teamId: string
32+
33+
@ApiProperty({ example: 'uuid-user-1' })
34+
invitedById: string
35+
36+
@ApiProperty({ example: 'user@example.com' })
37+
email: string
38+
39+
@ApiProperty({ enum: ['OWNER', 'ADMIN', 'MEMBER'], example: 'MEMBER' })
40+
role: TeamRole
41+
42+
@ApiProperty({
43+
enum: ['PENDING', 'ACCEPTED', 'DECLINED', 'EXPIRED'],
44+
example: 'PENDING',
45+
})
46+
status: 'PENDING' | 'ACCEPTED' | 'DECLINED' | 'EXPIRED'
47+
48+
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
49+
token: string
50+
51+
@ApiProperty({ example: '2026-04-11T12:00:00.000Z' })
52+
expiresAt: Date
53+
54+
@ApiProperty({ example: '2026-04-09T12:00:00.000Z' })
55+
createdAt: Date
56+
57+
@ApiProperty({ example: '2026-04-09T12:00:00.000Z' })
58+
updatedAt: Date
59+
60+
@ApiPropertyOptional({ type: InvitationInvitedByResponse })
61+
invitedBy?: InvitationInvitedByResponse
62+
63+
@ApiPropertyOptional({ type: InvitationTeamSummaryResponse })
64+
team?: InvitationTeamSummaryResponse
65+
}
66+
67+
export class MyInvitationResponse {
68+
@ApiProperty({ example: 'uuid-invitation-1' })
69+
id: string
70+
71+
@ApiProperty({ example: 'user@example.com' })
72+
email: string
73+
74+
@ApiProperty({ enum: ['OWNER', 'ADMIN', 'MEMBER'], example: 'MEMBER' })
75+
role: TeamRole
76+
77+
@ApiProperty({ example: '550e8400-e29b-41d4-a716-446655440000' })
78+
token: string
79+
80+
@ApiProperty({ example: '2026-04-11T12:00:00.000Z' })
81+
expiresAt: Date
82+
83+
@ApiProperty({ example: '2026-04-09T12:00:00.000Z' })
84+
createdAt: Date
85+
86+
@ApiProperty({ type: InvitationTeamSummaryResponse })
87+
team: InvitationTeamSummaryResponse
88+
89+
@ApiProperty({ type: InvitationInvitedByResponse })
90+
invitedBy: InvitationInvitedByResponse
91+
}

0 commit comments

Comments
 (0)