Skip to content
88 changes: 73 additions & 15 deletions apps/api/src/routes/exports/archives/photos.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,22 @@
import { dayjs } from '@codeanker/helpers'
import XLSX from '@e965/xlsx'
import type { Gliederung } from '@prisma/client'
import { TRPCError } from '@trpc/server'
import archiver from 'archiver'
import type { Context } from 'koa'
import mime from 'mime'
import { randomUUID } from 'node:crypto'
import { z } from 'zod'
import prisma from '../../../prisma.js'
import { openFileStream } from '../../../services/file/helpers/getFileUrl.js'
import { sheetAuthorize } from '../sheets/sheets.schema.js'
import { z } from 'zod'
import { getSecurityWorksheet } from '../helpers/getSecurityWorksheet.js'
import { sheetAuthorize, type SheetQuery } from '../sheets/sheets.schema.js'

const querySchema = z.object({
mode: z.enum(['group', 'flat']),
})

export async function veranstaltungPhotoArchive(ctx: Context) {
const authorization = await sheetAuthorize(ctx)
if (!authorization) {
return
}

const { query, gliederung } = authorization
const { mode } = querySchema.parse(ctx.query)

const anmeldungen = await prisma.anmeldung.findMany({
function queryAnmeldungen(query: SheetQuery, gliederung?: Gliederung) {
return prisma.anmeldung.findMany({
where: {
OR: [
{
Expand All @@ -46,9 +42,11 @@ export async function veranstaltungPhotoArchive(ctx: Context) {
id: true,
unterveranstaltung: {
select: {
beschreibung: true,
veranstaltung: {
select: {
name: true,
beginn: true,
},
},
gliederung: {
Expand All @@ -64,10 +62,60 @@ export async function veranstaltungPhotoArchive(ctx: Context) {
firstname: true,
lastname: true,
photo: true,
birthday: true,
essgewohnheit: true,
},
},
},
})
}

function buildSheet(
anmeldungen: Awaited<ReturnType<typeof queryAnmeldungen>>,
person: { firstname: string; lastname: string }
) {
const rows = anmeldungen.map((anmeldung) => {
const age = dayjs(anmeldung.unterveranstaltung.veranstaltung.beginn).diff(anmeldung.person.birthday, 'years')
const extension = mime.getExtension(anmeldung.person.photo?.mimetype ?? 'text/plain')
return {
Fotomarker: anmeldung.person.photo ? `Fotos/${anmeldung.person.photo.id}.${extension}` : '',
Altersmarker: age < 16 ? 'U16' : age < 18 ? 'U18' : '',
Veggiemarker: anmeldung.person.essgewohnheit === 'OMNIVOR' ? '' : 'brocolli.svg',
Vorname: anmeldung.person.firstname,
Nachname: anmeldung.person.lastname,
Ausschreibung:
anmeldung.unterveranstaltung.beschreibung?.substring(0, 30) || anmeldung.unterveranstaltung.gliederung.name,
}
})

const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.json_to_sheet(rows)

XLSX.utils.book_append_sheet(workbook, worksheet, `Teilnehmendenliste`)

/** add Security Worksheet */
const { securityWorksheet, securityWorksheetName } = getSecurityWorksheet({ person }, rows.length)
XLSX.utils.book_append_sheet(workbook, securityWorksheet, securityWorksheetName)

return XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer
}

export async function veranstaltungPhotoArchive(ctx: Context) {
const authorization = await sheetAuthorize(ctx)
if (!authorization) {
return
}

const { query, gliederung, account } = authorization
const { mode } = querySchema.parse(ctx.query)

if (mode === 'flat' && account.role !== 'ADMIN') {
throw new TRPCError({
code: 'FORBIDDEN',
})
}

const anmeldungen = await queryAnmeldungen(query, gliederung)

const zip = archiver('zip')

Expand All @@ -86,7 +134,10 @@ export async function veranstaltungPhotoArchive(ctx: Context) {

ctx.res.statusCode = 201
ctx.res.setHeader('Content-Type', 'application/zip')
ctx.res.setHeader('Content-Disposition', `attachment; filename="photos-${randomUUID()}.zip"`)
ctx.res.setHeader(
'Content-Disposition',
`attachment; filename="${mode === 'flat' ? 'FotosForAutomation' : 'Fotos'}.zip"`
)
zip.pipe(ctx.res)

for (const { person, unterveranstaltung } of anmeldungen) {
Expand All @@ -101,11 +152,18 @@ export async function veranstaltungPhotoArchive(ctx: Context) {
const extension = mime.getExtension(person.photo.mimetype ?? 'text/plain')

zip.append(stream, {
name: mode === 'group' ? `${directory}/${basename}.${extension}` : `${person.photo.id}.${extension}`,
name: mode === 'group' ? `${directory}/${basename}.${extension}` : `Fotos/${person.photo.id}.${extension}`,
date: person.photo.createdAt,
})
}

if (mode === 'flat') {
const buffer = buildSheet(anmeldungen, account.person)
zip.append(buffer, {
name: 'Datenzusammenführung.xlsx',
})
}

await zip.finalize()

ctx.res.end()
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/exports/sheets/sheets.schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export const sheetQuerySchema = z
message: 'Exactly one of veranstaltungId or unterveranstaltungId must be provided',
})

export type SheetQuery = z.infer<typeof sheetQuerySchema>

export async function sheetAuthorize(ctx: Context) {
const [success, query] = await zodSafe(sheetQuerySchema, ctx.query)
if (!success) {
Expand Down
21 changes: 14 additions & 7 deletions apps/api/src/routes/exports/sheets/teilnehmendenliste.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) {
unterveranstaltung: {
gliederungId: gliederung?.id,
},
status: 'BESTAETIGT',
},
select: {
id: true,
Expand All @@ -41,7 +42,12 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) {
birthday: true,
email: true,
telefon: true,
photoId: true,
photo: {
select: {
id: true,
mimetype: true,
},
},
essgewohnheit: true,
address: {
select: {
Expand Down Expand Up @@ -105,6 +111,9 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) {
}
})
.reduce((acc, cur) => ({ ...acc, ...cur }), {})

const age = dayjs(anmeldung.unterveranstaltung.veranstaltung.beginn).diff(anmeldung.person.birthday, 'years')

return {
'#': anmeldung.id,

Expand All @@ -115,16 +124,13 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) {

Status: AnmeldungStatusMapping[anmeldung.status].human,
Anmeldedatum: anmeldung.createdAt,
'Foto ID': anmeldung.person.photoId ?? '',
Foto: anmeldung.person.photo ? 'Ja' : 'Nein',

Geschlecht: anmeldung.person.gender ? GenderMapping[anmeldung.person.gender].human : '',
Vorname: anmeldung.person.firstname,
Nachname: anmeldung.person.lastname,
Geburtstag: anmeldung.person.birthday,
'Alter zu Beginn': dayjs(anmeldung.unterveranstaltung.veranstaltung.beginn).diff(
anmeldung.person.birthday,
'years'
),
'Alter zu Beginn': age,
Email: anmeldung.person.email,
Telefon: anmeldung.person.telefon,
Essgewohnheit: anmeldung.person.essgewohnheit,
Expand All @@ -147,13 +153,14 @@ export async function veranstaltungTeilnehmendenliste(ctx: Context) {
})
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.json_to_sheet(rows)

XLSX.utils.book_append_sheet(workbook, worksheet, `Teilnehmendenliste`)

/** add Security Worksheet */
const { securityWorksheet, securityWorksheetName } = getSecurityWorksheet(account, rows.length)
XLSX.utils.book_append_sheet(workbook, securityWorksheet, securityWorksheetName)

const filename = `${dayjs().format('YYYYMMDD-hhmm')}-Teilnehmenden.xlsx`
const filename = `${dayjs().format('YYYYMMDD-hhmm')}-Teilnehmendenliste.xlsx`
const buf = XLSX.write(workbook, { type: 'buffer', bookType: 'xlsx' }) as Buffer

ctx.res.statusCode = 201
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,13 @@ export const unterveranstaltungListProcedure = defineProtectedQueryProcedure({
},
},
_count: {
select: { Anmeldung: true },
select: {
Anmeldung: {
where: {
status: 'BESTAETIGT',
},
},
},
},
meldebeginn: true,
meldeschluss: true,
Expand Down
14 changes: 12 additions & 2 deletions apps/api/src/services/veranstaltung/veranstaltungVerwaltungList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,18 @@ export const veranstaltungVerwaltungListProcedure = defineProtectedQueryProcedur
},
},
})

return veranstaltungen
return veranstaltungen.map((veranstaltung) => {
const count = veranstaltung.unterveranstaltungen.reduce((acc, unterveranstaltung) => {
if (unterveranstaltung._count.Anmeldung) {
acc += unterveranstaltung._count.Anmeldung
}
return acc
}, 0)
return {
...veranstaltung,
anzahlAnmeldungen: count,
}
})
},
})

Expand Down
3 changes: 2 additions & 1 deletion apps/frontend/src/components/UnterveranstaltungenTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import BasicInput from '@/components/BasicInputs/BasicInput.vue'
import { UnterveranstaltungTypeMapping, type RouterInput, type RouterOutput } from '@codeanker/api'
import { type TGridColumn } from '@codeanker/datagrid'
import DataGridDoubleLineCell from './DataGridDoubleLineCell.vue'
import { formatCurrency } from '@codeanker/helpers'

const props = defineProps<{
veranstaltungId?: number
Expand Down Expand Up @@ -64,7 +65,7 @@ const columns = computed<TGridColumn<TUnterveranstaltungData, TUnterveranstaltun
title: 'Gebühr',
size: '120px',
sortable: true,
format: (value) => `${value.toFixed(2)} €`,
format: (value) => formatCurrency(value),
},
{
field: 'maxTeilnehmende',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,21 +151,13 @@
hoverColor: 'hover:text-primary-700',
},
{
name: 'Fotos (Gruppiert)',
name: 'Fotos',
description: 'Alle Fotos von bestätigten Teilnehmenden, gruppiert nach Veranstaltung und Ausschreibung',
icon: CameraIcon,
bgColor: 'bg-orange-600',
hoverColor: 'hover:text-orange-700',
href: `/api/export/archive/photos?${exportParams}&mode=group`,
},
{
name: 'Fotos (Für automatisierte Verarbeitung)',
description: 'Alle Fotos von bestätigten Teilnehmenden, optimiert für automatisierte Verarbeitung',
icon: CameraIcon,
bgColor: 'bg-orange-600',
hoverColor: 'hover:text-orange-700',
href: `/api/export/archive/photos?${exportParams}&mode=flat`,
},
]

const faqList = useTemplateRef('faqList')
Expand Down Expand Up @@ -218,7 +210,7 @@
</div>
<div
class="prose dark:prose-invert"
v-html="unterveranstaltung?.bedingungen"

Check warning on line 213 in apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue

View workflow job for this annotation

GitHub Actions / 🔍 turbo-checks

'v-html' directive can lead to XSS attack
/>
<hr class="my-10" />
<div class="my-10">
Expand All @@ -229,7 +221,7 @@
</div>
<div
class="prose dark:prose-invert"
v-html="unterveranstaltung?.veranstaltung?.teilnahmeBedingungenPublic"

Check warning on line 224 in apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue

View workflow job for this annotation

GitHub Actions / 🔍 turbo-checks

'v-html' directive can lead to XSS attack
/>
<hr class="my-10" />
<div class="my-10">
Expand All @@ -238,7 +230,7 @@
</div>
<div
class="prose dark:prose-invert"
v-html="unterveranstaltung?.veranstaltung?.teilnahmeBedingungen"

Check warning on line 233 in apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue

View workflow job for this annotation

GitHub Actions / 🔍 turbo-checks

'v-html' directive can lead to XSS attack
/>
<hr class="my-10" />
<div class="my-10">
Expand All @@ -247,7 +239,7 @@
</div>
<div
class="prose dark:prose-invert"
v-html="unterveranstaltung?.veranstaltung?.datenschutz"

Check warning on line 242 in apps/frontend/src/views/Unterveranstaltung/UnterveranstaltungDetail.vue

View workflow job for this annotation

GitHub Actions / 🔍 turbo-checks

'v-html' directive can lead to XSS attack
/>
</Tab>
<Tab key="marketing">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { useRouteTitle } from '@/composables/useRouteTitle'
import router from '@/router'
import { type RouterInput, type RouterOutput } from '@codeanker/api'
import { type TGridColumn } from '@codeanker/datagrid'
import { formatCurrency } from '@codeanker/helpers'

const { setTitle } = useRouteTitle()
setTitle('Veranstaltungen')
Expand Down Expand Up @@ -51,13 +52,16 @@ const columns: TGridColumn<TData, TFilter>[] = [
},
{
field: 'teilnahmegebuehr',
title: 'Teilnahmegebühr',
title: 'Gebühr',
size: '120px',
sortable: true,
format: (value) => formatCurrency(value),
},
{
field: 'maxTeilnehmende',
title: 'TN',
sortable: true,
title: 'Anm. / Max',
size: '150px',
format: (value, row) => `${row.anzahlAnmeldungen} / ${value}`,
},
]

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "codeanker-project",
"author": "CODEANKER GmbH",
"version": "2.3.0",
"version": "2.4.0",
"description": "",
"license": "CC-BY-3.0-DE",
"workspaces": [
Expand Down
34 changes: 34 additions & 0 deletions packages/helpers/src/formatCurrency.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* eslint-disable @typescript-eslint/no-unsafe-argument */
export function formatCurrency(value, round = false, isCents = false) {
if (typeof value !== 'number') {
const parsedValue = Number.parseFloat(value)
if (Number.isNaN(parsedValue)) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return value
}
value = parsedValue
}
if (!round) {
value = toFixed(value, 2)
}
const formatter = new Intl.NumberFormat('de', {
style: 'currency',
currency: 'EUR',
minimumFractionDigits: 2,
})
if (isCents) {
return formatter.format(value / 100)
} else {
return formatter.format(value)
}
}

function toFixed(num, fixed) {
const re = new RegExp('^-?\\d+(?:.\\d{0,' + (fixed || -1) + '})?')
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const matches = num.toString().match(re)
if (matches) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
return Number.parseFloat(matches[0])
}
}
1 change: 1 addition & 0 deletions packages/helpers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './format.js'
export * from './password-strength.js'
export * from './formatBytes.js'
export * from './group-by.js'
export * from './formatCurrency.js'
Loading