Skip to content

Commit 820a77b

Browse files
committed
feat: implement access request for Gliederung
1 parent 1dd11d2 commit 820a77b

28 files changed

Lines changed: 942 additions & 17 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{{#> layout }}
2+
3+
<mj-text
4+
font-size="16px"
5+
line-height="1.5"
6+
>
7+
{{ name }} hat soeben einen Antrag auf Zugriff auf die Gliederung {{ gliederung }} gestellt. Bitte prüft den Antrag
8+
und bestätigt diesen, damit {{ name }} Zugriff auf die Ausschreibungen eurer Gliederung erhält.
9+
</mj-text>
10+
11+
<mj-text
12+
font-size="16px"
13+
line-height="1.5"
14+
>
15+
Nutzt für die Bestätigung oder Ablehnung des Antrags bitte folgenden Link: {{ confirmLink }}
16+
</mj-text>
17+
18+
{{/layout}}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
{{#> layout }}
2+
3+
<mj-text
4+
font-size="16px"
5+
line-height="1.5"
6+
>
7+
{{ name }} hat soeben einen Antrag auf Zugriff auf die Gliederung {{ gliederung }} gestellt. Bitte prüft den Antrag
8+
und bestätigt diesen, damit {{ name }} Zugriff auf die Ausschreibungen eurer Gliederung erhält.
9+
</mj-text>
10+
11+
<mj-text
12+
font-size="16px"
13+
line-height="1.5"
14+
>
15+
Nutzt für die Bestätigung oder Ablehnung des Antrags bitte folgenden Link: {{ confirmLink }}
16+
</mj-text>
17+
18+
{{/layout}}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
-- AlterTable
2+
ALTER TABLE "GliederungToAccount" ADD COLUMN "confirmByGliederungToken" TEXT,
3+
ADD COLUMN "confirmedByGliederung" BOOLEAN NOT NULL DEFAULT false;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Gliederung" ADD COLUMN "email" TEXT;

apps/api/prisma/schema/Gliederung.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ model Gliederung {
22
id String @id @default(uuid(7))
33
name String
44
edv String @unique
5+
email String?
56
unterveranstaltungen Unterveranstaltung[]
67
personen Person[]
78
GliederungToAccount GliederungToAccount[]

apps/api/prisma/schema/GliederungToAccount.prisma

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,8 @@ model GliederungToAccount {
1212
account Account @relation(fields: [accountId], references: [id], onDelete: Cascade)
1313
role GliederungAccountRole
1414
15+
confirmByGliederungToken String?
16+
confirmedByGliederung Boolean @default(false)
17+
1518
@@unique([gliederungId, accountId])
1619
}

apps/api/src/config.ts

Lines changed: 2 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { StringValue } from '@codeanker/authentication'
55
import { FileProvider } from '@prisma/client'
66
import config from 'config'
77
import { z } from 'zod'
8+
import { zEmptyStringAsUndefined } from './util/zod.js'
89

910
const __filename = fileURLToPath(import.meta.url)
1011
const __dirname = path.dirname(__filename)
@@ -37,16 +38,7 @@ export const configSchema = z.strictObject({
3738
dlrg: z.strictObject({
3839
issuer: z.string().url(),
3940
clientId: z.string(),
40-
clientSecret: z
41-
.string()
42-
.optional()
43-
.transform((v) => {
44-
const trimmed = v?.trim()
45-
if (trimmed === undefined || trimmed.length === 0) {
46-
return undefined
47-
}
48-
return trimmed
49-
}),
41+
clientSecret: z.string().optional().transform(zEmptyStringAsUndefined),
5042
allowInsecure: z.coerce.boolean(),
5143
}),
5244
}),

apps/api/src/context.ts

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import type { Account } from '@prisma/client'
21
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch'
32
import { getEntityIdFromHeader } from './authentication.js'
43
import { logger } from './logger.js'
@@ -12,6 +11,33 @@ function getAuthorizationHeader(headers: FetchCreateContextFnOptions['req']['hea
1211
}
1312
}
1413

14+
function getAccountById(accountId: string) {
15+
return prisma.account.findFirst({
16+
where: {
17+
id: accountId,
18+
},
19+
select: {
20+
id: true,
21+
activatedAt: true,
22+
email: true,
23+
role: true,
24+
status: true,
25+
GliederungToAccount: {
26+
select: {
27+
confirmedByGliederung: true,
28+
role: true,
29+
},
30+
},
31+
person: {
32+
select: {
33+
firstname: true,
34+
lastname: true,
35+
},
36+
},
37+
},
38+
})
39+
}
40+
1541
export async function createContext({ req }: FetchCreateContextFnOptions): Promise<Context> {
1642
try {
1743
const authorization = getAuthorizationHeader(req.headers)
@@ -26,11 +52,7 @@ export async function createContext({ req }: FetchCreateContextFnOptions): Promi
2652
}
2753
}
2854

29-
const account = await prisma.account.findFirst({
30-
where: {
31-
id: accountId,
32-
},
33-
})
55+
const account = await getAccountById(accountId)
3456

3557
if (account === null) {
3658
return {
@@ -64,7 +86,7 @@ type AuthContext =
6486
| {
6587
authenticated: true
6688
accountId: string
67-
account: Account
89+
account: Awaited<ReturnType<typeof getAccountById>>
6890
}
6991

7092
export type Context = AuthContext
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { TRPCError } from '@trpc/server'
2+
import * as uuid from 'uuid'
3+
import { z } from 'zod'
4+
import prisma from '../../prisma.js'
5+
import { defineProtectedMutateProcedure } from '../../types/defineProcedure.js'
6+
import { sendMail } from '../../util/mail.js'
7+
import config from '../../config.js'
8+
9+
export const requestGliederungAccessCreateProcedure = defineProtectedMutateProcedure({
10+
key: 'requestGliederungAdminCreate',
11+
roleIds: ['USER'],
12+
inputSchema: z.strictObject({
13+
gliederungId: z.string().uuid(),
14+
}),
15+
handler: async ({ ctx, input }) => {
16+
const existing = await prisma.gliederungToAccount.findFirst({
17+
where: {
18+
accountId: ctx.accountId,
19+
gliederungId: input.gliederungId,
20+
confirmedByGliederung: false,
21+
},
22+
})
23+
if (existing !== null) {
24+
throw new TRPCError({
25+
code: 'FORBIDDEN',
26+
message: 'Es gibt bereits eine ausstehende Anfrage für diese Gliederung.',
27+
})
28+
}
29+
30+
const gliederung = await prisma.gliederung.findUniqueOrThrow({
31+
where: {
32+
id: input.gliederungId,
33+
},
34+
select: {
35+
name: true,
36+
email: true,
37+
},
38+
})
39+
40+
if (!gliederung.email) {
41+
throw new TRPCError({
42+
code: 'INTERNAL_SERVER_ERROR',
43+
message: `Die Gliederung ${gliederung.name} hat keine Kontaktadresse hinterlegt!`,
44+
})
45+
}
46+
47+
const confirmByGliederungToken = uuid.v7()
48+
49+
await prisma.$transaction(async (tx) => {
50+
await tx.gliederungToAccount.create({
51+
data: {
52+
gliederungId: input.gliederungId,
53+
accountId: ctx.accountId,
54+
role: 'DELEGATIONSLEITER',
55+
confirmedByGliederung: false,
56+
confirmByGliederungToken,
57+
},
58+
})
59+
60+
await sendMail({
61+
to: `${gliederung.email}`,
62+
subject: 'Zugriffsanfrage auf deine Gliederung',
63+
template: 'gliederung-access-request-info',
64+
categories: ['access', 'gliederung'],
65+
variables: {
66+
hostname: 'brahmsee.digital',
67+
gliederung: gliederung.name,
68+
name: gliederung.name,
69+
veranstaltung: 'Zugriffsanfrage',
70+
confirmLink: `${config.clientUrl}/confirm/gliederung-access/${confirmByGliederungToken}`,
71+
},
72+
})
73+
})
74+
},
75+
})
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { z } from 'zod'
2+
import { defineProtectedMutateProcedure } from '../../types/defineProcedure.js'
3+
import prisma from '../../prisma.js'
4+
import logActivity from '../../util/activity.js'
5+
import { TRPCError } from '@trpc/server'
6+
import { sendMail } from '../../util/mail.js'
7+
8+
export const requestGliederungAdminDecideProcedure = defineProtectedMutateProcedure({
9+
key: 'requestGliederungAdminDecide',
10+
roleIds: ['ADMIN', 'GLIEDERUNG_ADMIN'],
11+
inputSchema: z.strictObject({
12+
token: z.string().uuid().optional(),
13+
requestId: z.number().int(),
14+
decision: z.boolean(),
15+
}),
16+
handler: async ({ ctx, input: { token, requestId, decision } }) => {
17+
if (ctx.account?.role === 'GLIEDERUNG_ADMIN' && token === undefined) {
18+
throw new TRPCError({
19+
code: 'BAD_REQUEST',
20+
message: 'token is required',
21+
})
22+
}
23+
24+
const request = await prisma.gliederungToAccount.findUnique({
25+
where: {
26+
id: requestId,
27+
confirmByGliederungToken: ctx.account?.role === 'ADMIN' ? undefined : token,
28+
},
29+
select: {
30+
id: true,
31+
accountId: true,
32+
account: {
33+
select: {
34+
email: true,
35+
},
36+
},
37+
gliederung: {
38+
select: {
39+
name: true,
40+
},
41+
},
42+
},
43+
})
44+
if (request === null) {
45+
throw new TRPCError({
46+
code: 'NOT_FOUND',
47+
})
48+
}
49+
50+
if (!decision) {
51+
await prisma.gliederungToAccount.delete({
52+
where: {
53+
id: requestId,
54+
},
55+
})
56+
57+
await logActivity({
58+
type: 'DELETE',
59+
subjectType: 'gliederungtoaccount',
60+
subjectId: `${request.id}`,
61+
description: `Eine Zugriffsanfrage auf die Gliederung ${request.gliederung.name} wurde abgelehnt.`,
62+
causerId: ctx.accountId,
63+
})
64+
} else {
65+
await prisma.$transaction(async (tx) => {
66+
const { accountId } = await tx.gliederungToAccount.update({
67+
where: {
68+
id: requestId,
69+
},
70+
data: {
71+
confirmByGliederungToken: null,
72+
confirmedByGliederung: true,
73+
},
74+
select: {
75+
accountId: true,
76+
},
77+
})
78+
await tx.account.update({
79+
where: {
80+
id: accountId,
81+
},
82+
data: {
83+
role: 'GLIEDERUNG_ADMIN',
84+
},
85+
})
86+
})
87+
88+
await logActivity({
89+
type: 'UPDATE',
90+
subjectType: 'account',
91+
subjectId: request.accountId,
92+
description: `Account ${request.account.email} ist jetzt Gliederungsadmin der Gliederung ${request.gliederung.name}`,
93+
causerId: ctx.accountId,
94+
})
95+
}
96+
97+
await sendMail({
98+
to: request.account.email,
99+
categories: ['access', 'gliederung'],
100+
template: 'gliederung-access-request-decision',
101+
subject: `Zugriffsanfrage auf deine Gliederung ${decision ? 'bestätigt' : 'abgelehnt'}`,
102+
variables: {
103+
hostname: 'brahmsee.digital',
104+
gliederung: request.gliederung.name,
105+
name: `${ctx.account?.person.firstname} ${ctx.account?.person.lastname}`,
106+
veranstaltung: 'Zugriffsanfrage',
107+
decision,
108+
},
109+
})
110+
111+
return decision
112+
},
113+
})

0 commit comments

Comments
 (0)