• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

inclusion-numerique / la-base / 3874a187-d8d7-431d-9609-c924d5a40fa8

08 Apr 2026 03:25PM UTC coverage: 8.349% (-0.01%) from 8.36%
3874a187-d8d7-431d-9609-c924d5a40fa8

Pull #407

circleci

KGALLET
fix: email url redir
Pull Request #407: feat: add admins base notifications

280 of 6350 branches covered (4.41%)

Branch coverage included in aggregate %.

0 of 14 new or added lines in 4 files covered. (0.0%)

2 existing lines in 1 file now uncovered.

1132 of 10562 relevant lines covered (10.72%)

0.6 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

0.0
/apps/web/src/server/rpc/baseJoinRequest/baseJoinRequest.ts
1
import { baseAuthorization } from '@app/web/authorization/models/baseAuthorization'
2
import { baseAuthorizationTargetSelect } from '@app/web/authorization/models/baseAuthorizationTargetSelect'
3
import { sendBaseJoinRequestEmail } from '@app/web/features/base/join-requests/emails/sendBaseJoinRequestEmail'
4
import { sendJoinRequestAcceptedEmail } from '@app/web/features/base/join-requests/emails/sendJoinRequestAcceptedEmail'
5
import { sendJoinRequestRejectedEmail } from '@app/web/features/base/join-requests/emails/sendJoinRequestRejectedEmail'
6
import { prismaClient } from '@app/web/prismaClient'
7
import {
8
  createNotification,
9
  createNotifications,
10
} from '@app/web/server/notifications/createNotificationWithDeduplication'
11
import { protectedProcedure, router } from '@app/web/server/rpc/createRouter'
12
import {
13
  authorizeOrThrow,
14
  invalidError,
15
  notFoundError,
16
} from '@app/web/server/rpc/trpcErrors'
17
import * as Sentry from '@sentry/nextjs'
18
import z from 'zod'
19

