#5-intelligent-groups #23

Merged
jkeffects merged 16 commits from #5-intelligent-groups into main 2024-12-19 09:50:45 +00:00
27 changed files with 285 additions and 74 deletions
Showing only changes of commit 6fd2091a7e - Show all commits

View file

@ -17,7 +17,6 @@ WORKDIR /app
COPY --from=build /app/dist /app/dist
COPY --from=build /app/node_modules /app/node_modules
COPY --from=build /app/package.json /app/package.json
COPY --from=build /app/.env /app/.env
EXPOSE 5000

View file

@ -1,30 +1,87 @@
# member-administration-server
Memberadministration
Mitgliederverwaltung für Feuerwehren und Vereine (Backend).
Authentications is realized via JWT-Tokens. The server is able to send Mails to the members.
Login is possible via Username and TOTP.
## Einleitung
Dieses Projekt, `member-administration-server`, ist das Backend zur Verwaltung von Mitgliederdaten. Die zugehörige Webapp ist im Repository [member-administration-ui](https://forgejo.jk-effects.cloud/Ehrenamt/member-administration-ui) zu finden.
Eine Demo zusammen mit der `member-administration-ui` finden Sie unter [ff-admin-demo.jk-effects.cloud](ff-admin-demo.jk-effects.cloud).
## Installation
### Requirements
### Docker Compose Setup
1. MySql Database
2. Access to the internet for sending Mails
Um den Container hochzufahren, erstellen Sie eine `docker-compose.yml` Datei mit folgendem Inhalt:
### Configuration
```yaml
version: "3"
1. Copy the .env.example file to .env and fill in the required information
2. Create a new Database in MySql named as in the .env file
3. Install all packages via `npm install`
4. Start the application to create the database schema
services:
ff-member-administration-server:
image: docker.registry.jk-effects.cloud/ehrenamt/member-administration/server:latest
container_name: ff_member_administration_server
restart: unless-stopped
environment:
- DB_TYPE = mysql
- DB_HOST=ffm-db
- DB_NAME=administration
- DB_USERNAME=administration_backend
- DB_PASSWORD=<dbuserpasswd>
- JWT_SECRET=<tobemodified>
- JWT_EXPIRATION=<number[m|d] - bsp.:15m>
- REFRESH_EXPIRATION=<number[m|d] - bsp.:1d>
- MAIL_USERNAME=<mailadress|username>
- MAIL_PASSWORD=<password>
- MAIL_HOST=<url>
- MAIL_PORT=<port>
- MAIL_SECURE=<boolean>
- CLUB_NAME=<tobemodified>
volumes:
- <volume|local path>:/app/export
networks:
- ff_internal
depends_on:
- ff-db
## Testing
ff-db:
image: mariadb:11.2
container_name: ff_db
restart: unless-stopped
environment:
- MYSQL_DATABASE=ffadmin
- MYSQL_USER=administration_backend
- MYSQL_PASSWORD=<dbuserpasswd>
- MYSQL_ROOT_PASSWORD=<dbrootpasswd>
volumes:
- <volume|local path>:/var/lib/mysql
networks:
- ff_internal
1. Install the database-system-package you like (e.g. mysql, mariadb, postgresql, sqlite3)
2. Configure type inside src/data-source.ts to run the database-system you like.
3. Set migrationsRun to false and synchronize to true for rapid prototyping
4. Building the schema via CLI:
- Run `npm run update-database` to build the schema using the migrations without starting the application
- Run `npm run synchronize-database` to build the schema without using migrations without starting the application
5. Run `npm run dev` to run inside dev-environment (runs migrations if migrationsRun is set to true)
networks:
ff_internal:
```
Führen Sie dann den folgenden Befehl im Verzeichnis der compose-Datei aus, um den Container zu starten:
```sh
docker-compose up -d
```
### Manuelle Installation
Klonen Sie dieses Repository und installieren Sie die Abhängigkeiten:
```sh
git clone https://forgejo.jk-effects.cloud/Ehrenamt/member-administration-server.git
cd member-administration-server
npm install
npm run build
npm run start
```
## Fragen und Wünsche
Bei Fragen, Anregungen oder Wünschen können Sie sich gerne melden.\
Wir freuen uns über Ihr Feedback und helfen Ihnen gerne weiter.\
Schreiben Sie dafür eine Mail an julian.krauser@jk-effects.com.

4
package-lock.json generated
View file

@ -1,12 +1,12 @@
{
"name": "member-administration-server",
"version": "0.0.7",
"version": "0.0.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "member-administration-server",
"version": "0.0.7",
"version": "0.0.9",
"license": "GPL-3.0-only",
"dependencies": {
"cors": "^2.8.5",

View file

@ -1,6 +1,6 @@
{
"name": "member-administration-server",
"version": "0.0.7",
"version": "0.0.9",
"description": "Feuerwehr/Verein Mitgliederverwaltung Server",
"main": "dist/index.js",
"scripts": {

View file

@ -2,6 +2,7 @@ export interface CreateCalendarTypeCommand {
type: string;
nscdr: boolean;
color: string;
passphrase?: string;
}
export interface UpdateCalendarTypeCommand {
@ -9,6 +10,7 @@ export interface UpdateCalendarTypeCommand {
type: string;
nscdr: boolean;
color: string;
passphrase?: string;
}
export interface DeleteCalendarTypeCommand {

View file

@ -18,6 +18,7 @@ export default abstract class CalendarTypeCommandHandler {
type: createCalendarType.type,
nscdr: createCalendarType.nscdr,
color: createCalendarType.color,
passphrase: createCalendarType.nscdr ? null : createCalendarType.passphrase,
})
.execute()
.then((result) => {
@ -41,6 +42,7 @@ export default abstract class CalendarTypeCommandHandler {
type: updateCalendarType.type,
nscdr: updateCalendarType.nscdr,
color: updateCalendarType.color,
passphrase: updateCalendarType.nscdr ? null : updateCalendarType.passphrase,
})
.where("id = :id", { id: updateCalendarType.id })
.execute()

View file

@ -1,5 +1,6 @@
export interface CreateCommunicationCommand {
preferred: boolean;
isSMSAlarming: boolean;
mobile: string;
email: string;
city: string;
@ -13,6 +14,7 @@ export interface CreateCommunicationCommand {
export interface UpdateCommunicationCommand {
id: number;
preferred: boolean;
isSMSAlarming: boolean;
mobile: string;
email: string;
city: string;

View file

@ -22,6 +22,7 @@ export default abstract class CommunicationCommandHandler {
.into(communication)
.values({
preferred: createCommunication.preferred,
isSMSAlarming: createCommunication.isSMSAlarming,
mobile: createCommunication.mobile,
email: createCommunication.email,
city: createCommunication.city,
@ -59,6 +60,7 @@ export default abstract class CommunicationCommandHandler {
.update(communication)
.set({
preferred: updateCommunication.preferred,
isSMSAlarming: updateCommunication.isSMSAlarming,
mobile: updateCommunication.mobile,
email: updateCommunication.email,
city: updateCommunication.city,

View file

@ -68,7 +68,6 @@ export default abstract class MemberCommandHandler {
* @returns {Promise<void>}
*/
static async updateNewsletter(updateMember: UpdateMemberNewsletterCommand): Promise<void> {
console.log(updateMember);
return await dataSource
.createQueryBuilder()
.update(member)
@ -88,6 +87,26 @@ export default abstract class MemberCommandHandler {
});
}
/**
* @description update member newsletter to unset
* @param memberId string
* @returns {Promise<void>}
*/
static async unsetNewsletter(memberId: number): Promise<void> {
return await dataSource
.createQueryBuilder()
.update(member)
.set({
sendNewsletter: null,
})
.where("id = :id", { id: memberId })
.execute()
.then(() => {})
.catch((err) => {
throw new InternalException("Failed updating member", err);
});
}
/**
* @description delete member
* @param DeleteMemberCommand

View file

@ -101,11 +101,13 @@ export async function createCalendarType(req: Request, res: Response): Promise<a
const type = req.body.type;
const nscdr = req.body.nscdr;
const color = req.body.color;
const passphrase = req.body.passphrase;
let createType: CreateCalendarTypeCommand = {
type,
nscdr,
color,
passphrase,
};
let id = await CalendarTypeCommandHandler.create(createType);
@ -154,12 +156,14 @@ export async function updateCalendarType(req: Request, res: Response): Promise<a
const type = req.body.type;
const nscdr = req.body.nscdr;
const color = req.body.color;
const passphrase = req.body.passphrase;
let updateType: UpdateCalendarTypeCommand = {
id,
type,
nscdr,
color,
passphrase,
};
await CalendarTypeCommandHandler.update(updateType);

View file

@ -346,6 +346,7 @@ export async function addExecutivePositionToMember(req: Request, res: Response):
export async function addCommunicationToMember(req: Request, res: Response): Promise<any> {
const memberId = parseInt(req.params.memberId);
const preferred = req.body.preferred;
const isSMSAlarming = req.body.isSMSAlarming;
const mobile = req.body.mobile;
const email = req.body.email;
const city = req.body.city;
@ -357,6 +358,7 @@ export async function addCommunicationToMember(req: Request, res: Response): Pro
let createCommunication: CreateCommunicationCommand = {
preferred,
isSMSAlarming,
mobile,
email,
city,
@ -528,6 +530,7 @@ export async function updateCommunicationOfMember(req: Request, res: Response):
const memberId = parseInt(req.params.memberId);
const recordId = parseInt(req.params.recordId);
const preferred = req.body.preferred;
const isSMSAlarming = req.body.isSMSAlarming;
const mobile = req.body.mobile;
const email = req.body.email;
const city = req.body.city;
@ -540,6 +543,7 @@ export async function updateCommunicationOfMember(req: Request, res: Response):
let updateCommunication: UpdateCommunicationCommand = {
id: recordId,
preferred,
isSMSAlarming,
mobile,
email,
city,
@ -550,12 +554,16 @@ export async function updateCommunicationOfMember(req: Request, res: Response):
};
await CommunicationCommandHandler.update(updateCommunication);
let currentUserNewsletterMain = await MemberService.getNewsletterById(memberId);
if (isNewsletterMain) {
let updateNewsletter: UpdateMemberNewsletterCommand = {
id: memberId,
communicationId: recordId,
};
await MemberCommandHandler.updateNewsletter(updateNewsletter);
} else if (currentUserNewsletterMain.sendNewsletter.id == recordId) {
await MemberCommandHandler.unsetNewsletter(memberId);
}
res.sendStatus(204);

View file

@ -4,63 +4,96 @@ import CalendarTypeService from "../service/calendarTypeService";
import { calendar } from "../entity/calendar";
import { createEvents } from "ics";
import moment from "moment";
import InternalException from "../exceptions/internalException";
import CalendarFactory from "../factory/admin/calendar";
/**
* @description get all calendar items by types or nscdr
* @summary passphrase is passed as value pair like `type:passphrase`
* @param req {Request} Express req object
* @param res {Response} Express res object
* @returns {Promise<*>}
*/
export async function getCalendarItemsByTypes(req: Request, res: Response): Promise<any> {
let types = Array.isArray(req.query.types) ? req.query.types : [req.query.types];
let output = (req.query.output as "ics" | "json") ?? "ics";
if (output != "ics" && output != "json") {
throw new InternalException("set output query value to `ics` or `json` (defaults to `ics`)");
}
types = types.filter((t) => t);
let items: Array<calendar> = [];
if (types.length == 0) {
let typeIds = await CalendarTypeService.getByTypes(types as Array<string>);
if (types.length != 0) {
let typeIds = await CalendarTypeService.getByTypes((types as Array<string>).map((t) => t.split(":")[0]));
typeIds = typeIds.filter(
(ti) =>
ti.passphrase == null ||
ti.passphrase == "" ||
ti.passphrase == (types as Array<string>).find((t) => t.includes(ti.type)).split(":")[1]
);
items = await CalendarService.getByTypes(typeIds.map((t) => t.id));
} else {
items = await CalendarService.getByTypeNSCDR();
}
let events = createEvents(
items.map((i) => ({
calName: process.env.CLUB_NAME,
uid: i.id,
sequence: 1,
start: moment(i.starttime)
.format("YYYY-M-D-H-m")
.split("-")
.map((a) => parseInt(a)) as [number, number, number, number, number],
end: moment(i.endtime)
.format("YYYY-M-D-H-m")
.split("-")
.map((a) => parseInt(a)) as [number, number, number, number, number],
title: i.title,
description: i.content,
location: i.location,
categories: [i.type.type],
created: moment(i.createdAt)
.format("YYYY-M-D-H-m")
.split("-")
.map((a) => parseInt(a)) as [number, number, number, number, number],
lastModified: moment(i.updatedAt)
.format("YYYY-M-D-H-m")
.split("-")
.map((a) => parseInt(a)) as [number, number, number, number, number],
transp: "OPAQUE" as "OPAQUE",
url: "https://www.ff-merching.de",
alarms: [
{
action: "display",
description: "Erinnerung",
trigger: {
minutes: 30,
before: true,
if (output == "json") {
res.json(CalendarFactory.mapToBase(items));
} else {
let events = createEvents(
items.map((i) => ({
calName: process.env.CLUB_NAME,
uid: i.id,
sequence: 1,
...(i.allDay
? {
start: moment(i.starttime)
.format("YYYY-M-D")
.split("-")
.map((a) => parseInt(a)) as [number, number, number],
end: moment(i.endtime)
.format("YYYY-M-D")
.split("-")
.map((a) => parseInt(a)) as [number, number, number],
}
: {
start: moment(i.starttime)
.format("YYYY-M-D-H-m")
.split("-")
.map((a) => parseInt(a)) as [number, number, number, number, number],
end: moment(i.endtime)
.format("YYYY-M-D-H-m")
.split("-")
.map((a) => parseInt(a)) as [number, number, number, number, number],
}),
title: i.title,
description: i.content,
location: i.location,
categories: [i.type.type],
created: moment(i.createdAt)
.format("YYYY-M-D-H-m")
.split("-")
.map((a) => parseInt(a)) as [number, number, number, number, number],
lastModified: moment(i.updatedAt)
.format("YYYY-M-D-H-m")
.split("-")
.map((a) => parseInt(a)) as [number, number, number, number, number],
transp: "OPAQUE" as "OPAQUE",
url: "https://www.ff-merching.de",
alarms: [
{
action: "display",
description: "Erinnerung",
trigger: {
minutes: 30,
before: true,
},
},
},
],
}))
);
],
}))
);
res.type("ics").send(events.value);
res.type("ics").send(events.value);
}
}

View file

@ -42,6 +42,8 @@ import { calendarType } from "./entity/calendarType";
import { Calendar1729947763295 } from "./migrations/1729947763295-calendar";
import { reset } from "./entity/reset";
import { ResetToken1732358596823 } from "./migrations/1732358596823-resetToken";
import { SMSAlarming1732696919191 } from "./migrations/1732696919191-SMSAlarming";
import { SecuringCalendarType1733249553766 } from "./migrations/1733249553766-securingCalendarType";
const dataSource = new DataSource({
type: DB_TYPE as any,
@ -94,6 +96,8 @@ const dataSource = new DataSource({
Protocol1729347911107,
Calendar1729947763295,
ResetToken1732358596823,
SMSAlarming1732696919191,
SecuringCalendarType1733249553766,
],
migrationsRun: true,
migrationsTransactionMode: "each",

View file

@ -15,6 +15,9 @@ export class calendarType {
@Column({ type: "varchar", length: 255 })
color: string;
@Column({ type: "varchar", length: 255, nullable: true, default: null })
passphrase: string | null;
@OneToMany(() => calendar, (c) => c.type, {
nullable: false,
onDelete: "RESTRICT",

View file

@ -10,6 +10,9 @@ export class communication {
@Column({ type: "boolean", default: false })
preferred: boolean;
@Column({ type: "boolean", default: false })
isSMSAlarming: boolean;
@Column({ type: "varchar", length: 255, nullable: true })
mobile: string;

View file

@ -65,4 +65,5 @@ export class member {
firstMembershipEntry?: membership;
lastMembershipEntry?: membership;
preferredCommunication?: Array<communication>;
smsAlarming?: Array<communication>;
}

View file

@ -13,6 +13,7 @@ export default abstract class CalendarTypeFactory {
type: record.type,
nscdr: record.nscdr,
color: record.color,
passphrase: record.passphrase,
};
}

View file

@ -20,6 +20,7 @@ export default abstract class CommunicationFactory {
streetNumberAddition: record.streetNumberAddition,
type: CommunicationTypeFactory.mapToSingle(record.type),
isNewsletterMain: isMain ? isMain : record?.member?.sendNewsletter?.id == record.id,
isSMSAlarming: record.isSMSAlarming,
};
}

View file

@ -27,6 +27,7 @@ export default abstract class MemberFactory {
preferredCommunication: record?.preferredCommunication
? CommunicationFactory.mapToBase(record.preferredCommunication)
: null,
smsAlarming: record?.smsAlarming ? CommunicationFactory.mapToBase(record.smsAlarming) : null,
};
}

View file

@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
export class SMSAlarming1732696919191 implements MigrationInterface {
name = "SMSAlarming1732696919191";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumn(
"communication",
new TableColumn({
name: "isSMSAlarming",
type: "tinyint",
default: 0,
isNullable: false,
})
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn("communication", "isSMSAlarming");
}
}

View file

@ -0,0 +1,20 @@
import { MigrationInterface, QueryRunner, TableColumn } from "typeorm";
export class SecuringCalendarType1733249553766 implements MigrationInterface {
name = "SecuringCalendarType1733249553766";
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.addColumns("calendar_type", [
new TableColumn({
name: "passphrase",
type: "varchar",
length: "255",
isNullable: true,
}),
]);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.dropColumn("calendar_type", "passphrase");
}
}

View file

@ -25,13 +25,13 @@ export default (app: Express) => {
app.use(cors());
app.options("*", cors());
app.use("/public", publicAvailable);
app.use("/setup", allowSetup, setup);
app.use("/reset", reset);
app.use("/invite", invite);
app.use("/auth", auth);
app.use("/api/public", publicAvailable);
app.use("/api/setup", allowSetup, setup);
app.use("/api/reset", reset);
app.use("/api/invite", invite);
app.use("/api/auth", auth);
app.use(authenticate);
app.use("/admin", admin);
app.use("/user", user);
app.use("/api/admin", admin);
app.use("/api/user", user);
app.use(errorHandler);
};

View file

@ -56,6 +56,6 @@ export default abstract class CommunicationService {
static getAvailableColumnsForCommunication(): Array<string> {
let metadata = dataSource.getMetadata(communication);
let columns = metadata.columns.map((c) => c.propertyName);
return columns.filter((c) => !["id", "preferred", "type", "member"].includes(c));
return columns.filter((c) => !["id", "preferred", "isSMSAlarming", "type", "member"].includes(c));
}
}

View file

@ -35,6 +35,8 @@ export default abstract class MemberService {
"preferredCommunication.preferred = 1"
)
.leftJoinAndSelect("preferredCommunication.type", "communicationtype_preferred")
.leftJoinAndMapMany("member.smsAlarming", "member.communications", "smsAlarming", "smsAlarming.isSMSAlarming = 1")
.leftJoinAndSelect("smsAlarming.type", "communicationtype_smsAlarming")
.offset(offset)
.limit(count)
.orderBy("member.lastname")
@ -52,7 +54,7 @@ export default abstract class MemberService {
/**
* @description get member by id
* @param {number} id
* @returns {Promise<Array<member>>}
* @returns {Promise<member>}
*/
static async getById(id: number): Promise<member> {
return await dataSource
@ -80,6 +82,9 @@ export default abstract class MemberService {
"preferredCommunication",
"preferredCommunication.preferred = 1"
)
.leftJoinAndMapMany("member.smsAlarming", "member.communications", "smsAlarming", "smsAlarming.isSMSAlarming = 1")
.leftJoinAndSelect("smsAlarming.type", "communicationtype_smsAlarming")
.leftJoinAndSelect("preferredCommunication.type", "communicationtype_preferred")
.where("member.id = :id", { id: id })
.getOneOrFail()
@ -90,4 +95,24 @@ export default abstract class MemberService {
throw new InternalException("member not found by id", err);
});
}
/**
* @description get newsletter by member by id
* @param {number} id
* @returns {Promise<member>}
*/
static async getNewsletterById(id: number): Promise<member> {
return await dataSource
.getRepository(member)
.createQueryBuilder("member")
.leftJoinAndSelect("member.sendNewsletter", "sendNewsletter")
.where("member.id = :id", { id: id })
.getOneOrFail()
.then((res) => {
return res;
})
.catch((err) => {
throw new InternalException("member not found by id", err);
});
}
}

View file

@ -3,4 +3,5 @@ export interface CalendarTypeViewModel {
type: string;
nscdr: boolean;
color: string;
passphrase: string | null;
}

View file

@ -11,4 +11,5 @@ export interface CommunicationViewModel {
streetNumberAddition: string;
type: CommunicationTypeViewModel;
isNewsletterMain: boolean;
isSMSAlarming: boolean;
}

View file

@ -12,5 +12,6 @@ export interface MemberViewModel {
firstMembershipEntry?: MembershipViewModel;
lastMembershipEntry?: MembershipViewModel;
sendNewsletter?: CommunicationViewModel;
smsAlarming?: Array<CommunicationViewModel>;
preferredCommunication?: Array<CommunicationViewModel>;
}