20
export const baseJoinRequestRouter = router({
×
21
  askToJoin: protectedProcedure
22
    .input(z.object({ baseId: z.string().uuid() }))
23
    .mutation(async ({ input, ctx: { user } }) => {
24
      const base = await prismaClient.base.findUnique({
×
25
        where: { id: input.baseId },
26
        select: {
27
          id: true,
28
          title: true,
29
          slug: true,
30
          members: {
31
            select: {
32
              memberId: true,
33
            },
34
          },
35
          joinRequests: {
36
            select: {
37
              id: true,
38
              applicantId: true,
39
              accepted: true,
40
              declined: true,
41
            },
42
          },
43
        },
44
      })
45

46
      if (!base) {
×
47
        return notFoundError()
×
48
      }
49

50
      const isAlreadyMember = base.members.some(
×
51
        (member) => member.memberId === user.id,
×
52
      )
53
      if (isAlreadyMember) {
×
54
        return invalidError('Vous êtes déjà membre de cette base')
×
55
      }
56

57
      const existingRequest = base.joinRequests.find(
×
58
        (request) =>
59
          request.applicantId === user.id && request.declined !== null,
×
60
      )
61
      if (existingRequest) {
×
62
        return prismaClient.baseJoinRequest.update({
×
63
          where: { id: existingRequest.id },
64
          data: {
65
            declined: null,
66
          },
67
        })
68
      }
69

70
      const joinRequest = await prismaClient.baseJoinRequest.create({
×
71
        data: {
72
          baseId: input.baseId,
73
          applicantId: user.id,
74
        },
75
        include: {
76
          applicant: {
77
            select: {
78
              email: true,
79
              name: true,
80
              slug: true,
81
              firstName: true,
82
              lastName: true,
83
            },
84
          },
85
          base: {
86
            select: {
87
              title: true,
88
              members: {
89
                where: {
90
                  isAdmin: true,
91
                },
92
                include: {
93
                  member: {
94
                    select: {
95
                      id: true,
96
                      email: true,
97
                    },
98
                  },
99
                },
100
              },
101
            },
102
          },
103
        },
104
      })
105

106
      const emailPromises = joinRequest.base.members.map((admin) =>
×
107
        sendBaseJoinRequestEmail({
×
108
          url: `/demandes/base/${joinRequest.id}`,
109
          profileUrl: `/profils/${joinRequest.applicant.slug}`,
110
          email: admin.member.email,
111
          baseTitle: joinRequest.base.title,
112
          applicant: {
113
            slug: joinRequest.applicant.slug,
114
            name: joinRequest.applicant.name || undefined,
×
115
            firstName: joinRequest.applicant.firstName || undefined,
×
116
            lastName: joinRequest.applicant.lastName || undefined,
×
117
            email: joinRequest.applicant.email,
118
          },
119
        }).catch((error) => {
120
          // silent fail for email sending
121
          Sentry.captureException(error)
×
122
          return null
×
123
        }),
124
      )
125

126
      const notificationPromises = joinRequest.base.members.map((admin) =>
×
127
        createNotification({
×
128
          userId: admin.member.id,
129
          type: 'AskJoinBase',
130
          baseId: input.baseId,
131
          initiatorId: user.id,
132
        }),
133
      )
134
      await Promise.all([...emailPromises, ...notificationPromises])
×
135

136
      return joinRequest
×
137
    }),
138
  accept: protectedProcedure
139
    .input(z.object({ requestId: z.string().uuid() }))
140
    .mutation(async ({ input, ctx: { user } }) => {
141
      const joinRequest = await prismaClient.baseJoinRequest.findUnique({
×
142
        where: { id: input.requestId },
143
        include: {
144
          applicant: {
145
            select: {
146
              id: true,
147
              email: true,
148
              name: true,
149
              firstName: true,
150
              lastName: true,
151
            },
152
          },
153
          base: {
154
            select: {
155
              title: true,
156
              slug: true,
157
              members: {
158
                where: {
159
                  accepted: { not: null },
160
                },
161
                select: {
162
                  memberId: true,
163
                },
164
              },
165
              ...baseAuthorizationTargetSelect,
166
            },
167
          },
168
        },
169
      })
170

171
      if (!joinRequest) {
×
172
        return notFoundError()
×
173
      }
174

175
      authorizeOrThrow(
×
176
        baseAuthorization(joinRequest.base, user).hasPermission(
177
          'AddBaseMember',
178
        ),
179
      )
180

181
      const existingMember = await prismaClient.baseMembers.findUnique({
×
182
        where: {
183
          memberId_baseId: {
184
            memberId: joinRequest.applicantId,
185
            baseId: joinRequest.baseId,
186
          },
187
        },
188
      })
189

190
      if (existingMember) {
×
191
        return invalidError('Cette personne est déjà membre de la base')
×
192
      }
193

194
      const [newMember] = await prismaClient.$transaction([
×
195
        prismaClient.baseMembers.create({
196
          data: {
197
            baseId: joinRequest.baseId,
198
            memberId: joinRequest.applicantId,
199
            isAdmin: false,
200
            accepted: new Date(),
201
          },
202
        }),
203
        prismaClient.baseJoinRequest.update({
204
          where: { id: input.requestId },
205
          data: {
206
            accepted: new Date(),
207
          },
208
        }),
209
      ])
210

211
      sendJoinRequestAcceptedEmail({
×
212
        url: `/bases/${joinRequest.base.slug}`,
213
        email: joinRequest.applicant.email,
214
        baseTitle: joinRequest.base.title,
215
        adminName: user.name || user.email,
×
216
      }).catch((error) => Sentry.captureException(error))
×
217

218
      // Notify the applicant
UNCOV
219
      await createNotification({
×
220
        userId: joinRequest.applicantId,
221
        type: 'AcceptedAskJoinBase',
222
        baseId: joinRequest.baseId,
223
        initiatorId: user.id,
224
      })
225

226
      // Notify all other base members
NEW
227
      const memberNotifications = joinRequest.base.members
×
228
        .filter(
229
          (member) =>
NEW
230
            member.memberId !== user.id &&
×
231
            member.memberId !== joinRequest.applicantId,
232
        )
NEW
233
        .map((member) => ({
×
234
          userId: member.memberId,
235
          type: 'MemberAcceptedAskJoinBase' as const,
236
          baseId: joinRequest.baseId,
237
          initiatorId: joinRequest.applicantId,
238
        }))
NEW
239
      await createNotifications(memberNotifications)
×
240

UNCOV
241
      return newMember
×
242
    }),
243
  decline: protectedProcedure
244
    .input(z.object({ requestId: z.string().uuid() }))
245
    .mutation(async ({ input, ctx: { user } }) => {
246
      const joinRequest = await prismaClient.baseJoinRequest.findUnique({
×
247
        where: { id: input.requestId },
248
        include: {
249
          applicant: {
250
            select: {
251
              email: true,
252
              name: true,
253
              firstName: true,
254
              lastName: true,
255
            },
256
          },
257
          base: {
258
            select: {
259
              title: true,
260
              members: {
261
                where: {
262
                  accepted: { not: null },
263
                },
264
                select: {
265
                  memberId: true,
266
                },
267
              },
268
              ...baseAuthorizationTargetSelect,
269
            },
270
          },
271
        },
272
      })
273

274
      if (!joinRequest) {
×
275
        return notFoundError()
×
276
      }
277

278
      authorizeOrThrow(
×
279
        baseAuthorization(joinRequest.base, user).hasPermission(
280
          'AddBaseMember',
281
        ),
282
      )
283

284
      await prismaClient.baseJoinRequest.update({
×
285
        where: { id: input.requestId },
286
        data: {
287
          declined: new Date(),
288
        },
289
      })
290

291
      sendJoinRequestRejectedEmail({
×
292
        email: joinRequest.applicant.email,
293
        baseTitle: joinRequest.base.title,
294
        adminName: user.name || user.email,
×
295
      }).catch((error) => Sentry.captureException(error))
×
296

297
      // Notify the applicant
298
      await createNotification({
×
299
        userId: joinRequest.applicantId,
300
        type: 'DeclinedAskJoinBase',
301
        baseId: joinRequest.baseId,
302
        initiatorId: user.id,
303
      })
304

305
      // Notify all other base members
NEW
306
      const memberNotifications = joinRequest.base.members
×
NEW
307
        .filter((member) => member.memberId !== user.id)
×
NEW
308
        .map((member) => ({
×
309
          userId: member.memberId,
310
          type: 'MemberDeclinedAskJoinBase' as const,
311
          baseId: joinRequest.baseId,
312
          initiatorId: joinRequest.applicantId,
313
        }))
NEW
314
      await createNotifications(memberNotifications)
×
315
    }),
316
  remove: protectedProcedure
317
    .input(z.object({ baseId: z.string().uuid() }))
318
    .mutation(async ({ input, ctx: { user } }) => {
319
      const joinRequest = await prismaClient.baseJoinRequest.findUnique({
×
320
        where: {
321
          applicantId_baseId: {
322
            applicantId: user.id,
323
            baseId: input.baseId,
324
          },
325
        },
326
        include: {
327
          base: {
328
            select: {
329
              title: true,
330
            },
331
          },
332
        },
333
      })
334

335
      if (!joinRequest) {
×
336
        return notFoundError()
×
337
      }
338

339
      return prismaClient.baseJoinRequest.delete({
×
340
        where: { id: joinRequest.id },
341
      })
342
    }),
343
})
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc