Merge pull request 'catchup' (#13) from devel into main
Reviewed-on: #13
@@ -0,0 +1,16 @@
|
|||||||
|
name: Deploy API
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Build-Api:
|
||||||
|
runs-on: rainbow-1
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: actions/checkout@v7
|
||||||
|
- run: cd packages/api
|
||||||
|
- run: pnpm install --prod
|
||||||
|
- run: tsc -p .
|
||||||
|
- name: Publish Artifact
|
||||||
|
uses: actions/upload-artifact@v7
|
||||||
|
with:
|
||||||
|
path: ./dist
|
||||||
|
retention-days: 2S
|
||||||
@@ -161,3 +161,5 @@ dist
|
|||||||
.ionide
|
.ionide
|
||||||
|
|
||||||
# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
|
# End of https://www.toptal.com/developers/gitignore/api/node,visualstudiocode
|
||||||
|
packages/api/src/services/keycloak/admin/nodejs-api.prod.json
|
||||||
|
packages/api/src/services/keycloak/user/keycloak.prod.json
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.1.11",
|
"nodemon": "^3.1.14",
|
||||||
"tsoa": "^6.6.0"
|
"tsoa": "^6.6.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,3 +5,10 @@ generated/
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
keycloak/realm-export
|
keycloak/realm-export
|
||||||
|
|
||||||
|
src/cert.pem
|
||||||
|
src/key.pem
|
||||||
|
|
||||||
|
storage
|
||||||
|
|
||||||
|
src/services/push/vapid.json
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
CREATE ROLE webapi WITH
|
||||||
|
LOGIN
|
||||||
|
NOSUPERUSER
|
||||||
|
INHERIT
|
||||||
|
NOCREATEDB
|
||||||
|
NOCREATEROLE
|
||||||
|
NOREPLICATION
|
||||||
|
NOBYPASSRLS
|
||||||
|
PASSWORD 'devpassword';
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public
|
||||||
|
GRANT DELETE, INSERT, SELECT, UPDATE ON TABLES TO webapi;
|
||||||
|
|
||||||
|
ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public
|
||||||
|
GRANT USAGE ON TYPES TO webapi;
|
||||||
|
--GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO webapi;
|
||||||
@@ -4,36 +4,43 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"build": "tsc -p .",
|
"build": "tsc -p .",
|
||||||
"dev": "pnpm -w run dev"
|
"dev": "pnpm -w run dev",
|
||||||
|
"postinstall": "playwright install"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be",
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/connect-pg-simple": "^7.0.3",
|
"@types/connect-pg-simple": "^7.0.3",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.5",
|
"@types/express": "^5.0.6",
|
||||||
"@types/express-session": "^1.18.2",
|
"@types/express-session": "^1.18.2",
|
||||||
"@types/node": "^24.9.2",
|
"@types/node": "^25.3.5",
|
||||||
"@types/pg": "^8.15.6",
|
"@types/pg": "^8.18.0",
|
||||||
"prisma": "^7.0.1",
|
"@types/web-push": "^3.6.4",
|
||||||
"tsx": "^4.20.6",
|
"prisma": "^7.5.0",
|
||||||
|
"tsx": "^4.21.0",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@keycloak/keycloak-admin-client": "^26.4.4",
|
"@keycloak/keycloak-admin-client": "^26.5.5",
|
||||||
"@prisma/adapter-pg": "^7.0.1",
|
"@prisma/adapter-pg": "^7.4.2",
|
||||||
"@prisma/client": "^7.0.1",
|
"@prisma/client": "^7.5.0",
|
||||||
"@tsoa/runtime": "^6.6.0",
|
"@tsoa/runtime": "^6.6.0",
|
||||||
"connect-pg-simple": "^10.0.0",
|
"connect-pg-simple": "^10.0.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.6",
|
||||||
"dotenv": "^17.2.3",
|
"crawlee": "^3.16.0",
|
||||||
"express": "^5.1.0",
|
"cron": "^4.4.0",
|
||||||
"express-session": "^1.18.2",
|
"date-fns": "^4.1.0",
|
||||||
|
"dotenv": "^17.3.1",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"express-session": "^1.19.0",
|
||||||
"helmet": "^8.1.0",
|
"helmet": "^8.1.0",
|
||||||
"keycloak-connect": "^26.1.1",
|
"keycloak-connect": "^26.1.1",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.20.0",
|
||||||
|
"playwright": "^1.58.2",
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "~7.8.2",
|
||||||
"tsoa": "^6.6.0"
|
"tsoa": "^6.6.0",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import "dotenv/config";
|
|||||||
import { defineConfig, env } from "prisma/config";
|
import { defineConfig, env } from "prisma/config";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
schema: "prisma/schema.prisma",
|
schema: "prisma/schema",
|
||||||
migrations: {
|
migrations: {
|
||||||
path: "prisma/migrations",
|
path: "prisma/migrations",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Member" ADD COLUMN "creationDate" DATE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Member" ALTER COLUMN "creationDate" SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FoundEvents" (
|
||||||
|
"id" UUID NOT NULL DEFAULT uuidv7(),
|
||||||
|
"source" VARCHAR NOT NULL,
|
||||||
|
"url" VARCHAR NOT NULL,
|
||||||
|
"foundDate" DATE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"eventDate" DATE NOT NULL,
|
||||||
|
"title" VARCHAR NOT NULL,
|
||||||
|
"description" VARCHAR NOT NULL,
|
||||||
|
"attachedFile" BYTEA NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FoundEvents_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FoundEvents" ALTER COLUMN "eventDate" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "title" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "description" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "attachedFile" DROP NOT NULL,
|
||||||
|
ALTER COLUMN "attachedFile" SET DATA TYPE VARCHAR;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `attachedFile` on the `FoundEvents` table. All the data in the column will be lost.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FoundEvents" DROP COLUMN "attachedFile";
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "FoundEventAttachments" (
|
||||||
|
"id" UUID NOT NULL DEFAULT uuidv7(),
|
||||||
|
"url" VARCHAR NOT NULL,
|
||||||
|
"eventId" UUID NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "FoundEventAttachments_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "FoundEventAttachments" ADD CONSTRAINT "FoundEventAttachments_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "FoundEvents"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FoundEvents" ALTER COLUMN "eventDate" SET DATA TYPE VARCHAR;
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "PushSubscription" (
|
||||||
|
"id" UUID NOT NULL DEFAULT uuidv7(),
|
||||||
|
"endpoint" VARCHAR NOT NULL,
|
||||||
|
"expirationTime" DOUBLE PRECISION,
|
||||||
|
"userSubscriptionId" UUID,
|
||||||
|
"p256dh" VARCHAR NOT NULL,
|
||||||
|
"auth" VARCHAR NOT NULL,
|
||||||
|
"userId" VARCHAR NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "PushSubscription_userSubscriptionId_key" ON "PushSubscription"("userSubscriptionId");
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- You are about to drop the column `userSubscriptionId` on the `PushSubscription` table. All the data in the column will be lost.
|
||||||
|
- Added the required column `clientId` to the `PushSubscription` table without a default value. This is not possible if the table is not empty.
|
||||||
|
- Added the required column `topic` to the `PushSubscription` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- DropIndex
|
||||||
|
DROP INDEX "PushSubscription_userSubscriptionId_key";
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PushSubscription" DROP COLUMN "userSubscriptionId",
|
||||||
|
ADD COLUMN "clientId" UUID NOT NULL,
|
||||||
|
ADD COLUMN "topic" "Topic" NOT NULL,
|
||||||
|
ADD COLUMN "topicConfiguration" JSONB;
|
||||||
|
|
||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "User" (
|
||||||
|
"id" VARCHAR NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/*
|
||||||
|
Warnings:
|
||||||
|
|
||||||
|
- Added the required column `title` to the `FoundEventAttachments` table without a default value. This is not possible if the table is not empty.
|
||||||
|
|
||||||
|
*/
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "FoundEventAttachments" ADD COLUMN "title" VARCHAR NOT NULL;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "Topic" ADD VALUE 'EVENTS';
|
||||||
|
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "PushSubscription" ADD COLUMN "lastNotified" DATE;
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
generator client {
|
|
||||||
provider = "prisma-client"
|
|
||||||
output = "../src/generated/prisma"
|
|
||||||
}
|
|
||||||
|
|
||||||
datasource db {
|
|
||||||
provider = "postgresql"
|
|
||||||
}
|
|
||||||
|
|
||||||
model Member {
|
|
||||||
id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
|
|
||||||
vorname String? @db.VarChar
|
|
||||||
nachname String? @db.VarChar
|
|
||||||
geburtsdatum DateTime? @db.Date
|
|
||||||
geburtsort String? @db.VarChar
|
|
||||||
geschlecht String? @db.VarChar
|
|
||||||
passnummer String? @db.VarChar
|
|
||||||
verein Boolean?
|
|
||||||
graduierung String? @db.VarChar
|
|
||||||
letztePruefung DateTime? @db.Date
|
|
||||||
kontakt String? @db.VarChar
|
|
||||||
kuendigungzum DateTime? @db.Date
|
|
||||||
marker Boolean?
|
|
||||||
}
|
|
||||||
|
|
||||||
model session {
|
|
||||||
sid String @id @db.VarChar
|
|
||||||
sess Json @db.Json
|
|
||||||
expire DateTime @db.Timestamp(6)
|
|
||||||
|
|
||||||
@@index([expire], map: "IDX_session_expire")
|
|
||||||
}
|
|
||||||
|
|
||||||
enum Topic {
|
|
||||||
TEST
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
model Exam {
|
||||||
|
id String @id @default(dbgenerated("uuidv7()"))
|
||||||
|
date DateTime @db.Date
|
||||||
|
title String @db.VarChar
|
||||||
|
memberExams MemberExam[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
model FoundEventAttachments {
|
||||||
|
id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
|
||||||
|
title String @db.VarChar
|
||||||
|
url String @db.VarChar
|
||||||
|
event FoundEvents @relation(fields: [eventId], references: [id])
|
||||||
|
eventId String @db.Uuid
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
model FoundEvents {
|
||||||
|
id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
|
||||||
|
source String @db.VarChar
|
||||||
|
url String @db.VarChar
|
||||||
|
foundDate DateTime @default(now()) @db.Date
|
||||||
|
eventDate String? @db.VarChar
|
||||||
|
title String? @db.VarChar
|
||||||
|
description String? @db.VarChar
|
||||||
|
attachedFiles FoundEventAttachments[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
model Member {
|
||||||
|
id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
|
||||||
|
vorname String? @db.VarChar
|
||||||
|
nachname String? @db.VarChar
|
||||||
|
geburtsdatum DateTime? @db.Date
|
||||||
|
geburtsort String? @db.VarChar
|
||||||
|
geschlecht String? @db.VarChar
|
||||||
|
passnummer String? @db.VarChar
|
||||||
|
verein Boolean?
|
||||||
|
graduierung String? @db.VarChar
|
||||||
|
letztePruefung DateTime? @db.Date
|
||||||
|
kontakt String? @db.VarChar
|
||||||
|
kuendigungzum DateTime? @db.Date
|
||||||
|
marker Boolean?
|
||||||
|
creationDate DateTime? @default(now()) @db.Date
|
||||||
|
memberExams MemberExam[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
model MemberExam {
|
||||||
|
id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
|
||||||
|
targetGrade String @db.VarChar
|
||||||
|
|
||||||
|
memberId String @db.Uuid
|
||||||
|
member Member @relation(fields: [memberId], references: [id])
|
||||||
|
|
||||||
|
examId String @db.Uuid
|
||||||
|
exam Exam @relation(fields: [examId], references: [id])
|
||||||
|
|
||||||
|
passed Boolean?
|
||||||
|
comment String? @db.VarChar
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
model PushSubscription {
|
||||||
|
id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
|
||||||
|
endpoint String @db.VarChar
|
||||||
|
expirationTime Float?
|
||||||
|
p256dh String @db.VarChar
|
||||||
|
auth String @db.VarChar
|
||||||
|
|
||||||
|
userId String @db.VarChar
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
|
|
||||||
|
topic Topic
|
||||||
|
topicConfiguration Json? @db.JsonB
|
||||||
|
clientId String @db.Uuid
|
||||||
|
lastNotified DateTime? @db.Date
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
generator client {
|
||||||
|
provider = "prisma-client"
|
||||||
|
output = "../../src/generated/prisma"
|
||||||
|
}
|
||||||
|
|
||||||
|
datasource db {
|
||||||
|
provider = "postgresql"
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
model session {
|
||||||
|
sid String @id @db.VarChar
|
||||||
|
sess Json @db.Json
|
||||||
|
expire DateTime @db.Timestamp(6)
|
||||||
|
|
||||||
|
@@index([expire], map: "IDX_session_expire")
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
enum Topic {
|
||||||
|
TEST
|
||||||
|
EVENTS
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
model User {
|
||||||
|
id String @id @db.VarChar
|
||||||
|
subscriptions PushSubscription[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import express from "express";
|
||||||
|
import { Body, Controller, Get, Path, Post, Query, Request, Route } from "tsoa";
|
||||||
|
import type {
|
||||||
|
ExamCreationDto,
|
||||||
|
ExamDto,
|
||||||
|
ExamWithMembersDto,
|
||||||
|
MemberExamCreationDto,
|
||||||
|
MemberExamDto,
|
||||||
|
} from "../dtos/examDto.js";
|
||||||
|
import { inject } from "../infrastructure/di/index.js";
|
||||||
|
import { ExamsService } from "../services/examsService.js";
|
||||||
|
|
||||||
|
@Route("exams")
|
||||||
|
export class ExamsController extends Controller {
|
||||||
|
private readonly examsService = inject(ExamsService);
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
public async createExam(
|
||||||
|
@Request() request: express.Request,
|
||||||
|
@Body() newExam: ExamCreationDto,
|
||||||
|
): Promise<ExamDto> {
|
||||||
|
return await this.examsService.createExam(newExam, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("latest")
|
||||||
|
public async getLatestExam(
|
||||||
|
@Request() request: express.Request,
|
||||||
|
): Promise<ExamWithMembersDto | null> {
|
||||||
|
return await this.examsService.getLatest(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("register/{examId}")
|
||||||
|
public async registerForExam(
|
||||||
|
@Request() request: express.Request,
|
||||||
|
@Body() newRegistration: MemberExamCreationDto
|
||||||
|
): Promise<MemberExamDto> {
|
||||||
|
return await this.examsService.register(newRegistration, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Request,
|
||||||
|
Route,
|
||||||
|
Security,
|
||||||
|
SuccessResponse,
|
||||||
|
} from "tsoa";
|
||||||
|
import type { FoundEventDto } from "../dtos/foundEvents.js";
|
||||||
|
import { inject } from "../infrastructure/di/index.js";
|
||||||
|
import { FoundEventsService } from "../services/foundEvents/foundEventsService.js";
|
||||||
|
import { KC_SECURITY_NAME } from "../services/keycloak/user/keycloak-user.js";
|
||||||
|
|
||||||
|
@Route("found-events")
|
||||||
|
export class FoundEventsController extends Controller {
|
||||||
|
private readonly foundEventsService = inject(FoundEventsService);
|
||||||
|
|
||||||
|
@Get("future")
|
||||||
|
@Security(KC_SECURITY_NAME)
|
||||||
|
@SuccessResponse("200", "OK")
|
||||||
|
public async getFutureEvents(
|
||||||
|
@Request() request: express.Request,
|
||||||
|
): Promise<FoundEventDto[]> {
|
||||||
|
return await this.foundEventsService.getFutureEvents(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -103,7 +103,7 @@ export class MembersContoller extends Controller {
|
|||||||
) {
|
) {
|
||||||
await this.membersService.scheduleDeletion(
|
await this.membersService.scheduleDeletion(
|
||||||
memberId,
|
memberId,
|
||||||
cancelDate,
|
cancelDate ? cancelDate : null,
|
||||||
request,
|
request,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import express from "express";
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
Request,
|
||||||
|
Route,
|
||||||
|
Security,
|
||||||
|
SuccessResponse,
|
||||||
|
} from "tsoa";
|
||||||
|
import type { PushSubscriptionCreateArgs as PushSubscriptionCreateArgsDto } from "../dtos/pushSubscription.js";
|
||||||
|
import { inject } from "../infrastructure/di/injector.js";
|
||||||
|
import { KC_SECURITY_NAME } from "../services/keycloak/user/keycloak-user.js";
|
||||||
|
import { PushService } from "../services/push/pushService.js";
|
||||||
|
|
||||||
|
@Route("push")
|
||||||
|
export class PushContoller extends Controller {
|
||||||
|
private readonly pushService = inject(PushService);
|
||||||
|
|
||||||
|
@Post("add")
|
||||||
|
@Security(KC_SECURITY_NAME)
|
||||||
|
@SuccessResponse("200", "OK")
|
||||||
|
public async addSubscription(
|
||||||
|
@Body() subscription: PushSubscriptionCreateArgsDto,
|
||||||
|
@Request() request: express.Request,
|
||||||
|
): Promise<void> {
|
||||||
|
return await this.pushService.storeSubscription(
|
||||||
|
subscription,
|
||||||
|
request,
|
||||||
|
request.res,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("clearSingle")
|
||||||
|
@Security(KC_SECURITY_NAME)
|
||||||
|
@SuccessResponse("200", "OK")
|
||||||
|
public async clearSubscription(
|
||||||
|
@Request() request: express.Request,
|
||||||
|
@Query() clientId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.pushService.clearSubscription(clientId ?? null, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Controller, Post, Route, Security, SuccessResponse } from "tsoa";
|
||||||
|
import { Claim } from "../dtos/claim.js";
|
||||||
|
import { inject } from "../infrastructure/di/index.js";
|
||||||
|
import { FoundEventsService } from "../services/foundEvents/foundEventsService.js";
|
||||||
|
import { KC_SECURITY_NAME } from "../services/keycloak/user/keycloak-user.js";
|
||||||
|
import { PushService } from "../services/push/pushService.js";
|
||||||
|
|
||||||
|
@Route("test")
|
||||||
|
export class TestContoller extends Controller {
|
||||||
|
private readonly pushService = inject(PushService);
|
||||||
|
private readonly foundEventsService = inject(FoundEventsService);
|
||||||
|
|
||||||
|
@Post("notify")
|
||||||
|
@Security(KC_SECURITY_NAME, [Claim.UserAdmin])
|
||||||
|
@SuccessResponse("200", "OK")
|
||||||
|
public async notifyEvents(): Promise<void> {
|
||||||
|
await this.pushService.sendEventNotifications();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post("crawl")
|
||||||
|
@Security(KC_SECURITY_NAME)
|
||||||
|
@SuccessResponse("200", "OK")
|
||||||
|
public async crawl(): Promise<void> {
|
||||||
|
await this.foundEventsService.crawl();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,11 +13,11 @@ import { Claim } from "../dtos/claim.js";
|
|||||||
import { type User } from "../dtos/user.js";
|
import { type User } from "../dtos/user.js";
|
||||||
import { inject } from "../infrastructure/di/index.js";
|
import { inject } from "../infrastructure/di/index.js";
|
||||||
import { KC_SECURITY_NAME } from "../services/keycloak/user/index.js";
|
import { KC_SECURITY_NAME } from "../services/keycloak/user/index.js";
|
||||||
import { UserService } from "../services/userService.js";
|
import { UserAdminService } from "../services/userAdminService.js";
|
||||||
|
|
||||||
@Route("users")
|
@Route("users")
|
||||||
export class UsersController extends Controller {
|
export class UsersController extends Controller {
|
||||||
private readonly userService = inject(UserService);
|
private readonly userService = inject(UserAdminService);
|
||||||
|
|
||||||
@Security(KC_SECURITY_NAME, [Claim.UserAdmin])
|
@Security(KC_SECURITY_NAME, [Claim.UserAdmin])
|
||||||
@SuccessResponse("200", "OK")
|
@SuccessResponse("200", "OK")
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
type ExamModel,
|
||||||
|
type MemberExamModel,
|
||||||
|
} from "../generated/prisma/models.js";
|
||||||
|
|
||||||
|
export class ExamCreationDto implements Omit<ExamModel, "id"> {
|
||||||
|
date!: Date;
|
||||||
|
title!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExamDto implements ExamModel {
|
||||||
|
id!: string;
|
||||||
|
date!: Date;
|
||||||
|
title!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemberExamDto implements MemberExamModel {
|
||||||
|
id!: string;
|
||||||
|
targetGrade!: string;
|
||||||
|
memberId!: string;
|
||||||
|
examId!: string;
|
||||||
|
passed!: boolean | null;
|
||||||
|
comment!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ExamWithMembersDto implements ExamModel {
|
||||||
|
id!: string;
|
||||||
|
date!: Date;
|
||||||
|
title!: string;
|
||||||
|
memberExams!: MemberExamDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemberExamCreationDto implements Omit<
|
||||||
|
MemberExamModel,
|
||||||
|
"id" | "passed" | "comment"
|
||||||
|
> {
|
||||||
|
targetGrade!: string;
|
||||||
|
memberId!: string;
|
||||||
|
examId!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type {
|
||||||
|
FoundEventAttachments,
|
||||||
|
FoundEvents,
|
||||||
|
} from "../generated/prisma/client.js";
|
||||||
|
|
||||||
|
export class FoundEventAttachmentDto implements FoundEventAttachments {
|
||||||
|
id!: string;
|
||||||
|
title!: string;
|
||||||
|
url!: string;
|
||||||
|
eventId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FoundEventDto implements FoundEvents {
|
||||||
|
id!: string;
|
||||||
|
source!: string;
|
||||||
|
url!: string;
|
||||||
|
foundDate!: Date;
|
||||||
|
eventDate!: string | null;
|
||||||
|
title!: string | null;
|
||||||
|
description!: string | null;
|
||||||
|
attachedFiles!: FoundEventAttachmentDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FoundEventCreateArgs implements Omit<
|
||||||
|
FoundEvents,
|
||||||
|
"id" | "foundDate"
|
||||||
|
> {
|
||||||
|
source!: string;
|
||||||
|
url!: string;
|
||||||
|
eventDate!: string | null;
|
||||||
|
title!: string | null;
|
||||||
|
description!: string | null;
|
||||||
|
}
|
||||||
@@ -1,51 +1,54 @@
|
|||||||
import { type MemberModel as DbMember } from "../generated/prisma/models.js";
|
import { type MemberModel as DbMember } from "../generated/prisma/models.js";
|
||||||
|
|
||||||
export interface MemberDto {
|
export interface MemberDto {
|
||||||
id: string | null;
|
id: string;
|
||||||
vorname: string | null;
|
vorname: string;
|
||||||
nachname: string | null;
|
nachname: string;
|
||||||
geburtsdatum: string | null;
|
geburtsdatum: string;
|
||||||
geburtsort: string | null;
|
geburtsort: string;
|
||||||
geschlecht: string | null;
|
geschlecht: string;
|
||||||
passnummer: string | null;
|
passnummer: string;
|
||||||
verein: boolean | null;
|
verein: boolean;
|
||||||
graduierung: string | null;
|
graduierung: string;
|
||||||
letztePruefung: string | null;
|
letztePruefung: string;
|
||||||
kontakt: string | null;
|
kontakt: string;
|
||||||
kuendigungzum: string | null;
|
kuendigungzum: string;
|
||||||
marker: boolean | null;
|
marker: boolean;
|
||||||
|
creationDate: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MemberCreateArgs implements Omit<MemberDto, "id"> {
|
export class MemberCreateArgs implements Omit<
|
||||||
vorname!: string | null;
|
MemberDto,
|
||||||
nachname!: string | null;
|
"id" | "kuendigungzum" | "letztePruefung" | "creationDate"
|
||||||
geburtsdatum!: string | null;
|
> {
|
||||||
geburtsort!: string | null;
|
vorname!: string;
|
||||||
geschlecht!: string | null;
|
nachname!: string;
|
||||||
passnummer!: string | null;
|
geburtsdatum!: string;
|
||||||
verein!: boolean | null;
|
geburtsort!: string;
|
||||||
graduierung!: string | null;
|
geschlecht!: string;
|
||||||
letztePruefung!: string | null;
|
passnummer!: string;
|
||||||
kontakt!: string | null;
|
verein!: boolean;
|
||||||
kuendigungzum!: string | null;
|
graduierung!: string;
|
||||||
marker!: boolean | null;
|
kontakt!: string;
|
||||||
|
marker!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapDbMemberToMember(dbMember: DbMember): MemberDto {
|
export function mapDbMemberToMember(dbMember: DbMember): MemberDto {
|
||||||
return {
|
return {
|
||||||
geburtsdatum: dbMember.geburtsdatum?.toDateString() ?? null,
|
geburtsdatum: dbMember.geburtsdatum?.toDateString() ?? "",
|
||||||
id: dbMember.id,
|
id: dbMember.id,
|
||||||
nachname: dbMember.nachname,
|
nachname: dbMember.nachname ?? "",
|
||||||
geburtsort: dbMember.geburtsort,
|
geburtsort: dbMember.geburtsort ?? "",
|
||||||
geschlecht: dbMember.geschlecht,
|
geschlecht: dbMember.geschlecht ?? "",
|
||||||
graduierung: dbMember.graduierung,
|
graduierung: dbMember.graduierung ?? "",
|
||||||
vorname: dbMember.vorname,
|
vorname: dbMember.vorname ?? "",
|
||||||
kontakt: dbMember.kontakt,
|
kontakt: dbMember.kontakt ?? "",
|
||||||
kuendigungzum: dbMember.kuendigungzum?.toDateString() ?? null,
|
kuendigungzum: dbMember.kuendigungzum?.toDateString() ?? "",
|
||||||
letztePruefung: dbMember.letztePruefung?.toDateString() ?? null,
|
letztePruefung: dbMember.letztePruefung?.toDateString() ?? "",
|
||||||
passnummer: dbMember.passnummer,
|
passnummer: dbMember.passnummer ?? "",
|
||||||
verein: dbMember.verein,
|
verein: dbMember.verein ?? false,
|
||||||
marker: dbMember.marker,
|
marker: dbMember.marker ?? false,
|
||||||
|
creationDate: dbMember.creationDate?.toDateString() ?? "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,5 +73,8 @@ export function mapMemberDtoToDbMember(member: MemberDto): DbMember {
|
|||||||
? new Date(member.kuendigungzum)
|
? new Date(member.kuendigungzum)
|
||||||
: null,
|
: null,
|
||||||
marker: member.marker,
|
marker: member.marker,
|
||||||
|
creationDate: member.creationDate
|
||||||
|
? new Date(member.creationDate)
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { JsonValue } from "@prisma/client/runtime/client";
|
||||||
|
import type { Topic } from "./topic.js";
|
||||||
|
|
||||||
|
export class PushSubscriptionDto {
|
||||||
|
id!: string;
|
||||||
|
endpoint!: string;
|
||||||
|
expirationTime!: number | null;
|
||||||
|
keys!: {
|
||||||
|
p256dh: string;
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
|
topic!: "TEST";
|
||||||
|
topicConfiguration!: JsonValue | null;
|
||||||
|
clientId!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PushSubscriptionCreateArgs /* implements Omit<PushSubscriptionDto, "id">*/ {
|
||||||
|
endpoint!: string;
|
||||||
|
expirationTime!: number | null;
|
||||||
|
keys!: { p256dh: string; auth: string };
|
||||||
|
topic!: Topic;
|
||||||
|
topicConfiguration!: string | null;
|
||||||
|
clientId!: string;
|
||||||
|
}
|
||||||
@@ -1,3 +1 @@
|
|||||||
export enum Topic {
|
export { type Topic } from "../generated/prisma/client.js";
|
||||||
test = "test",
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import constants from "constants";
|
import constants from "constants";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
|
import { CronJob } from "cron";
|
||||||
import express, {
|
import express, {
|
||||||
type NextFunction,
|
type NextFunction,
|
||||||
type Request,
|
type Request,
|
||||||
@@ -11,7 +12,9 @@ import { RegisterRoutes } from "./generated/routes.js";
|
|||||||
import { inject } from "./infrastructure/di/injector.js";
|
import { inject } from "./infrastructure/di/injector.js";
|
||||||
import { sessionHandler } from "./infrastructure/sessionHandler.js";
|
import { sessionHandler } from "./infrastructure/sessionHandler.js";
|
||||||
import { EnvironmentService } from "./services/environmentService.js";
|
import { EnvironmentService } from "./services/environmentService.js";
|
||||||
|
import { FoundEventsService } from "./services/foundEvents/foundEventsService.js";
|
||||||
import { KeycloakUser } from "./services/keycloak/user/keycloak-user.js";
|
import { KeycloakUser } from "./services/keycloak/user/keycloak-user.js";
|
||||||
|
import { PushService } from "./services/push/pushService.js";
|
||||||
// import helmet from "helmet";
|
// import helmet from "helmet";
|
||||||
// TODO import http2 from 'http2';
|
// TODO import http2 from 'http2';
|
||||||
|
|
||||||
@@ -34,24 +37,12 @@ async function run() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use(sessionHandler);
|
app.use(sessionHandler);
|
||||||
|
|
||||||
app.use(await keycloakUser.middleware());
|
app.use(await keycloakUser.middleware());
|
||||||
|
|
||||||
app.use(express.urlencoded({ extended: true }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// const dbService = inject(DatabaseService);
|
|
||||||
// app.use(async (req, res, next) => {
|
|
||||||
// await dbService.createTransaction(async prisma => {
|
|
||||||
// (req as any).transaction = prisma;
|
|
||||||
// return new Promise<void>((resolve, reject) => {
|
|
||||||
// res.on("finish", resolve);
|
|
||||||
// res.on("close", resolve);
|
|
||||||
// res.on("error", reject);
|
|
||||||
// next();
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
// });
|
|
||||||
|
|
||||||
RegisterRoutes(app);
|
RegisterRoutes(app);
|
||||||
|
|
||||||
const environment = inject(EnvironmentService);
|
const environment = inject(EnvironmentService);
|
||||||
@@ -66,25 +57,41 @@ async function run() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
if (environment.isDev()) {
|
const isDev = environment.isDev();
|
||||||
//TODO create self-signed for localhost
|
|
||||||
const port = 3000;
|
let cert: https.ServerOptions["cert"];
|
||||||
app.listen(port, () => {
|
let key: https.ServerOptions["key"];
|
||||||
|
let SSL_KEY_PASSWORD: string | undefined;
|
||||||
|
let port: number;
|
||||||
|
|
||||||
|
if (isDev) {
|
||||||
|
//TODO start with HTTPS in dev
|
||||||
|
// cert = fs.readFileSync("src/cert.pem");
|
||||||
|
// key = fs.readFileSync("src/key.pem");
|
||||||
|
|
||||||
|
port = 3000;
|
||||||
|
const server = app.listen(port, error => {
|
||||||
|
if (error) {
|
||||||
|
console.error("Startup crashed:", error);
|
||||||
|
}
|
||||||
console.log(`Server is running at http://localhost:${port}`);
|
console.log(`Server is running at http://localhost:${port}`);
|
||||||
});
|
});
|
||||||
|
server.on("close", () => stopCron());
|
||||||
} else {
|
} else {
|
||||||
//TODO start with HTTPS in production
|
port = 5443;
|
||||||
const port = 5443;
|
|
||||||
const SSL_CERT_PATH = process.env["SSL_CERT_PATH"];
|
const SSL_CERT_PATH = process.env["SSL_CERT_PATH"];
|
||||||
const SSL_KEY_PATH = process.env["SSL_KEY_PATH"];
|
const SSL_KEY_PATH = process.env["SSL_KEY_PATH"];
|
||||||
const SSL_KEY_PASSWORD = process.env["SSL_KEY_PASSWORD"];
|
SSL_KEY_PASSWORD = process.env["SSL_KEY_PASSWORD"];
|
||||||
if (!SSL_CERT_PATH || !SSL_KEY_PATH || !SSL_KEY_PASSWORD) {
|
if (!SSL_CERT_PATH || !SSL_KEY_PATH || !SSL_KEY_PASSWORD) {
|
||||||
throw new Error("Missing SSL configuration");
|
throw new Error("Missing SSL configuration");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cert = fs.readFileSync(SSL_CERT_PATH);
|
||||||
|
key = fs.readFileSync(SSL_KEY_PATH);
|
||||||
|
|
||||||
const sslOptions: https.ServerOptions = {
|
const sslOptions: https.ServerOptions = {
|
||||||
cert: fs.readFileSync(SSL_CERT_PATH),
|
cert: cert,
|
||||||
key: fs.readFileSync(SSL_KEY_PATH),
|
key: key,
|
||||||
passphrase: SSL_KEY_PASSWORD,
|
passphrase: SSL_KEY_PASSWORD,
|
||||||
minVersion: "TLSv1.3",
|
minVersion: "TLSv1.3",
|
||||||
maxVersion: "TLSv1.3",
|
maxVersion: "TLSv1.3",
|
||||||
@@ -97,17 +104,56 @@ async function run() {
|
|||||||
constants.SSL_OP_NO_SSLv2 |
|
constants.SSL_OP_NO_SSLv2 |
|
||||||
constants.SSL_OP_NO_SSLv3,
|
constants.SSL_OP_NO_SSLv3,
|
||||||
};
|
};
|
||||||
https.createServer(sslOptions, app).listen(port, () => {
|
const server = https.createServer(sslOptions, app).listen(port, () => {
|
||||||
console.log(`Server is running on port ${port}`);
|
console.log(`Server is running on port ${port}`);
|
||||||
});
|
});
|
||||||
|
server.on("close", () => stopCron());
|
||||||
|
}
|
||||||
|
|
||||||
// const h2SslOptions={};
|
// const h2SslOptions={};
|
||||||
// http2.createServer(h2SslOptions, app).listen(port, ()=>{
|
// http2.createServer(h2SslOptions, app).listen(port, ()=>{
|
||||||
// console.log(`HTTP/2 server is running on port ${port}`);
|
// console.log(`HTTP/2 server is running on port ${port}`);
|
||||||
// })
|
// })
|
||||||
|
initJobs();
|
||||||
|
startCron();
|
||||||
|
|
||||||
|
console.log("setup done");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log("done");
|
const jobs: CronJob[] = [];
|
||||||
|
|
||||||
|
function initJobs() {
|
||||||
|
const eventsService = inject(FoundEventsService);
|
||||||
|
const crawlJob = CronJob.from({
|
||||||
|
cronTime: "35 4 * * *",
|
||||||
|
onTick: async () => {
|
||||||
|
await eventsService.crawl();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
jobs.push(crawlJob);
|
||||||
|
|
||||||
|
const pushService = inject(PushService);
|
||||||
|
const notifyJob = CronJob.from({
|
||||||
|
cronTime: "10 12 * * *",
|
||||||
|
onTick: async () => {
|
||||||
|
await pushService.sendEventNotifications();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
jobs.push(notifyJob);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startCron() {
|
||||||
|
console.log("starting jobs");
|
||||||
|
for (const job of jobs) {
|
||||||
|
job.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopCron() {
|
||||||
|
console.log("stopping jobs");
|
||||||
|
for (const job of jobs) {
|
||||||
|
job.stop();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
run();
|
run();
|
||||||
|
|||||||
@@ -1,13 +1,10 @@
|
|||||||
import { PrismaPg } from "@prisma/adapter-pg";
|
import { PrismaPg } from "@prisma/adapter-pg";
|
||||||
import { type Request } from "express";
|
import { type Request } from "express";
|
||||||
import { PrismaClient } from "../../generated/prisma/client.js";
|
import { PrismaClient } from "../../generated/prisma/client.js";
|
||||||
|
import type { UserModel } from "../../generated/prisma/models.js";
|
||||||
import { Injectable } from "../../infrastructure/di/injectable-decorator.js";
|
import { Injectable } from "../../infrastructure/di/injectable-decorator.js";
|
||||||
|
|
||||||
type ExtendedClient = ReturnType<typeof createExtendedClient>;
|
type ExtendedClient = ReturnType<typeof createExtendedClient>;
|
||||||
// type TransactionClient = Omit<
|
|
||||||
// ExtendedClient,
|
|
||||||
// "$connect" | "$disconnect" | "$on" | "$transaction" | "$extends"
|
|
||||||
// >;
|
|
||||||
|
|
||||||
export type SafeClient = Omit<
|
export type SafeClient = Omit<
|
||||||
ExtendedClient,
|
ExtendedClient,
|
||||||
@@ -23,20 +20,19 @@ export type SafeClient = Omit<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
function createExtendedClient(client: PrismaClient) {
|
function createExtendedClient(client: PrismaClient) {
|
||||||
return client;
|
return client.$extends({
|
||||||
// .$extends({
|
client: {
|
||||||
// name: "testExtension",
|
async ensureUserKnown(userId: string): Promise<UserModel> {
|
||||||
// model: {
|
let user = await client.user.findUnique({
|
||||||
// member: {
|
where: { id: userId },
|
||||||
// withFullName(member: Member) {
|
});
|
||||||
// return {
|
if (user == null) {
|
||||||
// ...member,
|
user = await client.user.create({ data: { id: userId } });
|
||||||
// fullName: ` ${member.vorname} ${member.nachname}`,
|
}
|
||||||
// };
|
return user;
|
||||||
// },
|
},
|
||||||
// },
|
},
|
||||||
// },
|
});
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -56,10 +52,11 @@ export class DatabaseService {
|
|||||||
|
|
||||||
public async doRequest<T>(
|
public async doRequest<T>(
|
||||||
command: (prisma: SafeClient) => Promise<T>,
|
command: (prisma: SafeClient) => Promise<T>,
|
||||||
request: Request,
|
request: Request | null,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const transaction =
|
const transaction: SafeClient | null = (request as any | null)
|
||||||
(request as any).transaction ?? (this.prismaClient as SafeClient);
|
?.transaction;
|
||||||
return await command(transaction);
|
const effectiveClient = transaction ?? this.prismaClient;
|
||||||
|
return await command(effectiveClient);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import type { Request } from "express";
|
||||||
|
import type {
|
||||||
|
ExamCreationDto,
|
||||||
|
ExamDto,
|
||||||
|
ExamWithMembersDto,
|
||||||
|
MemberExamCreationDto,
|
||||||
|
MemberExamDto,
|
||||||
|
} from "../dtos/examDto.js";
|
||||||
|
import { inject } from "../infrastructure/di/index.js";
|
||||||
|
import { Injectable } from "../infrastructure/di/injectable-decorator.js";
|
||||||
|
import { DatabaseService } from "./db/prisma.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ExamsService {
|
||||||
|
private readonly db = inject(DatabaseService);
|
||||||
|
public async createExam(
|
||||||
|
newExam: ExamCreationDto,
|
||||||
|
request: Request,
|
||||||
|
): Promise<ExamDto> {
|
||||||
|
return await this.db.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
prisma.exam.create({
|
||||||
|
data: newExam,
|
||||||
|
}),
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
public async getLatest(
|
||||||
|
request: Request,
|
||||||
|
): Promise<ExamWithMembersDto | null> {
|
||||||
|
return await this.db.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
prisma.exam.findFirst({
|
||||||
|
orderBy: { date: "desc" },
|
||||||
|
include: { memberExams: true },
|
||||||
|
}),
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async register(
|
||||||
|
creationDto: MemberExamCreationDto,
|
||||||
|
request: Request,
|
||||||
|
): Promise<MemberExamDto> {
|
||||||
|
return await this.db.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
prisma.memberExam.create({
|
||||||
|
data: {
|
||||||
|
examId: creationDto.examId,
|
||||||
|
memberId: creationDto.memberId,
|
||||||
|
targetGrade: creationDto.targetGrade,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { PlaywrightCrawler } from "crawlee";
|
||||||
|
import * as dateFns from "date-fns";
|
||||||
|
import type { Request } from "express";
|
||||||
|
import type { FoundEventDto } from "../../dtos/foundEvents.js";
|
||||||
|
import { inject } from "../../infrastructure/di/index.js";
|
||||||
|
import { Injectable } from "../../infrastructure/di/injectable-decorator.js";
|
||||||
|
import { DatabaseService } from "../db/prisma.js";
|
||||||
|
import { EnvironmentService } from "../environmentService.js";
|
||||||
|
import { parseTUS, parseTUSDetail } from "./parseTUS.js";
|
||||||
|
import { FoundEventSource } from "./sources.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FoundEventsService {
|
||||||
|
private readonly environmnent = inject(EnvironmentService);
|
||||||
|
private readonly database = inject(DatabaseService);
|
||||||
|
private readonly crawler: PlaywrightCrawler;
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
const top = this;
|
||||||
|
|
||||||
|
this.crawler = new PlaywrightCrawler({
|
||||||
|
maxRequestsPerMinute: 100,
|
||||||
|
maxConcurrency: 2,
|
||||||
|
requestHandler: async ({ request, page, log }) => {
|
||||||
|
// if (request.retryCount > 0) return;
|
||||||
|
|
||||||
|
const label = request.label;
|
||||||
|
|
||||||
|
switch (label) {
|
||||||
|
case "TUS":
|
||||||
|
const tusDetails = await parseTUS(page, top.database);
|
||||||
|
await top.crawler.addRequests(
|
||||||
|
tusDetails.map(url => ({
|
||||||
|
url: url,
|
||||||
|
label: "TUS_DETAIL",
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
case "TUS_DETAIL":
|
||||||
|
await parseTUSDetail(
|
||||||
|
request.loadedUrl,
|
||||||
|
page,
|
||||||
|
top.database,
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
// case "DTU":
|
||||||
|
// events = parseDTU(page);
|
||||||
|
// break;
|
||||||
|
// case "BTU":
|
||||||
|
// events = parseBTU(page);
|
||||||
|
// break;
|
||||||
|
// case "MAGOSCH":
|
||||||
|
// events = parseMAGOSCH(page);
|
||||||
|
// break;
|
||||||
|
default:
|
||||||
|
log.warning(`Unknown source ${label}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
headless: !this.environmnent.isDev(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async crawl() {
|
||||||
|
const sources = Object.entries(FoundEventSource).map(([key, url]) => ({
|
||||||
|
url,
|
||||||
|
label: key,
|
||||||
|
}));
|
||||||
|
return await this.crawler.run(sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getFutureEvents(request: Request): Promise<FoundEventDto[]> {
|
||||||
|
const allEvents = await this.database.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
prisma.foundEvents.findMany({
|
||||||
|
include: {
|
||||||
|
attachedFiles: true,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
|
||||||
|
const today = new Date(Date.now());
|
||||||
|
return allEvents.filter(event => {
|
||||||
|
const parts = event.eventDate?.split("-");
|
||||||
|
let date: Date | null = null;
|
||||||
|
if (!parts) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (parts.length == 1) {
|
||||||
|
// like '10.05.2012'
|
||||||
|
date = dateFns.parse(parts[0]!, "dd.MM.yyyy", new Date());
|
||||||
|
}
|
||||||
|
if (parts.length == 2) {
|
||||||
|
// like '12.-25.04.2026'
|
||||||
|
date = dateFns.parse(parts[1]!, "dd.MM.yyyy", new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!date) {
|
||||||
|
console.warn("Unknown Date Format", event.eventDate);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return isNaN(date.valueOf()) || date >= today;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { Page } from "playwright";
|
||||||
|
import { expect } from "playwright/test";
|
||||||
|
import type { DatabaseService } from "../db/prisma.js";
|
||||||
|
import { FoundEventSource } from "./sources.js";
|
||||||
|
|
||||||
|
export async function parseTUS(
|
||||||
|
page: Page,
|
||||||
|
database: DatabaseService,
|
||||||
|
): Promise<string[]> {
|
||||||
|
let rowLocators = await page.locator("table").locator("tr").all();
|
||||||
|
rowLocators = rowLocators.filter(
|
||||||
|
// skip --- and header rows
|
||||||
|
(_row, index) => index > 0 && index % 2 === 0,
|
||||||
|
);
|
||||||
|
const links = await Promise.all(
|
||||||
|
rowLocators.map(async row => {
|
||||||
|
return {
|
||||||
|
url: await row.locator("a").getAttribute("href"),
|
||||||
|
title: await row.locator("td").nth(1).textContent(),
|
||||||
|
date: await row.locator("td").nth(0).textContent(),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const urls = links.map(link => link.url).filter(url => url != null);
|
||||||
|
const existingEvents = await database.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
prisma.foundEvents.findMany({
|
||||||
|
where: {
|
||||||
|
url: { in: urls },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const newLinks = links.filter(
|
||||||
|
newEvent =>
|
||||||
|
!existingEvents.some(
|
||||||
|
oldEvent =>
|
||||||
|
oldEvent.url === newEvent.url ||
|
||||||
|
(oldEvent.title === newEvent.title &&
|
||||||
|
oldEvent.eventDate === newEvent.date),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const datePages = newLinks.map(link => link.url).filter(url => url != null);
|
||||||
|
return datePages;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function parseTUSDetail(
|
||||||
|
pageUrl: string,
|
||||||
|
page: Page,
|
||||||
|
database: DatabaseService,
|
||||||
|
): Promise<void> {
|
||||||
|
const content = page.locator("div#content");
|
||||||
|
const title = await content.locator("h1").textContent();
|
||||||
|
expect(content.locator("td").nth(0)).toHaveText("Datum:");
|
||||||
|
const dateString = await content.locator("td").nth(1).textContent();
|
||||||
|
const description = (await content.locator("span").allTextContents()).join(
|
||||||
|
" ",
|
||||||
|
);
|
||||||
|
const attachedFileUrls = (
|
||||||
|
await Promise.all(
|
||||||
|
(await content.locator("a").all()).map(async locator => {
|
||||||
|
const url = await locator.getAttribute("href");
|
||||||
|
const isAbsolute = url?.includes("://");
|
||||||
|
const aboluteUrl =
|
||||||
|
url != null
|
||||||
|
? isAbsolute
|
||||||
|
? new URL(url).toString()
|
||||||
|
: new URL(url, pageUrl).toString()
|
||||||
|
: "";
|
||||||
|
return {
|
||||||
|
url: aboluteUrl,
|
||||||
|
title: (await locator.textContent()) ?? "",
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
).filter(url => url != null);
|
||||||
|
|
||||||
|
await database.doRequest(async prisma => {
|
||||||
|
const newEvent = await prisma.foundEvents.create({
|
||||||
|
data: {
|
||||||
|
title: title?.trim() ?? null,
|
||||||
|
eventDate: dateString,
|
||||||
|
source: FoundEventSource.TUS,
|
||||||
|
url: pageUrl,
|
||||||
|
description: description?.trim() ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await prisma.foundEventAttachments.createMany({
|
||||||
|
data: attachedFileUrls.map(attachment => ({
|
||||||
|
url: attachment.url,
|
||||||
|
title: attachment.title,
|
||||||
|
eventId: newEvent.id,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}, null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum FoundEventSource {
|
||||||
|
TUS = "https://www.taekwondo-union-sachsen.de/index.php?page=Termine-TUS",
|
||||||
|
// DTU = "https://www.dtu.de/termine",
|
||||||
|
// BTU = "https://btu-online.de/termine/",
|
||||||
|
// MAGOSCH = "https://www.taekwondo-magosch.de/termine",
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ export class KeycloakUser {
|
|||||||
this.keycloakConnect = new Promise(async resolve => {
|
this.keycloakConnect = new Promise(async resolve => {
|
||||||
const config = await this.getConfig();
|
const config = await this.getConfig();
|
||||||
const client = new KeycloakConnect({ store: sessionStore }, config);
|
const client = new KeycloakConnect({ store: sessionStore }, config);
|
||||||
|
client.authenticated=(r)=>{
|
||||||
|
console.dir(r);
|
||||||
|
};
|
||||||
resolve(client);
|
resolve(client);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -49,4 +52,10 @@ export class KeycloakUser {
|
|||||||
const client = await this.keycloakConnect;
|
const client = await this.keycloakConnect;
|
||||||
return client.middleware();
|
return client.middleware();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getUid(request: Request, response?: Response): Promise<string>{
|
||||||
|
const client = await this.keycloakConnect;
|
||||||
|
const grant = await client.getGrant(request, response ?? ({} as any));
|
||||||
|
return (grant.access_token as any)?.content?.sub;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,12 +43,14 @@ export class MembersService {
|
|||||||
createArgs: MemberCreateArgs,
|
createArgs: MemberCreateArgs,
|
||||||
request: express.Request,
|
request: express.Request,
|
||||||
): Promise<MemberDto> {
|
): Promise<MemberDto> {
|
||||||
// const x: MemberCreateArgs = {
|
const x = {
|
||||||
// ...createArgs,
|
...createArgs,
|
||||||
// geburtsdatum: createArgs.geburtsdatum ?? null,
|
geburtsdatum: createArgs.geburtsdatum
|
||||||
// };
|
? createArgs.geburtsdatum
|
||||||
|
: null,
|
||||||
|
};
|
||||||
const newMember = await this.database.doRequest(
|
const newMember = await this.database.doRequest(
|
||||||
async prisma => await prisma.member.create({ data: createArgs }),
|
async prisma => await prisma.member.create({ data: x }),
|
||||||
request,
|
request,
|
||||||
);
|
);
|
||||||
return mapDbMemberToMember(newMember);
|
return mapDbMemberToMember(newMember);
|
||||||
@@ -68,7 +70,22 @@ export class MembersService {
|
|||||||
const modifiedMember = await this.database.doRequest(
|
const modifiedMember = await this.database.doRequest(
|
||||||
async prisma =>
|
async prisma =>
|
||||||
await prisma.member.update({
|
await prisma.member.update({
|
||||||
data: { ...member, id: id },
|
data: {
|
||||||
|
...member,
|
||||||
|
id: id,
|
||||||
|
letztePruefung: member.letztePruefung
|
||||||
|
? member.letztePruefung
|
||||||
|
: null,
|
||||||
|
geburtsdatum: member.geburtsdatum
|
||||||
|
? member.geburtsdatum
|
||||||
|
: null,
|
||||||
|
kuendigungzum: member.kuendigungzum
|
||||||
|
? member.kuendigungzum
|
||||||
|
: null,
|
||||||
|
creationDate: member.creationDate
|
||||||
|
? member.creationDate
|
||||||
|
: null,
|
||||||
|
},
|
||||||
where: { id: id },
|
where: { id: id },
|
||||||
}),
|
}),
|
||||||
request,
|
request,
|
||||||
@@ -79,7 +96,7 @@ export class MembersService {
|
|||||||
|
|
||||||
public async scheduleDeletion(
|
public async scheduleDeletion(
|
||||||
memberId: string,
|
memberId: string,
|
||||||
cancelDate: Date,
|
cancelDate: Date | null,
|
||||||
request: express.Request,
|
request: express.Request,
|
||||||
) {
|
) {
|
||||||
await this.database.doRequest(
|
await this.database.doRequest(
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
type USVString = string;
|
||||||
|
type DOMString = string;
|
||||||
|
type DOMTimeStamp = number;
|
||||||
|
|
||||||
|
export interface NotificationAction {
|
||||||
|
/** A string identifying a user action to be displayed on the notification. */
|
||||||
|
action: string;
|
||||||
|
|
||||||
|
/** A string containing action text to be shown to the user. */
|
||||||
|
title: string;
|
||||||
|
|
||||||
|
/** A string containing the URL of an icon to display with the action. */
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum NotificationActionOperation {
|
||||||
|
/** Opens a new tab at the specified URL. */
|
||||||
|
OPEN_WINDOW = "openWindow",
|
||||||
|
|
||||||
|
/** Focuses the last focused client. If there is no client open, then it opens a new tab at the specified URL. */
|
||||||
|
FOCUS_LAST_FOCUSED_OR_OPEN = "focusLastFocusedOrOpen",
|
||||||
|
|
||||||
|
/** Focuses the last focused client and navigates it to the specified URL. If there is no client open, then it opens a new tab at the specified URL. */
|
||||||
|
NAVIGATE_LAsT_FOCUSED_OR_OPEN = "navigateLastFocusedOrOpen",
|
||||||
|
|
||||||
|
/** Send a simple GET request to the specified URL. */
|
||||||
|
SEND_REQUEST = "sendRequest",
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationDataAction {
|
||||||
|
operation: NotificationActionOperation;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionClickHandler {
|
||||||
|
default: NotificationDataAction;
|
||||||
|
[key: string]: NotificationDataAction;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationData {
|
||||||
|
onActionClick?: ActionClickHandler;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AngularPushPayload {
|
||||||
|
/** https://developer.mozilla.org/en-US/docs/Web/API/Notification */
|
||||||
|
notification: {
|
||||||
|
/** Declares actions handled in data.onActionClick[action] */
|
||||||
|
readonly actions?: NotificationAction[];
|
||||||
|
|
||||||
|
/** A string containing the URL of an image to represent the notification when there is not enough space to display the notification itself such as for example, the Android Notification Bar. On Android devices, the badge should accommodate devices up to 4x resolution, about 96 by 96 px, and the image will be automatically masked. */
|
||||||
|
readonly badge?: USVString;
|
||||||
|
|
||||||
|
/** indicates the body string of the notification */
|
||||||
|
readonly body?: DOMString;
|
||||||
|
|
||||||
|
/** The data read-only property of the Notification interface returns a structured clone of the notification's data */
|
||||||
|
readonly data?: NotificationData;
|
||||||
|
|
||||||
|
/** The text direction of the notification */
|
||||||
|
readonly dir?: "auto" | "ltr" | "rtl";
|
||||||
|
|
||||||
|
/** The URL of the image used as an icon of the notification */
|
||||||
|
readonly icon?: USVString;
|
||||||
|
|
||||||
|
/** The URL of an image to be displayed as part of the notification */
|
||||||
|
readonly image?: USVString;
|
||||||
|
|
||||||
|
/** The language code of the notification (BCP 47 language tag) */
|
||||||
|
readonly lang?: DOMString;
|
||||||
|
|
||||||
|
/** Specifies whether the user should be notified after a new notification replaces an old one. */
|
||||||
|
readonly renotify?: boolean;
|
||||||
|
|
||||||
|
/** A boolean value indicating that a notification should remain active until the user clicks or dismisses it, rather than closing automatically. */
|
||||||
|
readonly requireInteraction?: boolean;
|
||||||
|
|
||||||
|
/** Specifies whether the notification should be silent — i.e., no sounds or vibrations should be issued regardless of the device settings. */
|
||||||
|
readonly silent?: boolean;
|
||||||
|
|
||||||
|
/** The idea of notification tags is that more than one notification can share the same tag, linking them together. One notification can then be programmatically replaced with another to avoid the users' screen being filled up with a huge number of similar notifications. */
|
||||||
|
readonly tag?: DOMString;
|
||||||
|
|
||||||
|
/** The notification's timestamp can represent the time, in milliseconds since 00:00:00 UTC on 1 January 1970, of the event for which the notification was created, or it can be an arbitrary timestamp that you want associated with the notification. For example, a timestamp for an upcoming meeting could be set in the future, whereas a timestamp for a missed message could be set in the past. */
|
||||||
|
readonly timestamp?: DOMTimeStamp | undefined;
|
||||||
|
|
||||||
|
/** The title of the notification */
|
||||||
|
readonly title: DOMString;
|
||||||
|
|
||||||
|
/** Specifies a vibration pattern for devices with vibration hardware to emit. */
|
||||||
|
readonly vibrate?: number[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,293 @@
|
|||||||
|
import { DbNull, type JsonValue } from "@prisma/client/runtime/client";
|
||||||
|
import { parse } from "date-fns";
|
||||||
|
import type { Request, Response } from "express";
|
||||||
|
import webpush from "web-push";
|
||||||
|
import type { PushSubscriptionCreateArgs } from "../../dtos/pushSubscription.js";
|
||||||
|
import type {
|
||||||
|
FoundEvents,
|
||||||
|
PushSubscription,
|
||||||
|
} from "../../generated/prisma/client.js";
|
||||||
|
import { Topic } from "../../generated/prisma/enums.js";
|
||||||
|
import { inject } from "../../infrastructure/di/index.js";
|
||||||
|
import { Injectable } from "../../infrastructure/di/injectable-decorator.js";
|
||||||
|
import { DatabaseService } from "../db/prisma.js";
|
||||||
|
import { KeycloakUser } from "../keycloak/user/keycloak-user.js";
|
||||||
|
import {
|
||||||
|
NotificationActionOperation,
|
||||||
|
type ActionClickHandler,
|
||||||
|
type AngularPushPayload,
|
||||||
|
type NotificationAction,
|
||||||
|
} from "./angularPushPayload.js";
|
||||||
|
import VAPID from "./vapid.json" with { type: "json" };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PushService {
|
||||||
|
private readonly db = inject(DatabaseService);
|
||||||
|
private readonly userService = inject(KeycloakUser);
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
webpush.setVapidDetails(
|
||||||
|
"mailto:toniwalter.blue@gmail.com",
|
||||||
|
VAPID.publicKey,
|
||||||
|
VAPID.privateKey,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async storeSubscription(
|
||||||
|
subscription: PushSubscriptionCreateArgs,
|
||||||
|
request: Request,
|
||||||
|
response?: Response,
|
||||||
|
): Promise<void> {
|
||||||
|
const userId = await this.userService.getUid(request, response);
|
||||||
|
await this.db.doRequest(async prisma => {
|
||||||
|
await prisma.ensureUserKnown(userId);
|
||||||
|
return await prisma.pushSubscription.create({
|
||||||
|
data: {
|
||||||
|
auth: subscription.keys.auth,
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
p256dh: subscription.keys.p256dh,
|
||||||
|
expirationTime: subscription.expirationTime ?? null,
|
||||||
|
userId: userId,
|
||||||
|
clientId: subscription.clientId,
|
||||||
|
topic: subscription.topic,
|
||||||
|
topicConfiguration: subscription.topicConfiguration
|
||||||
|
? JSON.parse(subscription.topicConfiguration)
|
||||||
|
: DbNull,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async sendEventNotifications() {
|
||||||
|
const subscriptions = await this.db.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
prisma.pushSubscription.findMany({
|
||||||
|
where: { topic: Topic.EVENTS },
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO filter by when last notify happened
|
||||||
|
const events = await this.db.doRequest(
|
||||||
|
async prisma => prisma.foundEvents.findMany(),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
|
||||||
|
const actions: NotificationAction[] = [
|
||||||
|
{
|
||||||
|
action: "toEvent",
|
||||||
|
title: "Veranstaltung",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: "toOverview",
|
||||||
|
title: "Alle",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const notificationBody = [
|
||||||
|
event.title,
|
||||||
|
event.eventDate,
|
||||||
|
event.description,
|
||||||
|
event.source,
|
||||||
|
]
|
||||||
|
.filter(p => !!p)
|
||||||
|
.join("\n");
|
||||||
|
|
||||||
|
const actionClickHandler: ActionClickHandler = {
|
||||||
|
default: {
|
||||||
|
operation: NotificationActionOperation.OPEN_WINDOW,
|
||||||
|
url: event.url,
|
||||||
|
},
|
||||||
|
toEvent: {
|
||||||
|
operation: NotificationActionOperation.OPEN_WINDOW,
|
||||||
|
url: event.url,
|
||||||
|
},
|
||||||
|
toOverview: {
|
||||||
|
operation: NotificationActionOperation.OPEN_WINDOW,
|
||||||
|
url: "events",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let date: Date | undefined = undefined;
|
||||||
|
if (event.eventDate) {
|
||||||
|
const parts = event.eventDate.split("-");
|
||||||
|
if (parts.length == 1) {
|
||||||
|
date = parse(parts[0]!, "dd.MM.yyyy", new Date());
|
||||||
|
}
|
||||||
|
if (parts.length == 2) {
|
||||||
|
const p2 = parts[1]!.split(".");
|
||||||
|
p2.splice(0, 1, parts[0]!);
|
||||||
|
date = parse(p2.join("."), "dd.MM.yyyy", new Date());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload: AngularPushPayload = {
|
||||||
|
notification: {
|
||||||
|
title: "Neue Veranstaltung",
|
||||||
|
actions: actions,
|
||||||
|
body: notificationBody,
|
||||||
|
data: {
|
||||||
|
onActionClick: actionClickHandler,
|
||||||
|
},
|
||||||
|
icon: "https://taekwondo-chemnitz.toni714.de/favicon.ico",
|
||||||
|
lang: "de-DE",
|
||||||
|
// TODO renotify: true, re-enable when tag
|
||||||
|
requireInteraction: true,
|
||||||
|
// timestamp: date?.valueOf(),
|
||||||
|
vibrate: [100],
|
||||||
|
// TODO tag: Topic.EVENTS, implement grouping in Angular SW
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const s of subscriptions) {
|
||||||
|
if (s.lastNotified && s.lastNotified >= event.foundDate) {
|
||||||
|
// this subscription has already seen this event
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!this.eventMatchesFilter(event, s.topicConfiguration)) {
|
||||||
|
// this subscription does not care for this event
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let backoff = 500;
|
||||||
|
let retry: boolean;
|
||||||
|
let maxRetry = 5;
|
||||||
|
do {
|
||||||
|
retry = await this.sendNotification(
|
||||||
|
s,
|
||||||
|
payload,
|
||||||
|
backoff,
|
||||||
|
);
|
||||||
|
if (maxRetry <= 0) {
|
||||||
|
throw new Error("Exceeded max retry");
|
||||||
|
}
|
||||||
|
backoff *= 2;
|
||||||
|
maxRetry--;
|
||||||
|
} while (retry);
|
||||||
|
await this.db.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
prisma.pushSubscription.update({
|
||||||
|
where: {
|
||||||
|
id: s.id,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
lastNotified: new Date(Date.now()),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Cannot send notification: ", error);
|
||||||
|
// throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async clearSubscription(
|
||||||
|
clientId: string | null,
|
||||||
|
request: Request,
|
||||||
|
): Promise<number> {
|
||||||
|
const userId = await this.userService.getUid(request, request.res);
|
||||||
|
const batchResult = clientId
|
||||||
|
? await this.db.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
await prisma.pushSubscription.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: userId,
|
||||||
|
AND: {
|
||||||
|
clientId: clientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request,
|
||||||
|
)
|
||||||
|
: await this.db.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
await prisma.pushSubscription.deleteMany({
|
||||||
|
where: {
|
||||||
|
userId: userId,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
request,
|
||||||
|
);
|
||||||
|
return batchResult.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sleep(ms: number): Promise<void> {
|
||||||
|
await new Promise(r => setTimeout(r, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async sendNotification(
|
||||||
|
s: PushSubscription,
|
||||||
|
payload: AngularPushPayload,
|
||||||
|
backoff: number,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const subscription: webpush.PushSubscription = {
|
||||||
|
endpoint: s.endpoint,
|
||||||
|
keys: {
|
||||||
|
auth: s.auth,
|
||||||
|
p256dh: s.p256dh,
|
||||||
|
},
|
||||||
|
expirationTime: s.expirationTime,
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await webpush.sendNotification(
|
||||||
|
subscription,
|
||||||
|
JSON.stringify(payload),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result.statusCode >= 200 && result.statusCode < 300) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (result.statusCode >= 500 && result.statusCode < 600) {
|
||||||
|
await this.sleep(backoff);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (result)
|
||||||
|
if (result.statusCode == 404 || result.statusCode == 301) {
|
||||||
|
// subscription expired / endpoint moved
|
||||||
|
await this.db.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
prisma.pushSubscription.delete({ where: { id: s.id } }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
//TODO request new subscription from client
|
||||||
|
throw new Error("Subscription expired for: " + s.clientId);
|
||||||
|
}
|
||||||
|
if (result.statusCode == 410) {
|
||||||
|
// unsubscribed
|
||||||
|
await this.db.doRequest(
|
||||||
|
async prisma =>
|
||||||
|
prisma.pushSubscription.delete({ where: { id: s.id } }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (result.statusCode == 429) {
|
||||||
|
const retryAfter = result.headers["retry-after"];
|
||||||
|
let seconds = Number(retryAfter);
|
||||||
|
let ms: number;
|
||||||
|
if (!retryAfter) {
|
||||||
|
ms = backoff;
|
||||||
|
} else if (!Number.isNaN(seconds)) {
|
||||||
|
ms = Math.max(0, seconds) * 1000;
|
||||||
|
} else {
|
||||||
|
ms = Math.max(0, Date.parse(retryAfter).valueOf() - Date.now());
|
||||||
|
}
|
||||||
|
await this.sleep(ms);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Unknown Notification response: " + result.statusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private eventMatchesFilter(
|
||||||
|
_event: FoundEvents,
|
||||||
|
_topicConfiguration: JsonValue,
|
||||||
|
): boolean {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { type User } from "../dtos/user.js";
|
|||||||
import { inject } from "../infrastructure/di/index.js";
|
import { inject } from "../infrastructure/di/index.js";
|
||||||
import { KeycloakAdmin } from "./keycloak/admin/index.js";
|
import { KeycloakAdmin } from "./keycloak/admin/index.js";
|
||||||
|
|
||||||
export class UserService {
|
export class UserAdminService {
|
||||||
private readonly keycloakAdmin = inject(KeycloakAdmin);
|
private readonly keycloakAdmin = inject(KeycloakAdmin);
|
||||||
public async setClaim(
|
public async setClaim(
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -54,8 +54,10 @@
|
|||||||
"strictPropertyInitialization": true,
|
"strictPropertyInitialization": true,
|
||||||
"noImplicitThis": true,
|
"noImplicitThis": true,
|
||||||
"alwaysStrict": true,
|
"alwaysStrict": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"preserveWatchOutput": true,
|
||||||
|
"esModuleInterop": true
|
||||||
},
|
},
|
||||||
"files": ["./src/index.ts"],
|
"files": ["./src/index.ts"],
|
||||||
"include": ["src"]
|
"include": ["src", "./package.json"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,5 @@ Thumbs.db
|
|||||||
|
|
||||||
###
|
###
|
||||||
src/app/generated-api/
|
src/app/generated-api/
|
||||||
|
|
||||||
|
src/app/services/push/vapid.json
|
||||||
|
|||||||
@@ -21,7 +21,6 @@
|
|||||||
"builder": "@angular/build:application",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"browser": "src/main.ts",
|
"browser": "src/main.ts",
|
||||||
"polyfills": ["zone.js"],
|
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
@@ -52,7 +51,8 @@
|
|||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
"sourceMap": true
|
"sourceMap": true,
|
||||||
|
"serviceWorker": "ngsw-config.json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"defaultConfiguration": "production"
|
"defaultConfiguration": "production"
|
||||||
@@ -75,7 +75,6 @@
|
|||||||
"test": {
|
"test": {
|
||||||
"builder": "@angular/build:karma",
|
"builder": "@angular/build:karma",
|
||||||
"options": {
|
"options": {
|
||||||
"polyfills": ["zone.js", "zone.js/testing"],
|
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"inlineStyleLanguage": "scss",
|
"inlineStyleLanguage": "scss",
|
||||||
"assets": [
|
"assets": [
|
||||||
|
|||||||
@@ -25,39 +25,38 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "20.2.11",
|
"@angular/cdk": "^21.2.1",
|
||||||
"@angular/common": "^20.3.15",
|
"@angular/common": "^21.2.1",
|
||||||
"@angular/compiler": "^20.3.15",
|
"@angular/compiler": "^21.2.1",
|
||||||
"@angular/core": "^20.3.15",
|
"@angular/core": "^21.2.1",
|
||||||
"@angular/forms": "^20.3.15",
|
"@angular/forms": "^21.2.1",
|
||||||
"@angular/material": "20.2.11",
|
"@angular/material": "^21.2.1",
|
||||||
"@angular/material-date-fns-adapter": "^20.2.14",
|
"@angular/material-date-fns-adapter": "^21.2.1",
|
||||||
"@angular/platform-browser": "^20.3.15",
|
"@angular/platform-browser": "^21.2.1",
|
||||||
"@angular/router": "^20.3.15",
|
"@angular/router": "^21.2.1",
|
||||||
"@angular/service-worker": "^20.3.15",
|
"@angular/service-worker": "^21.2.1",
|
||||||
"@date-fns/tz": "^1.4.1",
|
"@date-fns/tz": "^1.4.1",
|
||||||
"@date-fns/utc": "^2.1.1",
|
"@date-fns/utc": "^2.1.1",
|
||||||
"@oazapfts/runtime": "^1.0.4",
|
"@oazapfts/runtime": "^1.2.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"keycloak-angular": "^20.0.0",
|
"keycloak-angular": "^21.0.0",
|
||||||
"keycloak-js": "^26.2.1",
|
"keycloak-js": "^26.2.3",
|
||||||
"rxjs": "~7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"zone.js": "~0.15.1"
|
"uuid": "^13.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^20.3.13",
|
"@angular/build": "^21.2.1",
|
||||||
"@angular/build": "^20.3.13",
|
"@angular/cli": "^21.2.1",
|
||||||
"@angular/cli": "^20.3.13",
|
"@angular/compiler-cli": "^21.2.1",
|
||||||
"@angular/compiler-cli": "^20.3.15",
|
"@types/jasmine": "~6.0.0",
|
||||||
"@types/jasmine": "~5.1.13",
|
"jasmine-core": "~6.1.0",
|
||||||
"jasmine-core": "~5.9.0",
|
|
||||||
"karma": "~6.4.4",
|
"karma": "~6.4.4",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
"karma-coverage": "~2.2.1",
|
"karma-coverage": "~2.2.1",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.2.0",
|
||||||
"oazapfts": "^6.3.1",
|
"oazapfts": "^7.4.2",
|
||||||
"typescript": "~5.9.3"
|
"typescript": "~5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 9.8 KiB |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 17 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "frontend",
|
"name": "TKD Club Chemnitz Datenbank",
|
||||||
"short_name": "frontend",
|
"short_name": "TCC DB",
|
||||||
"display": "standalone",
|
"display": "standalone",
|
||||||
"scope": "./",
|
"scope": "./",
|
||||||
"start_url": "./",
|
"start_url": "./",
|
||||||
@@ -40,18 +40,6 @@
|
|||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"purpose": "maskable any"
|
"purpose": "maskable any"
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/icon-384x384.png",
|
|
||||||
"sizes": "384x384",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable any"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"src": "icons/icon-512x512.png",
|
|
||||||
"sizes": "512x512",
|
|
||||||
"type": "image/png",
|
|
||||||
"purpose": "maskable any"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
LOCALE_ID,
|
LOCALE_ID,
|
||||||
provideAppInitializer,
|
provideAppInitializer,
|
||||||
provideBrowserGlobalErrorListeners,
|
provideBrowserGlobalErrorListeners,
|
||||||
provideZoneChangeDetection,
|
provideCheckNoChangesConfig,
|
||||||
|
provideZonelessChangeDetection,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
PreloadAllModules,
|
PreloadAllModules,
|
||||||
@@ -48,7 +49,7 @@ export const appConfig: ApplicationConfig = {
|
|||||||
withComponentInputBinding(),
|
withComponentInputBinding(),
|
||||||
),
|
),
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
provideZonelessChangeDetection(),
|
||||||
provideKeycloak({
|
provideKeycloak({
|
||||||
config: keycloakConfig,
|
config: keycloakConfig,
|
||||||
initOptions: {
|
initOptions: {
|
||||||
@@ -85,8 +86,14 @@ export const appConfig: ApplicationConfig = {
|
|||||||
{ provide: MAT_DATE_FORMATS, useValue: MAT_DATE_FNS_FORMATS },
|
{ provide: MAT_DATE_FORMATS, useValue: MAT_DATE_FNS_FORMATS },
|
||||||
{ provide: MAT_DATE_LOCALE, useValue: de },
|
{ provide: MAT_DATE_LOCALE, useValue: de },
|
||||||
provideServiceWorker('ngsw-worker.js', {
|
provideServiceWorker('ngsw-worker.js', {
|
||||||
enabled: !isDevMode(),
|
enabled: true,//!isDevMode(),
|
||||||
registrationStrategy: 'registerWhenStable:30000',
|
registrationStrategy: 'registerWhenStable:30000',
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (isDevMode()) {
|
||||||
|
appConfig.providers.push(
|
||||||
|
provideCheckNoChangesConfig({ exhaustive: true, interval: 100 }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -70,5 +70,10 @@ export const routes: Routes = [
|
|||||||
),
|
),
|
||||||
canActivate: [requireRoles([Claim.Memberadmin])],
|
canActivate: [requireRoles([Claim.Memberadmin])],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'events',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./components/events/events').then(mod => mod.Events),
|
||||||
|
},
|
||||||
{ path: '**', component: MissingPage },
|
{ path: '**', component: MissingPage },
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -1,4 +1,10 @@
|
|||||||
import { Component, effect, inject, signal } from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { RouterModule, RouterOutlet } from '@angular/router';
|
import { RouterModule, RouterOutlet } from '@angular/router';
|
||||||
import { Authentication } from './services/authentication';
|
import { Authentication } from './services/authentication';
|
||||||
@@ -9,6 +15,7 @@ import { keycloakConfig } from './util/keycloak-config';
|
|||||||
imports: [RouterOutlet, MatButtonModule, RouterModule],
|
imports: [RouterOutlet, MatButtonModule, RouterModule],
|
||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.scss',
|
styleUrl: './app.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class App {
|
export class App {
|
||||||
protected readonly title = signal('frontend');
|
protected readonly title = signal('frontend');
|
||||||
@@ -22,7 +29,7 @@ export class App {
|
|||||||
effect(() => {
|
effect(() => {
|
||||||
const loggedIn = this.auth.loggedIn();
|
const loggedIn = this.auth.loggedIn();
|
||||||
if (!loggedIn) {
|
if (!loggedIn) {
|
||||||
this.auth.login();
|
this.auth.login(window.location.pathname ?? undefined);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
<form [formRoot]="form">
|
||||||
<p>Name: {{ member.vorname }} {{ member.nachname }}</p>
|
<p>Name: {{ member.vorname }} {{ member.nachname }}</p>
|
||||||
<p>Passnummer: {{ member.passnummer }}</p>
|
<p>Passnummer: {{ member.passnummer }}</p>
|
||||||
<p>Geburtsdatum: {{ member.geburtsdatum??undefined | datePipe }}</p>
|
<p>Geburtsdatum: {{ member.geburtsdatum | datePipe }}</p>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Kündigun zum</mat-label>
|
<mat-label>Kündigun zum</mat-label>
|
||||||
<input matInput [matDatepicker]="picker" formControlName="kuendigungzum" />
|
<input
|
||||||
|
matInput
|
||||||
|
[matDatepicker]="picker"
|
||||||
|
[formField]="form.kuendigungzum" />
|
||||||
<mat-hint>DD.MM.YYYY</mat-hint>
|
<mat-hint>DD.MM.YYYY</mat-hint>
|
||||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
<mat-datepicker-toggle
|
||||||
|
matIconSuffix
|
||||||
|
[for]="picker"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #picker></mat-datepicker>
|
<mat-datepicker #picker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<button mat-raised-button color="primary" type="submit">Speichern</button>
|
<button mat-raised-button color="primary" type="submit">Speichern</button>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { Component, Inject } from '@angular/core';
|
import {
|
||||||
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
form,
|
||||||
|
FormField,
|
||||||
|
FormRoot,
|
||||||
|
TreeValidationResult,
|
||||||
|
} from '@angular/forms/signals';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatOptionModule } from '@angular/material/core';
|
import { MatOptionModule } from '@angular/material/core';
|
||||||
@@ -9,18 +19,17 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
|||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import * as api from '../../generated-api/api';
|
import * as api from '../../generated-api/api';
|
||||||
import { DatePipePipe, dateToLocal } from '../../pipes/date-pipe.pipe.js';
|
import {
|
||||||
|
DatePipePipe,
|
||||||
|
dateToLocal,
|
||||||
|
dateToUTC,
|
||||||
|
} from '../../pipes/date-pipe.pipe.js';
|
||||||
import { EditMemberDialogComponent } from '../edit-member-dialog/edit-member-dialog.component.js';
|
import { EditMemberDialogComponent } from '../edit-member-dialog/edit-member-dialog.component.js';
|
||||||
|
|
||||||
type DeletionForm = {
|
|
||||||
kuendigungzum: FormControl<Date | null>;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-delete-member-dialog',
|
selector: 'app-delete-member-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
ReactiveFormsModule,
|
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
@@ -29,32 +38,41 @@ type DeletionForm = {
|
|||||||
MatOptionModule,
|
MatOptionModule,
|
||||||
MatDatepickerModule,
|
MatDatepickerModule,
|
||||||
DatePipePipe,
|
DatePipePipe,
|
||||||
|
FormField,
|
||||||
|
FormRoot,
|
||||||
],
|
],
|
||||||
templateUrl: './delete-member-dialog.component.html',
|
templateUrl: './delete-member-dialog.component.html',
|
||||||
styleUrl: './delete-member-dialog.component.scss',
|
styleUrl: './delete-member-dialog.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class DeleteMemberDialogComponent {
|
export class DeleteMemberDialogComponent {
|
||||||
form: FormGroup<DeletionForm>;
|
private readonly data = inject<{ member: api.MemberDto }>(MAT_DIALOG_DATA);
|
||||||
member: api.MemberDto;
|
private readonly dialogRef = inject(
|
||||||
constructor(
|
MatDialogRef<EditMemberDialogComponent>,
|
||||||
@Inject(MAT_DIALOG_DATA) public data: { member: api.MemberDto },
|
);
|
||||||
private dialogRef: MatDialogRef<EditMemberDialogComponent>,
|
private readonly deletionModel = signal({
|
||||||
) {
|
kuendigungzum: this.data.member.kuendigungzum
|
||||||
this.member = data.member;
|
? dateToUTC(this.data.member.kuendigungzum)
|
||||||
this.form = new FormGroup<DeletionForm>({
|
: null,
|
||||||
kuendigungzum: new FormControl(null),
|
});
|
||||||
|
protected readonly form = form(this.deletionModel, {
|
||||||
|
submission: {
|
||||||
|
action: async () => {
|
||||||
|
return await this.submit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
protected readonly member = this.data.member;
|
||||||
|
|
||||||
|
protected async submit(): Promise<TreeValidationResult> {
|
||||||
|
const date = this.deletionModel().kuendigungzum;
|
||||||
|
await api
|
||||||
|
.scheduleDeletion(
|
||||||
|
this.member.id,
|
||||||
|
date ? dateToLocal(date).toISOString() : '',
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
this.dialogRef.close();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async submit() {
|
|
||||||
const date = this.form.controls.kuendigungzum.value;
|
|
||||||
if (date) {
|
|
||||||
await api.scheduleDeletion(
|
|
||||||
this.member.id!,
|
|
||||||
dateToLocal(date).toISOString(),
|
|
||||||
);
|
|
||||||
|
|
||||||
this.dialogRef.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,48 @@
|
|||||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
<form [formRoot]="form">
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Vorname</mat-label>
|
<mat-label>Vorname</mat-label>
|
||||||
<input matInput placeholder="Vorname" formControlName="vorname" />
|
<input matInput placeholder="Vorname" [formField]="form.vorname" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Nachname</mat-label>
|
<mat-label>Nachname</mat-label>
|
||||||
<input matInput placeholder="Nachname" formControlName="nachname" />
|
<input matInput placeholder="Nachname" [formField]="form.nachname" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Geschlecht</mat-label>
|
<mat-label>Geschlecht</mat-label>
|
||||||
<input matInput placeholder="Geschlecht" formControlName="geschlecht" />
|
<input
|
||||||
|
matInput
|
||||||
|
placeholder="Geschlecht"
|
||||||
|
[formField]="form.geschlecht" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Passnummer</mat-label>
|
<mat-label>Passnummer</mat-label>
|
||||||
<input matInput placeholder="Passnummer" formControlName="passnummer" />
|
<input
|
||||||
|
matInput
|
||||||
|
placeholder="Passnummer"
|
||||||
|
[formField]="form.passnummer" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Geburtsdatum</mat-label>
|
<mat-label>Geburtsdatum</mat-label>
|
||||||
<input matInput [matDatepicker]="picker" formControlName="geburtsdatum" />
|
<input
|
||||||
|
matInput
|
||||||
|
[matDatepicker]="picker"
|
||||||
|
[formField]="form.geburtsdatum" />
|
||||||
<mat-hint>DD.MM.YYYY</mat-hint>
|
<mat-hint>DD.MM.YYYY</mat-hint>
|
||||||
<mat-datepicker-toggle matIconSuffix [for]="picker"></mat-datepicker-toggle>
|
<mat-datepicker-toggle
|
||||||
|
matIconSuffix
|
||||||
|
[for]="picker"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #picker></mat-datepicker>
|
<mat-datepicker #picker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Geburtsort</mat-label>
|
<mat-label>Geburtsort</mat-label>
|
||||||
<input matInput placeholder="Geburtsort" formControlName="geburtsort" />
|
<input
|
||||||
</mat-form-field>
|
matInput
|
||||||
<mat-form-field>
|
placeholder="Geburtsort"
|
||||||
<mat-label>Unterrichtsvertrag?</mat-label>
|
[formField]="form.geburtsort" />
|
||||||
<mat-select matInput formControlName="uvertrag">
|
|
||||||
<mat-option [value]="true">Ja</mat-option>
|
|
||||||
<mat-option [value]="false">Nein</mat-option>
|
|
||||||
</mat-select>
|
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Vereinsmitglied?</mat-label>
|
<mat-label>Vereinsmitglied?</mat-label>
|
||||||
<mat-select matInput formControlName="verein">
|
<mat-select matInput [formField]="form.verein">
|
||||||
<mat-option [value]="true">Ja</mat-option>
|
<mat-option [value]="true">Ja</mat-option>
|
||||||
<mat-option [value]="false">Nein</mat-option>
|
<mat-option [value]="false">Nein</mat-option>
|
||||||
</mat-select>
|
</mat-select>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { Component, Inject } from '@angular/core';
|
import {
|
||||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
form,
|
||||||
|
FormField,
|
||||||
|
FormRoot,
|
||||||
|
TreeValidationResult,
|
||||||
|
} from '@angular/forms/signals';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatOptionModule } from '@angular/material/core';
|
import { MatOptionModule } from '@angular/material/core';
|
||||||
@@ -9,13 +19,12 @@ import { MatFormFieldModule } from '@angular/material/form-field';
|
|||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import { MatSelectModule } from '@angular/material/select';
|
import { MatSelectModule } from '@angular/material/select';
|
||||||
import * as api from '../../generated-api/api';
|
import * as api from '../../generated-api/api';
|
||||||
import { MemberDtoHelper } from '../../util/member.js';
|
import { MemberDtoHelper, MemberEditForm } from '../../util/member.js';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-edit-member-dialog',
|
selector: 'app-edit-member-dialog',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
ReactiveFormsModule,
|
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
@@ -23,25 +32,37 @@ import { MemberDtoHelper } from '../../util/member.js';
|
|||||||
MatSelectModule,
|
MatSelectModule,
|
||||||
MatOptionModule,
|
MatOptionModule,
|
||||||
MatDatepickerModule,
|
MatDatepickerModule,
|
||||||
|
FormField,
|
||||||
|
FormRoot,
|
||||||
],
|
],
|
||||||
templateUrl: './edit-member-dialog.component.html',
|
templateUrl: './edit-member-dialog.component.html',
|
||||||
styleUrl: './edit-member-dialog.component.scss',
|
styleUrl: './edit-member-dialog.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class EditMemberDialogComponent {
|
export class EditMemberDialogComponent {
|
||||||
form: FormGroup;
|
private readonly data = inject<{ member: api.MemberDto }>(MAT_DIALOG_DATA);
|
||||||
constructor(
|
private readonly memberDtoHelper = inject(MemberDtoHelper);
|
||||||
@Inject(MAT_DIALOG_DATA) public data: { member: api.MemberDto },
|
private readonly dialogRef = inject(
|
||||||
private memberDtoHelper: MemberDtoHelper,
|
MatDialogRef<EditMemberDialogComponent>,
|
||||||
private dialogRef: MatDialogRef<EditMemberDialogComponent>,
|
|
||||||
) {
|
|
||||||
this.form = this.memberDtoHelper.newMemberForm(data.member);
|
|
||||||
}
|
|
||||||
|
|
||||||
submit() {
|
|
||||||
const member: api.MemberDto = this.memberDtoHelper.memberFromForm(
|
|
||||||
this.form,
|
|
||||||
);
|
);
|
||||||
api.updateMember(member);
|
|
||||||
|
private readonly memberModel = signal<MemberEditForm>(
|
||||||
|
this.memberDtoHelper.editMemberForm(this.data.member),
|
||||||
|
);
|
||||||
|
protected readonly form = form(this.memberModel, {
|
||||||
|
submission: {
|
||||||
|
action: async () => {
|
||||||
|
return await this.submit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
protected async submit(): Promise<TreeValidationResult> {
|
||||||
|
const member: api.MemberDto = this.memberDtoHelper.memberFromSignal(
|
||||||
|
this.memberModel(),
|
||||||
|
);
|
||||||
|
await api.updateMember(member).then(() => {
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<h1>Gesammelte Veranstaltungen</h1>
|
||||||
|
<p>
|
||||||
|
@if(this.pushService.isSubscribed()){
|
||||||
|
<span>Aboniert<mat-icon>check</mat-icon></span
|
||||||
|
><br />
|
||||||
|
<button matButton="filled" class="secondary-btn" (click)="unsubscribe()">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
}@else {
|
||||||
|
<button matButton="filled" (click)="subscribe()">Abonieren</button>
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<div class="table-container">
|
||||||
|
<table mat-table [dataSource]="datasource" matSort>
|
||||||
|
<ng-container matColumnDef="source">
|
||||||
|
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
||||||
|
Gefunden bei
|
||||||
|
</th>
|
||||||
|
<td mat-cell *matCellDef="let element">
|
||||||
|
<a [href]="element.source"
|
||||||
|
>{{truncateSource(element.source)}}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="url">
|
||||||
|
<th mat-header-cell mat-sort-header *matHeaderCellDef>Link</th>
|
||||||
|
<td mat-cell *matCellDef="let element">
|
||||||
|
<a
|
||||||
|
matButton
|
||||||
|
[href]="element.url"
|
||||||
|
target="_blank"
|
||||||
|
[ellipsis]="80"
|
||||||
|
>{{element.url}}</a
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="eventDate">
|
||||||
|
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
||||||
|
Ausrichtungsdatum
|
||||||
|
</th>
|
||||||
|
<td mat-cell *matCellDef="let element">{{element.eventDate}}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="title">
|
||||||
|
<th mat-header-cell mat-sort-header *matHeaderCellDef>Titel</th>
|
||||||
|
<td mat-cell *matCellDef="let element">{{element.title}}</td>
|
||||||
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="description">
|
||||||
|
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
||||||
|
Beschreibung
|
||||||
|
</th>
|
||||||
|
<td mat-cell *matCellDef="let element">{{element.description}}</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<ng-container matColumnDef="attachedFiles">
|
||||||
|
<th mat-header-cell *matHeaderCellDef>Anhänge</th>
|
||||||
|
<td mat-cell *matCellDef="let element">
|
||||||
|
@for(attachment of element.attachedFiles; track attachment.id){
|
||||||
|
<a matButton="outlined" [href]="attachment.url" target="_blank"
|
||||||
|
>{{attachment.title??'?'}}</a
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
<tr
|
||||||
|
mat-header-row
|
||||||
|
*matHeaderRowDef="displayedColumns; sticky: true"></tr>
|
||||||
|
<tr mat-row *matRowDef="let row; columns: displayedColumns"></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-flow: column;
|
||||||
|
height: 100%;
|
||||||
|
// overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-container {
|
||||||
|
overflow: auto;
|
||||||
|
border: 1px solid var(--mat-sys-outline);
|
||||||
|
min-height: 400px;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
|
||||||
|
import { Events } from './events';
|
||||||
|
|
||||||
|
describe('Events', () => {
|
||||||
|
let component: Events;
|
||||||
|
let fixture: ComponentFixture<Events>;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [Events]
|
||||||
|
})
|
||||||
|
.compileComponents();
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(Events);
|
||||||
|
component = fixture.componentInstance;
|
||||||
|
await fixture.whenStable();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create', () => {
|
||||||
|
expect(component).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatIconModule } from '@angular/material/icon';
|
||||||
|
import { MatSort, MatSortModule } from '@angular/material/sort';
|
||||||
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||||
|
import { parse } from 'date-fns';
|
||||||
|
import { Ellipsis } from '../../directives/ellipsis/ellipsis';
|
||||||
|
import * as api from '../../generated-api/api';
|
||||||
|
import { PushService } from '../../services/push/pushService';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-events',
|
||||||
|
imports: [
|
||||||
|
MatTableModule,
|
||||||
|
MatSortModule,
|
||||||
|
MatButtonModule,
|
||||||
|
Ellipsis,
|
||||||
|
MatIconModule,
|
||||||
|
],
|
||||||
|
templateUrl: './events.html',
|
||||||
|
styleUrl: './events.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class Events implements OnInit {
|
||||||
|
protected readonly pushService = inject(PushService);
|
||||||
|
protected readonly datasource = new MatTableDataSource<api.FoundEventDto>();
|
||||||
|
protected readonly sort = viewChild.required(MatSort);
|
||||||
|
protected readonly displayedColumns = [
|
||||||
|
'eventDate',
|
||||||
|
'title',
|
||||||
|
'description',
|
||||||
|
'attachedFiles',
|
||||||
|
'url',
|
||||||
|
'source',
|
||||||
|
];
|
||||||
|
|
||||||
|
public async ngOnInit(): Promise<void> {
|
||||||
|
this.datasource.sort = this.sort();
|
||||||
|
const originalSortingAccessor = this.datasource.sortingDataAccessor;
|
||||||
|
this.datasource.sortingDataAccessor = (item, property) => {
|
||||||
|
if (property != 'eventDate') {
|
||||||
|
return originalSortingAccessor(item, property);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!item.eventDate) {
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = item.eventDate.split('-');
|
||||||
|
if (parts.length == 1) {
|
||||||
|
return parse(parts[0]!, 'dd.MM.yyyy', new Date()).valueOf();
|
||||||
|
}
|
||||||
|
if (parts.length == 2) {
|
||||||
|
const p2 = parts[1].split('.');
|
||||||
|
p2.splice(0, 1, parts[0]);
|
||||||
|
return parse(p2.join('.'), 'dd.MM.yyyy', new Date()).valueOf();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn('unknown date format:', property);
|
||||||
|
return originalSortingAccessor(item, property);
|
||||||
|
};
|
||||||
|
const data = await api.getFutureEvents();
|
||||||
|
this.datasource.data = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected truncateSource(sourceUrl: string): string {
|
||||||
|
if (!URL.canParse(sourceUrl)) {
|
||||||
|
return sourceUrl;
|
||||||
|
}
|
||||||
|
const url = new URL(sourceUrl);
|
||||||
|
return url.hostname;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async unsubscribe() {
|
||||||
|
await this.pushService.resetSubscriptions();
|
||||||
|
}
|
||||||
|
protected async subscribe() {
|
||||||
|
await this.pushService.subscribeToEvents();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,16 +5,24 @@
|
|||||||
Mitgliederliste
|
Mitgliederliste
|
||||||
</button>
|
</button>
|
||||||
</mat-list-item>
|
</mat-list-item>
|
||||||
<mat-list-item [claimGuard]="Claim.Useradmin">
|
<mat-list-item>
|
||||||
|
<button
|
||||||
|
matButton="filled"
|
||||||
|
class="tertiary-btn"
|
||||||
|
[routerLink]="['/events']">
|
||||||
|
Veranstaltungen
|
||||||
|
</button>
|
||||||
|
</mat-list-item>
|
||||||
|
<!-- <mat-list-item [claimGuard]="Claim.Useradmin">
|
||||||
<button matButton="filled" color="warn" [routerLink]="['/manageUsers']">
|
<button matButton="filled" color="warn" [routerLink]="['/manageUsers']">
|
||||||
Benutzer Verwalten
|
Benutzer Verwalten
|
||||||
</button>
|
</button>
|
||||||
</mat-list-item>
|
</mat-list-item> -->
|
||||||
<mat-list-item [claimGuard]="Claim.Memberadmin">
|
<!-- <mat-list-item [claimGuard]="Claim.Memberadmin">
|
||||||
<button matButton="filled" color="primary" [routerLink]="['/exam']">
|
<button matButton="filled" color="primary" [routerLink]="['/exam']">
|
||||||
Prüfungsanmeldung
|
Prüfungsanmeldung
|
||||||
</button>
|
</button>
|
||||||
</mat-list-item>
|
</mat-list-item> -->
|
||||||
<mat-list-item>
|
<mat-list-item>
|
||||||
<button
|
<button
|
||||||
matButton="outlined"
|
matButton="outlined"
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component } from '@angular/core';
|
import { ChangeDetectionStrategy, Component } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatListModule } from '@angular/material/list';
|
import { MatListModule } from '@angular/material/list';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
@@ -15,6 +15,7 @@ import { Claim } from '../../generated-api/api';
|
|||||||
],
|
],
|
||||||
templateUrl: './home.html',
|
templateUrl: './home.html',
|
||||||
styleUrl: './home.scss',
|
styleUrl: './home.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class Home {
|
export class Home {
|
||||||
protected Claim = Claim;
|
protected Claim = Claim;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import { Router } from '@angular/router';
|
||||||
import { Authentication } from '../../services/authentication';
|
import { Authentication } from '../../services/authentication';
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ import { Authentication } from '../../services/authentication';
|
|||||||
imports: [],
|
imports: [],
|
||||||
templateUrl: './login.html',
|
templateUrl: './login.html',
|
||||||
styleUrl: './login.scss',
|
styleUrl: './login.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class Login {
|
export class Login {
|
||||||
private readonly authentication = inject(Authentication);
|
private readonly authentication = inject(Authentication);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { Authentication } from '../../services/authentication';
|
import { Authentication } from '../../services/authentication';
|
||||||
@@ -8,6 +8,7 @@ import { Authentication } from '../../services/authentication';
|
|||||||
imports: [MatButtonModule, RouterModule],
|
imports: [MatButtonModule, RouterModule],
|
||||||
templateUrl: './missing-page.html',
|
templateUrl: './missing-page.html',
|
||||||
styleUrl: './missing-page.scss',
|
styleUrl: './missing-page.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class MissingPage {
|
export class MissingPage {
|
||||||
protected readonly auth = inject(Authentication);
|
protected readonly auth = inject(Authentication);
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
<h1>Neues Mitglied anlegen</h1>
|
<h1>Neues Mitglied anlegen</h1>
|
||||||
<form [formGroup]="memberForm" (ngSubmit)="onSubmit()">
|
<form [formRoot]="memberForm">
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Vorname</mat-label>
|
<mat-label>Vorname</mat-label>
|
||||||
<input matInput formControlName="vorname" />
|
<input matInput [formField]="memberForm.vorname" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Nachname</mat-label>
|
<mat-label>Nachname</mat-label>
|
||||||
<input matInput formControlName="nachname" />
|
<input matInput [formField]="memberForm.nachname" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Geschlecht</mat-label>
|
<mat-label>Geschlecht</mat-label>
|
||||||
<input matInput formControlName="geschlecht" />
|
<input matInput [formField]="memberForm.geschlecht" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Geburtsdatum</mat-label>
|
<mat-label>Geburtsdatum</mat-label>
|
||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
[matDatepicker]="picker"
|
[matDatepicker]="picker"
|
||||||
formControlName="geburtsdatum" />
|
[formField]="memberForm.geburtsdatum" />
|
||||||
<mat-hint>DD.MM.YYYY</mat-hint>
|
<mat-hint>DD.MM.YYYY</mat-hint>
|
||||||
<mat-datepicker-toggle
|
<mat-datepicker-toggle
|
||||||
matIconSuffix
|
matIconSuffix
|
||||||
@@ -26,18 +26,18 @@
|
|||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Geburtsort</mat-label>
|
<mat-label>Geburtsort</mat-label>
|
||||||
<input matInput formControlName="geburtsort" />
|
<input matInput [formField]="memberForm.geburtsort" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-checkbox [formControl]="memberForm.controls.verein"
|
<mat-checkbox [formField]="memberForm.verein"
|
||||||
>Verein Beigetreten?</mat-checkbox
|
>Verein Beigetreten?</mat-checkbox
|
||||||
>
|
>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Passnummer (falls vorhanden)</mat-label>
|
<mat-label>Passnummer (falls vorhanden)</mat-label>
|
||||||
<input matInput formControlName="passnummer" />
|
<input matInput [formField]="memberForm.passnummer" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<mat-label>Kontakt</mat-label>
|
<mat-label>Kontakt</mat-label>
|
||||||
<input matInput formControlName="kontakt" />
|
<input matInput [formField]="memberForm.kontakt" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<button mat-flat-button color="primary" type="submit">Hinzufügen</button>
|
<button mat-flat-button color="primary" type="submit">Hinzufügen</button>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import {
|
||||||
import { ReactiveFormsModule } from '@angular/forms';
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import {
|
||||||
|
form,
|
||||||
|
FormField,
|
||||||
|
FormRoot,
|
||||||
|
TreeValidationResult,
|
||||||
|
} from '@angular/forms/signals';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
@@ -7,37 +17,49 @@ import { MatDialogRef } from '@angular/material/dialog';
|
|||||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||||
import { MatInputModule } from '@angular/material/input';
|
import { MatInputModule } from '@angular/material/input';
|
||||||
import * as api from '../../generated-api/api.js';
|
import * as api from '../../generated-api/api.js';
|
||||||
import { MemberDtoHelper } from '../../util/member.js';
|
import { MemberCreateForm, MemberDtoHelper } from '../../util/member.js';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-new-member',
|
selector: 'app-new-member',
|
||||||
standalone: true,
|
standalone: true,
|
||||||
providers: [],
|
providers: [],
|
||||||
imports: [
|
imports: [
|
||||||
ReactiveFormsModule,
|
|
||||||
MatFormFieldModule,
|
MatFormFieldModule,
|
||||||
MatDatepickerModule,
|
MatDatepickerModule,
|
||||||
MatInputModule,
|
MatInputModule,
|
||||||
MatCheckboxModule,
|
MatCheckboxModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
|
FormField,
|
||||||
|
FormRoot,
|
||||||
],
|
],
|
||||||
templateUrl: './new-member.component.html',
|
templateUrl: './new-member.component.html',
|
||||||
styleUrl: './new-member.component.scss',
|
styleUrl: './new-member.component.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class NewMemberComponent {
|
export class NewMemberComponent {
|
||||||
private readonly memberDtoHelper = inject(MemberDtoHelper);
|
private readonly memberDtoHelper = inject(MemberDtoHelper);
|
||||||
|
private readonly dialogRef: MatDialogRef<NewMemberComponent> = inject(
|
||||||
constructor() {}
|
|
||||||
memberForm = this.memberDtoHelper.newMemberForm();
|
|
||||||
private dialogRef: MatDialogRef<NewMemberComponent> = inject(
|
|
||||||
MatDialogRef<NewMemberComponent>,
|
MatDialogRef<NewMemberComponent>,
|
||||||
);
|
);
|
||||||
|
|
||||||
onSubmit() {
|
private readonly memberModel = signal<MemberCreateForm>(
|
||||||
const newMember: api.MemberCreateArgs =
|
this.memberDtoHelper.newMemberForm(),
|
||||||
this.memberDtoHelper.newMemberFromForm(this.memberForm);
|
);
|
||||||
|
protected readonly memberForm = form(this.memberModel, {
|
||||||
|
submission: {
|
||||||
|
action: async () => {
|
||||||
|
return await this.submit();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
api.createMember(newMember)
|
protected async submit(): Promise<TreeValidationResult> {
|
||||||
|
const newMember = this.memberDtoHelper.newMemberFromSignal(
|
||||||
|
this.memberModel(),
|
||||||
|
);
|
||||||
|
|
||||||
|
await api
|
||||||
|
.createMember(newMember)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
console.log('Successfully added Member');
|
console.log('Successfully added Member');
|
||||||
this.dialogRef.close();
|
this.dialogRef.close();
|
||||||
|
|||||||
@@ -1,17 +1,28 @@
|
|||||||
<h1>Testeite für interne Berechtigung</h1>
|
<h1>Testeite für interne Berechtigung</h1>
|
||||||
<p>API: {{apiStatus()}}</p>
|
<p>API: {{apiStatus()}}</p>
|
||||||
<p>Auth: {{authTest()}}</p>
|
<p>Auth: {{authTest()}}</p>
|
||||||
|
<p>Capabilities: {{pushCaps|json}}</p>
|
||||||
|
<pre>More Caps: {{asyncCaps()|json}}</pre>
|
||||||
|
<button [claimGuard]="Claim.Useradmin" (click)="crawl()">Crawl</button>
|
||||||
|
<button
|
||||||
|
[claimGuard]="Claim.Useradmin"
|
||||||
|
(click)="notifyAll()"
|
||||||
|
class="secondary-btn"
|
||||||
|
matButton="filled">
|
||||||
|
Notify All
|
||||||
|
</button>
|
||||||
Claim:
|
Claim:
|
||||||
<ul>
|
<ul>
|
||||||
@let claims = this.auth.claims();
|
@let claims = this.auth.claims(); @if(claims) {
|
||||||
@if(claims) {
|
|
||||||
<pre>
|
<pre>
|
||||||
{{claims | json}}
|
{{claims | json}}
|
||||||
</pre>
|
</pre
|
||||||
|
>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
<details name="UserInfo:">
|
<details name="UserInfo:">
|
||||||
<pre>
|
<pre>
|
||||||
{{this.auth.userInfo() | json}}
|
{{this.auth.userInfo() | json}}
|
||||||
</pre>
|
</pre
|
||||||
|
>
|
||||||
</details>
|
</details>
|
||||||
|
|||||||
@@ -1,42 +1,82 @@
|
|||||||
import { JsonPipe } from '@angular/common';
|
import { JsonPipe } from '@angular/common';
|
||||||
import { Component, effect, inject, signal } from '@angular/core';
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { ClaimGuardDirective } from '../../directives/claim-guard.directive';
|
||||||
import * as api from '../../generated-api/api';
|
import * as api from '../../generated-api/api';
|
||||||
import { Authentication } from '../../services/authentication';
|
import { Authentication } from '../../services/authentication';
|
||||||
|
import { PushService } from '../../services/push/pushService';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-test',
|
selector: 'app-test',
|
||||||
imports: [JsonPipe],
|
imports: [JsonPipe, ClaimGuardDirective],
|
||||||
templateUrl: './test.html',
|
templateUrl: './test.html',
|
||||||
styleUrl: './test.scss',
|
styleUrl: './test.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class Test {
|
export class Test {
|
||||||
protected auth = inject(Authentication);
|
protected readonly pushService = inject(PushService);
|
||||||
|
protected readonly auth = inject(Authentication);
|
||||||
|
|
||||||
protected apiStatus = signal<string>("Waiting");
|
protected readonly apiStatus = signal<string>('Waiting');
|
||||||
protected authTest = signal<string>("Waiting");
|
protected readonly authTest = signal<string>('Waiting');
|
||||||
|
protected readonly Claim = api.Claim;
|
||||||
|
protected readonly pushCaps = {
|
||||||
|
serviceWorker: 'serviceWorker' in navigator,
|
||||||
|
pushManager: 'PushManager' in window,
|
||||||
|
notification: 'Notification' in window,
|
||||||
|
notifyPermission: Notification.permission,
|
||||||
|
};
|
||||||
|
protected asyncCaps = signal<any>(null);
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
|
this.loadCaps();
|
||||||
effect(() => {
|
effect(() => {
|
||||||
const loggedIn = this.auth.loggedIn();
|
const loggedIn = this.auth.loggedIn();
|
||||||
if (!loggedIn) {
|
if (!loggedIn) {
|
||||||
this.apiStatus.set("N/A - not logged in");
|
this.apiStatus.set('N/A - not logged in');
|
||||||
this.authTest.set("N/A - not logged in");
|
this.authTest.set('N/A - not logged in');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.apiStatus.set("Waiting for API");
|
this.apiStatus.set('Waiting for API');
|
||||||
this.authTest.set("Waiting for API");
|
this.authTest.set('Waiting for API');
|
||||||
api.getOk().then((status) => {
|
api.getOk()
|
||||||
this.apiStatus.set("Ok");
|
.then(status => {
|
||||||
}).catch((e) => {
|
this.apiStatus.set('Ok');
|
||||||
this.apiStatus.set("Error");
|
})
|
||||||
|
.catch(e => {
|
||||||
|
this.apiStatus.set('Error');
|
||||||
});
|
});
|
||||||
api.getAuth().then((response) => {
|
api.getAuth()
|
||||||
this.authTest.set("Ok");
|
.then(response => {
|
||||||
}).catch((e) => {
|
this.authTest.set('Ok');
|
||||||
|
})
|
||||||
|
.catch(e => {
|
||||||
this.authTest.set(e);
|
this.authTest.set(e);
|
||||||
});
|
});
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async loadCaps() {
|
||||||
|
const caps = {
|
||||||
|
registraion: await navigator.serviceWorker.getRegistrations(),
|
||||||
|
subscription: await (
|
||||||
|
await navigator.serviceWorker.ready
|
||||||
|
).pushManager.getSubscription(),
|
||||||
|
};
|
||||||
|
|
||||||
|
this.asyncCaps.set(caps);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async notifyAll() {
|
||||||
|
await api.notifyEvents();
|
||||||
|
}
|
||||||
|
protected async crawl() {
|
||||||
|
await api.crawl();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,22 +2,34 @@
|
|||||||
<h1>Mitglieder & Sportlerübersicht</h1>
|
<h1>Mitglieder & Sportlerübersicht</h1>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button matButton="filled" class="primary-btn" (click)="newMember()">
|
<button
|
||||||
|
matButton="filled"
|
||||||
|
class="primary-btn .no-print"
|
||||||
|
(click)="newMember()">
|
||||||
Neues Mitglied
|
Neues Mitglied
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
matButton="filled"
|
||||||
|
class="secondary-btn .no-print"
|
||||||
|
(click)="print()">
|
||||||
|
Drucken
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="filter-container">
|
<div class="filter-container .no-print">
|
||||||
<mat-form-field>
|
<mat-form-field>
|
||||||
<label>Filter:</label>
|
<label>Filter:</label>
|
||||||
<input matInput [formControl]="filterControl" placeholder="Filter" />
|
<input
|
||||||
|
matInput
|
||||||
|
[formField]="filterForm.filterString"
|
||||||
|
placeholder="Filter" />
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
<div class="status-filters">
|
<div class="status-filters">
|
||||||
<mat-radio-group [formControl]="statusControl">
|
<mat-radio-group [formField]="filterForm.status">
|
||||||
<mat-radio-button [value]="MemberStatusFilter.ACTIVE"
|
<mat-radio-button [value]="MemberStatusFilter.ACTIVE"
|
||||||
>Aktiv</mat-radio-button
|
>Aktiv zum</mat-radio-button
|
||||||
>
|
>
|
||||||
<mat-radio-button [value]="MemberStatusFilter.INACTIVE"
|
<mat-radio-button [value]="MemberStatusFilter.INACTIVE"
|
||||||
>Inaktiv</mat-radio-button
|
>Gekündigt seit</mat-radio-button
|
||||||
>
|
>
|
||||||
<mat-radio-button [value]="MemberStatusFilter.ALL"
|
<mat-radio-button [value]="MemberStatusFilter.ALL"
|
||||||
>Alle</mat-radio-button
|
>Alle</mat-radio-button
|
||||||
@@ -30,20 +42,21 @@
|
|||||||
<input
|
<input
|
||||||
matInput
|
matInput
|
||||||
[matDatepicker]="picker"
|
[matDatepicker]="picker"
|
||||||
[formControl]="statusDateControl"
|
[formField]="filterForm.statusDate"
|
||||||
placeholder="Aktiv bis" />
|
placeholder="Aktiv bis" />
|
||||||
<mat-hint>DD.MM.YYYY</mat-hint>
|
<mat-hint>DD.MM.YYYY</mat-hint>
|
||||||
<mat-datepicker-toggle
|
<mat-datepicker-toggle
|
||||||
matIconSuffix
|
matIconSuffix
|
||||||
[disabled]="statusControl.value===MemberStatusFilter.ALL"
|
[disabled]="statusDateDisabled()"
|
||||||
[for]="picker"></mat-datepicker-toggle>
|
[for]="picker"></mat-datepicker-toggle>
|
||||||
<mat-datepicker #picker></mat-datepicker>
|
<mat-datepicker #picker></mat-datepicker>
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
</div>
|
</div>
|
||||||
<mat-checkbox [formControl]="onlyWithID">Nur mit Passnummer</mat-checkbox>
|
<mat-checkbox [formField]="filterForm.onlyWithID"
|
||||||
|
>Nur mit Passnummer</mat-checkbox
|
||||||
|
>
|
||||||
</div>
|
</div>
|
||||||
<div class="table-container">
|
<div class="table-container">
|
||||||
<!--TODO implement row count, selecting and alternating colors-->
|
|
||||||
<table
|
<table
|
||||||
mat-table
|
mat-table
|
||||||
[dataSource]="dataSource"
|
[dataSource]="dataSource"
|
||||||
@@ -97,7 +110,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="geburtsdatum">
|
<ng-container matColumnDef="geburtsdatum">
|
||||||
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
||||||
Geburtsdatum
|
Geburtsdatum Geboren am
|
||||||
</th>
|
</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td mat-cell *matCellDef="let element">
|
||||||
{{ element.geburtsdatum | datePipe }}
|
{{ element.geburtsdatum | datePipe }}
|
||||||
@@ -110,9 +123,7 @@
|
|||||||
<td mat-cell *matCellDef="let element">{{ element.geburtsort }}</td>
|
<td mat-cell *matCellDef="let element">{{ element.geburtsort }}</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<ng-container matColumnDef="verein">
|
<ng-container matColumnDef="verein">
|
||||||
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
<th mat-header-cell mat-sort-header *matHeaderCellDef>Verein?</th>
|
||||||
Vereinsmitgliedschaft
|
|
||||||
</th>
|
|
||||||
<td mat-cell *matCellDef="let element">
|
<td mat-cell *matCellDef="let element">
|
||||||
@if(element.verein){
|
@if(element.verein){
|
||||||
<mat-icon
|
<mat-icon
|
||||||
@@ -136,12 +147,18 @@
|
|||||||
|
|
||||||
<ng-container matColumnDef="kuendigungzum">
|
<ng-container matColumnDef="kuendigungzum">
|
||||||
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
<th mat-header-cell mat-sort-header *matHeaderCellDef>
|
||||||
Kündigung zum
|
Kündigung
|
||||||
</th>
|
</th>
|
||||||
<td mat-cell *matCellDef="let element">
|
<td mat-cell *matCellDef="let element">
|
||||||
{{ (element.kuendigungzum | datePipe) ?? "-" }}
|
{{ (element.kuendigungzum | datePipe) ?? "-" }}
|
||||||
</td>
|
</td>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
<ng-container matColumnDef="creationDate">
|
||||||
|
<th mat-header-cell mat-sort-header *matHeaderCellDef>Beitritt</th>
|
||||||
|
<td mat-cell *matCellDef="let element">
|
||||||
|
{{ element.creationDate | datePipe }}
|
||||||
|
</td>
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
<ng-container matColumnDef="delete">
|
<ng-container matColumnDef="delete">
|
||||||
<th mat-header-cell *matHeaderCellDef>Entf.</th>
|
<th mat-header-cell *matHeaderCellDef>Entf.</th>
|
||||||
@@ -174,7 +191,8 @@
|
|||||||
<tr
|
<tr
|
||||||
mat-row
|
mat-row
|
||||||
*matRowDef="let row; columns: displayedColumns"
|
*matRowDef="let row; columns: displayedColumns"
|
||||||
class="table-row"></tr>
|
class="table-row"
|
||||||
|
[class.expired]="isInPast(row.kuendigungzum)"></tr>
|
||||||
<tr mat-footer-row *matFooterRowDef="['totalCount']; sticky: true"></tr>
|
<tr mat-footer-row *matFooterRowDef="['totalCount']; sticky: true"></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -49,3 +49,16 @@ td {
|
|||||||
.table-row:nth-child(odd) {
|
.table-row:nth-child(odd) {
|
||||||
background-color: var(--mat-sys-surface);
|
background-color: var(--mat-sys-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.table-row.expired {
|
||||||
|
//background-color: lightsalmon;
|
||||||
|
filter: sepia(50%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.positive {
|
||||||
|
color: green;
|
||||||
|
}
|
||||||
|
|
||||||
|
.negative {
|
||||||
|
color: red;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,15 @@
|
|||||||
import { AfterViewInit, Component, viewChild } from '@angular/core';
|
import {
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
AfterViewInit,
|
||||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
ChangeDetectionStrategy,
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { form, FormField } from '@angular/forms/signals';
|
||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCheckbox } from '@angular/material/checkbox';
|
import { MatCheckbox } from '@angular/material/checkbox';
|
||||||
import { MatDatepickerModule } from '@angular/material/datepicker';
|
import { MatDatepickerModule } from '@angular/material/datepicker';
|
||||||
@@ -13,7 +22,6 @@ import { MatSort, MatSortModule } from '@angular/material/sort';
|
|||||||
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
|
||||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||||
import { RouterModule } from '@angular/router';
|
import { RouterModule } from '@angular/router';
|
||||||
import { combineLatest, startWith } from 'rxjs';
|
|
||||||
import * as api from '../../generated-api/api';
|
import * as api from '../../generated-api/api';
|
||||||
import { DatePipePipe } from '../../pipes/date-pipe.pipe.js';
|
import { DatePipePipe } from '../../pipes/date-pipe.pipe.js';
|
||||||
import { NobreakHyphenPipe } from '../../pipes/nobreak-hyphen.pipe.js';
|
import { NobreakHyphenPipe } from '../../pipes/nobreak-hyphen.pipe.js';
|
||||||
@@ -22,6 +30,44 @@ import { DeleteMemberDialogComponent } from '../delete-member-dialog/delete-memb
|
|||||||
import { EditMemberDialogComponent } from '../edit-member-dialog/edit-member-dialog.component.js';
|
import { EditMemberDialogComponent } from '../edit-member-dialog/edit-member-dialog.component.js';
|
||||||
import { NewMemberComponent } from '../new-member/new-member.component';
|
import { NewMemberComponent } from '../new-member/new-member.component';
|
||||||
|
|
||||||
|
class MemberFilters {
|
||||||
|
public filterString: string;
|
||||||
|
public status: MemberStatusFilter;
|
||||||
|
public statusDate: Date | null;
|
||||||
|
public onlyWithID: boolean;
|
||||||
|
|
||||||
|
public constructor(data: {
|
||||||
|
filterString: string;
|
||||||
|
status: MemberStatusFilter;
|
||||||
|
statusDate: Date | null;
|
||||||
|
onlyWithID: boolean;
|
||||||
|
}) {
|
||||||
|
this.filterString = data.filterString;
|
||||||
|
this.status = data.status;
|
||||||
|
this.statusDate = data.statusDate;
|
||||||
|
this.onlyWithID = data.onlyWithID;
|
||||||
|
}
|
||||||
|
|
||||||
|
public encode(): string {
|
||||||
|
return `${this.filterString?.replaceAll(';', '') ?? ''};${this.status};${this.statusDate?.toISOString()};${this.onlyWithID}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static decode(encoded: string): MemberFilters {
|
||||||
|
const parts = encoded.split(';');
|
||||||
|
const statusDateStr = parts[2];
|
||||||
|
|
||||||
|
return new MemberFilters({
|
||||||
|
filterString: parts[0],
|
||||||
|
status: parts[1] as MemberStatusFilter,
|
||||||
|
statusDate:
|
||||||
|
statusDateStr && statusDateStr != ''
|
||||||
|
? new Date(statusDateStr)
|
||||||
|
: null,
|
||||||
|
onlyWithID: parts[3] === 'true',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
enum MemberStatusFilter {
|
enum MemberStatusFilter {
|
||||||
ACTIVE = 'ACTIVE',
|
ACTIVE = 'ACTIVE',
|
||||||
INACTIVE = 'INACTIVE',
|
INACTIVE = 'INACTIVE',
|
||||||
@@ -40,25 +86,34 @@ enum MemberStatusFilter {
|
|||||||
RouterModule,
|
RouterModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
DatePipePipe,
|
DatePipePipe,
|
||||||
ReactiveFormsModule,
|
|
||||||
MatDatepickerModule,
|
MatDatepickerModule,
|
||||||
MatTooltipModule,
|
MatTooltipModule,
|
||||||
MatCheckbox,
|
MatCheckbox,
|
||||||
MatRadioModule,
|
MatRadioModule,
|
||||||
MatSortModule,
|
MatSortModule,
|
||||||
|
FormField,
|
||||||
],
|
],
|
||||||
templateUrl: './view-members.html',
|
templateUrl: './view-members.html',
|
||||||
styleUrl: './view-members.scss',
|
styleUrl: './view-members.scss',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class ViewMembers implements AfterViewInit {
|
export class ViewMembers implements AfterViewInit {
|
||||||
protected MemberStatusFilter = MemberStatusFilter;
|
private readonly changeDetectorRef = inject(ChangeDetectorRef);
|
||||||
protected readonly filterControl = new FormControl<string>('');
|
|
||||||
protected readonly statusControl = new FormControl<MemberStatusFilter>(
|
protected readonly MemberStatusFilter = MemberStatusFilter;
|
||||||
MemberStatusFilter.ACTIVE,
|
private readonly filterModel = signal({
|
||||||
|
filterString: '',
|
||||||
|
status: MemberStatusFilter.ACTIVE,
|
||||||
|
statusDate: new Date() as Date | null,
|
||||||
|
onlyWithID: false,
|
||||||
|
});
|
||||||
|
protected readonly filterForm = form(this.filterModel);
|
||||||
|
|
||||||
|
protected readonly statusDateDisabled = computed(
|
||||||
|
() => this.filterForm.status().value() === MemberStatusFilter.ALL,
|
||||||
);
|
);
|
||||||
protected readonly statusDateControl = new FormControl<Date | null>(null);
|
|
||||||
protected readonly onlyWithID = new FormControl<boolean>(false);
|
protected readonly dataSource = new MatTableDataSource<api.MemberDto>();
|
||||||
protected dataSource = new MatTableDataSource<api.MemberDto>();
|
|
||||||
private readonly datePipe = new DatePipePipe();
|
private readonly datePipe = new DatePipePipe();
|
||||||
|
|
||||||
protected readonly displayedColumns: string[] = [
|
protected readonly displayedColumns: string[] = [
|
||||||
@@ -72,6 +127,7 @@ export class ViewMembers implements AfterViewInit {
|
|||||||
'geburtsort',
|
'geburtsort',
|
||||||
'verein',
|
'verein',
|
||||||
'kontakt',
|
'kontakt',
|
||||||
|
'creationDate',
|
||||||
'kuendigungzum',
|
'kuendigungzum',
|
||||||
'delete',
|
'delete',
|
||||||
];
|
];
|
||||||
@@ -122,35 +178,41 @@ export class ViewMembers implements AfterViewInit {
|
|||||||
data: api.MemberDto,
|
data: api.MemberDto,
|
||||||
encodedFilter: string,
|
encodedFilter: string,
|
||||||
) => {
|
) => {
|
||||||
const parts = encodedFilter.split(';');
|
const filters = MemberFilters.decode(encodedFilter);
|
||||||
const filter = parts[0];
|
|
||||||
const status = parts[1] as MemberStatusFilter;
|
|
||||||
const statusDateStr = parts[2];
|
|
||||||
const onlyWithID = parts[3] === 'true';
|
|
||||||
|
|
||||||
const currentDate =
|
if (filters.statusDate) {
|
||||||
statusDateStr && statusDateStr != ''
|
|
||||||
? new Date(statusDateStr)
|
|
||||||
: null;
|
|
||||||
const cancellationDate = data.kuendigungzum
|
const cancellationDate = data.kuendigungzum
|
||||||
? new Date(data.kuendigungzum)
|
? new Date(data.kuendigungzum)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
|
switch (filters.status) {
|
||||||
|
case MemberStatusFilter.ACTIVE:
|
||||||
|
const creationDate = data.creationDate
|
||||||
|
? new Date(data.creationDate)
|
||||||
|
: null;
|
||||||
if (
|
if (
|
||||||
currentDate &&
|
(cancellationDate &&
|
||||||
cancellationDate &&
|
cancellationDate <= filters.statusDate) ||
|
||||||
((status === MemberStatusFilter.ACTIVE &&
|
(creationDate && creationDate > filters.statusDate)
|
||||||
cancellationDate <= currentDate) ||
|
|
||||||
(status === MemberStatusFilter.INACTIVE &&
|
|
||||||
cancellationDate > currentDate))
|
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (status === MemberStatusFilter.INACTIVE && !cancellationDate) {
|
break;
|
||||||
|
case MemberStatusFilter.INACTIVE:
|
||||||
|
if (
|
||||||
|
!cancellationDate ||
|
||||||
|
cancellationDate < filters.statusDate
|
||||||
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
break;
|
||||||
|
case MemberStatusFilter.ALL:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
onlyWithID &&
|
filters.onlyWithID &&
|
||||||
(!data.passnummer || data.passnummer.trim() === '')
|
(!data.passnummer || data.passnummer.trim() === '')
|
||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
@@ -159,7 +221,7 @@ export class ViewMembers implements AfterViewInit {
|
|||||||
return originalFilterPredicate(
|
return originalFilterPredicate(
|
||||||
{
|
{
|
||||||
...data,
|
...data,
|
||||||
id: null,
|
id: '',
|
||||||
geburtsdatum: this.datePipe.transform(
|
geburtsdatum: this.datePipe.transform(
|
||||||
data.geburtsdatum ?? undefined,
|
data.geburtsdatum ?? undefined,
|
||||||
),
|
),
|
||||||
@@ -169,45 +231,23 @@ export class ViewMembers implements AfterViewInit {
|
|||||||
letztePruefung: this.datePipe.transform(
|
letztePruefung: this.datePipe.transform(
|
||||||
data.letztePruefung ?? undefined,
|
data.letztePruefung ?? undefined,
|
||||||
),
|
),
|
||||||
|
creationDate: this.datePipe.transform(
|
||||||
|
data.creationDate ?? undefined,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
filter,
|
filters.filterString,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
combineLatest([
|
|
||||||
this.filterControl.valueChanges.pipe(
|
|
||||||
startWith(this.filterControl.value),
|
|
||||||
),
|
|
||||||
this.statusControl.valueChanges.pipe(
|
|
||||||
startWith(this.statusControl.value),
|
|
||||||
),
|
|
||||||
this.statusDateControl.valueChanges.pipe(
|
|
||||||
startWith(this.statusDateControl.value),
|
|
||||||
),
|
|
||||||
this.onlyWithID.valueChanges.pipe(startWith(this.onlyWithID.value)),
|
|
||||||
])
|
|
||||||
// .pipe(takeUntilDestroyed())
|
|
||||||
.subscribe(combined => {
|
|
||||||
const filter = combined[0];
|
|
||||||
const status = combined[1];
|
|
||||||
const date = combined[2];
|
|
||||||
const onlyWithID = combined[3];
|
|
||||||
this.dataSource.filter = `${filter?.replaceAll(';', '') ?? ''};${status};${date?.toISOString()};${onlyWithID}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
this.statusControl.valueChanges
|
effect(() => {
|
||||||
.pipe(takeUntilDestroyed())
|
const filtersSignal = this.filterModel();
|
||||||
.subscribe(value => {
|
const filters = new MemberFilters({
|
||||||
if (
|
filterString: filtersSignal.filterString,
|
||||||
value === MemberStatusFilter.ALL &&
|
status: filtersSignal.status,
|
||||||
this.statusDateControl.enabled
|
statusDate: filtersSignal.statusDate,
|
||||||
) {
|
onlyWithID: filtersSignal.onlyWithID,
|
||||||
return this.statusDateControl.disable();
|
});
|
||||||
} else if (
|
this.dataSource.filter = filters.encode();
|
||||||
value !== MemberStatusFilter.ALL &&
|
|
||||||
this.statusDateControl.disabled
|
|
||||||
) {
|
|
||||||
return this.statusDateControl.enable();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.updateData();
|
this.updateData();
|
||||||
@@ -221,5 +261,127 @@ export class ViewMembers implements AfterViewInit {
|
|||||||
private async updateData(): Promise<void> {
|
private async updateData(): Promise<void> {
|
||||||
const members = await api.getMembers();
|
const members = await api.getMembers();
|
||||||
this.dataSource.data = members;
|
this.dataSource.data = members;
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected print() {
|
||||||
|
const data = this.dataSource.filteredData;
|
||||||
|
const printWindow = document.createElement('iframe');
|
||||||
|
|
||||||
|
const table = document.createElement('table');
|
||||||
|
const headerRow = document.createElement('tr');
|
||||||
|
|
||||||
|
const firstnameHeader = document.createElement('th');
|
||||||
|
firstnameHeader.innerText = 'Vorname';
|
||||||
|
headerRow.appendChild(firstnameHeader);
|
||||||
|
|
||||||
|
const lastnameHeader = document.createElement('th');
|
||||||
|
lastnameHeader.innerText = 'Nachname';
|
||||||
|
headerRow.appendChild(lastnameHeader);
|
||||||
|
|
||||||
|
const genderHeader = document.createElement('th');
|
||||||
|
genderHeader.innerText = 'Geschlecht';
|
||||||
|
headerRow.appendChild(genderHeader);
|
||||||
|
|
||||||
|
const clubnrHeader = document.createElement('th');
|
||||||
|
clubnrHeader.innerText = 'Passnummer';
|
||||||
|
headerRow.appendChild(clubnrHeader);
|
||||||
|
|
||||||
|
const birthdayHeader = document.createElement('th');
|
||||||
|
birthdayHeader.innerText = 'Geboren am';
|
||||||
|
headerRow.appendChild(birthdayHeader);
|
||||||
|
|
||||||
|
const geburtsortHeader = document.createElement('th');
|
||||||
|
geburtsortHeader.innerText = 'Geburtsort';
|
||||||
|
headerRow.appendChild(geburtsortHeader);
|
||||||
|
|
||||||
|
const cancelDateHeader = document.createElement('th');
|
||||||
|
cancelDateHeader.innerText = 'Kündigung';
|
||||||
|
headerRow.appendChild(cancelDateHeader);
|
||||||
|
|
||||||
|
table.appendChild(headerRow);
|
||||||
|
|
||||||
|
var docstyle = window.getComputedStyle(document.body);
|
||||||
|
var surface = docstyle.getPropertyValue('--mat-sys-surface');
|
||||||
|
var surfaceVariant = docstyle.getPropertyValue(
|
||||||
|
'--mat-sys-surface-variant',
|
||||||
|
);
|
||||||
|
|
||||||
|
var counter = 0;
|
||||||
|
for (const member of data) {
|
||||||
|
const memberRow = document.createElement('tr');
|
||||||
|
memberRow.style.backgroundColor =
|
||||||
|
counter % 2 === 0 ? surface : surfaceVariant;
|
||||||
|
counter++;
|
||||||
|
|
||||||
|
const firstnameCell = document.createElement('td');
|
||||||
|
firstnameCell.innerText = member.vorname ?? '';
|
||||||
|
memberRow.appendChild(firstnameCell);
|
||||||
|
firstnameCell.style.border = '1px solid darkgray';
|
||||||
|
firstnameCell.style.borderCollapse = 'collapse';
|
||||||
|
// firstnameCell.style.backgroundColor = 'black';
|
||||||
|
|
||||||
|
const lastnameCell = document.createElement('td');
|
||||||
|
lastnameCell.innerText = member.nachname ?? '';
|
||||||
|
memberRow.appendChild(lastnameCell);
|
||||||
|
lastnameCell.style.border = '1px solid darkgray';
|
||||||
|
lastnameCell.style.borderCollapse = 'collapse';
|
||||||
|
|
||||||
|
const genderCell = document.createElement('td');
|
||||||
|
genderCell.innerText = member.geschlecht ?? '';
|
||||||
|
memberRow.appendChild(genderCell);
|
||||||
|
genderCell.style.border = '1px solid darkgray';
|
||||||
|
genderCell.style.borderCollapse = 'collapse';
|
||||||
|
|
||||||
|
const clubnrCell = document.createElement('td');
|
||||||
|
clubnrCell.innerText = member.passnummer ?? '';
|
||||||
|
memberRow.appendChild(clubnrCell);
|
||||||
|
clubnrCell.style.border = '1px solid darkgray';
|
||||||
|
clubnrCell.style.borderCollapse = 'collapse';
|
||||||
|
|
||||||
|
const birthdayCell = document.createElement('td');
|
||||||
|
birthdayCell.innerText =
|
||||||
|
member.geburtsdatum != null
|
||||||
|
? (this.datePipe.transform(member.geburtsdatum) ?? '')
|
||||||
|
: '';
|
||||||
|
memberRow.appendChild(birthdayCell);
|
||||||
|
birthdayCell.style.border = '1px solid darkgray';
|
||||||
|
birthdayCell.style.borderCollapse = 'collapse';
|
||||||
|
|
||||||
|
const geburtsortCell = document.createElement('td');
|
||||||
|
geburtsortCell.innerText = member.geburtsort ?? '';
|
||||||
|
memberRow.appendChild(geburtsortCell);
|
||||||
|
geburtsortCell.style.border = '1px solid darkgray';
|
||||||
|
geburtsortCell.style.borderCollapse = 'collapse';
|
||||||
|
|
||||||
|
const cancelDateCell = document.createElement('td');
|
||||||
|
cancelDateCell.innerText =
|
||||||
|
member.kuendigungzum != null
|
||||||
|
? (this.datePipe.transform(member.kuendigungzum) ?? '')
|
||||||
|
: '';
|
||||||
|
memberRow.appendChild(cancelDateCell);
|
||||||
|
cancelDateCell.style.border = '1px solid darkgray';
|
||||||
|
cancelDateCell.style.borderCollapse = 'collapse';
|
||||||
|
|
||||||
|
table.appendChild(memberRow);
|
||||||
|
}
|
||||||
|
|
||||||
|
// printWindow.contentDocument?.appendChild(table);
|
||||||
|
|
||||||
|
table.style.width = '100%';
|
||||||
|
table.style.borderCollapse = 'collapse';
|
||||||
|
table.style.borderSpacing = '0';
|
||||||
|
// printWindow.style.display = 'none';
|
||||||
|
|
||||||
|
printWindow.srcdoc = table.outerHTML;
|
||||||
|
document.body.appendChild(printWindow);
|
||||||
|
printWindow.contentWindow?.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected isInPast(date: string): boolean {
|
||||||
|
if (!date) return false;
|
||||||
|
const today = new Date();
|
||||||
|
const cancellationDate = new Date(date);
|
||||||
|
return cancellationDate <= today;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Ellipsis } from './ellipsis';
|
||||||
|
|
||||||
|
describe('Ellipsis', () => {
|
||||||
|
it('should create an instance', () => {
|
||||||
|
const directive = new Ellipsis();
|
||||||
|
expect(directive).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import {
|
||||||
|
AfterViewInit,
|
||||||
|
Directive,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
signal,
|
||||||
|
WritableSignal,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { MatTooltip } from '@angular/material/tooltip';
|
||||||
|
|
||||||
|
@Directive({
|
||||||
|
selector: '[ellipsis]',
|
||||||
|
hostDirectives: [MatTooltip],
|
||||||
|
})
|
||||||
|
export class Ellipsis implements AfterViewInit {
|
||||||
|
// private readonly templateRef = inject(TemplateRef<any>);
|
||||||
|
// private readonly viewContainer = inject(ViewContainerRef);
|
||||||
|
private readonly elementRef = inject<ElementRef<HTMLElement>>(
|
||||||
|
ElementRef<HTMLElement>,
|
||||||
|
);
|
||||||
|
private readonly tooltip = inject(MatTooltip);
|
||||||
|
private readonly originalText: WritableSignal<string>;
|
||||||
|
public readonly ellipsis = input.required<number>();
|
||||||
|
|
||||||
|
public constructor() {
|
||||||
|
this.originalText = signal(this.elementRef.nativeElement.innerHTML);
|
||||||
|
effect(() => {
|
||||||
|
const length = this.ellipsis();
|
||||||
|
const originalText = this.originalText();
|
||||||
|
this.tooltip.message = originalText;
|
||||||
|
if (length < 0) {
|
||||||
|
console.warn('Ellipsis Pipe with negative length', length);
|
||||||
|
this.elementRef.nativeElement.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (length < 4) {
|
||||||
|
this.elementRef.nativeElement.innerHTML = '.'.repeat(length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (originalText.length > length) {
|
||||||
|
this.elementRef.nativeElement.innerHTML = `${originalText.substring(0, length - 3)}...`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngAfterViewInit(): void {
|
||||||
|
const originalText = this.elementRef.nativeElement.innerText;
|
||||||
|
this.originalText.set(originalText);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
type USVString = string;
|
||||||
|
type DOMString = string;
|
||||||
|
type DOMTimeStamp = number;
|
||||||
|
|
||||||
|
export interface NotificationAction {
|
||||||
|
action: string;
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AngularPushPayload {
|
||||||
|
notification: {
|
||||||
|
actions?: NotificationAction[];
|
||||||
|
badge?: USVString;
|
||||||
|
body?: DOMString;
|
||||||
|
data?: any;
|
||||||
|
dir?: 'auto' | 'ltr' | 'rtl';
|
||||||
|
icon?: USVString;
|
||||||
|
image?: USVString;
|
||||||
|
lang?: DOMString;
|
||||||
|
renotify?: boolean;
|
||||||
|
requireInteraction?: boolean;
|
||||||
|
silent?: boolean;
|
||||||
|
tag?: DOMString;
|
||||||
|
timestamp?: DOMTimeStamp;
|
||||||
|
title: DOMString;
|
||||||
|
vibrate?: number[];
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop';
|
||||||
|
import { SwPush } from '@angular/service-worker';
|
||||||
|
import { map } from 'rxjs';
|
||||||
|
import { v7 as uuidv7 } from 'uuid';
|
||||||
|
import * as api from '../../generated-api/api';
|
||||||
|
import VAPID from './vapid.json' with { type: 'json' };
|
||||||
|
|
||||||
|
const SUBSCRIPTION_CLIENT_ID = 'subscriptionClientId';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class PushService {
|
||||||
|
private readonly swPush = inject(SwPush);
|
||||||
|
public readonly isSubscribed = toSignal(
|
||||||
|
this.swPush.subscription.pipe(map(x => x != null)),
|
||||||
|
);
|
||||||
|
|
||||||
|
private arrayBufferToString(buffer: ArrayBuffer | null) {
|
||||||
|
if (buffer == null) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
let binary = '';
|
||||||
|
const bytes = new Uint8Array(buffer);
|
||||||
|
const len = bytes.length;
|
||||||
|
for (let i = 0; i < len; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async subscribeToEvents() {
|
||||||
|
try {
|
||||||
|
console.log('trying to subscribe');
|
||||||
|
const subscription = await this.swPush.requestSubscription({
|
||||||
|
serverPublicKey: VAPID.publicKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('returned');
|
||||||
|
console.dir(subscription);
|
||||||
|
console.log('end');
|
||||||
|
|
||||||
|
const previousCliendId = localStorage.getItem(
|
||||||
|
SUBSCRIPTION_CLIENT_ID,
|
||||||
|
);
|
||||||
|
if (previousCliendId) {
|
||||||
|
try {
|
||||||
|
await api.clearSubscription({ clientId: previousCliendId });
|
||||||
|
console.log('previous subscription cleared');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const newClientId = uuidv7();
|
||||||
|
|
||||||
|
await api.addSubscription({
|
||||||
|
endpoint: subscription.endpoint,
|
||||||
|
keys: {
|
||||||
|
auth: this.arrayBufferToString(subscription.getKey('auth')),
|
||||||
|
p256dh: this.arrayBufferToString(
|
||||||
|
subscription.getKey('p256dh'),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
expirationTime: subscription.expirationTime,
|
||||||
|
topic: api.Topic.Events,
|
||||||
|
topicConfiguration: null,
|
||||||
|
clientId: newClientId,
|
||||||
|
});
|
||||||
|
|
||||||
|
localStorage.setItem(SUBSCRIPTION_CLIENT_ID, newClientId);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Subscribing failed: ', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async resetSubscriptions() {
|
||||||
|
const clientId = localStorage.getItem(SUBSCRIPTION_CLIENT_ID);
|
||||||
|
await this.swPush.unsubscribe();
|
||||||
|
await api.clearSubscription({
|
||||||
|
clientId: clientId ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Inject, Injectable } from '@angular/core';
|
import { Inject, Injectable } from '@angular/core';
|
||||||
import { FormControl, FormGroup } from '@angular/forms';
|
import { FormControl } from '@angular/forms';
|
||||||
import { MAT_DATE_LOCALE } from '@angular/material/core';
|
import { MAT_DATE_LOCALE } from '@angular/material/core';
|
||||||
import { Locale, parse } from 'date-fns';
|
import { Locale } from 'date-fns';
|
||||||
import { MemberCreateArgs, MemberDto } from '../generated-api/api';
|
import { MemberCreateArgs, MemberDto } from '../generated-api/api';
|
||||||
import { dateToLocal, dateToUTC } from '../pipes/date-pipe.pipe';
|
import { dateToLocal, dateToUTC } from '../pipes/date-pipe.pipe';
|
||||||
|
|
||||||
@@ -21,86 +21,78 @@ interface MemberForm {
|
|||||||
marker: FormControl<boolean | null | undefined>;
|
marker: FormControl<boolean | null | undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MemberCreateForm extends Omit<
|
||||||
|
MemberCreateArgs,
|
||||||
|
'geburtsdatum' | 'creationDate'
|
||||||
|
> {
|
||||||
|
geburtsdatum: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberEditForm extends Omit<
|
||||||
|
MemberDto,
|
||||||
|
'geburtsdatum' | 'kuendigungzum' | 'letztePruefung'
|
||||||
|
> {
|
||||||
|
geburtsdatum: Date | null;
|
||||||
|
kuendigungzum: Date | null;
|
||||||
|
letztePruefung: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class MemberDtoHelper {
|
export class MemberDtoHelper {
|
||||||
constructor(@Inject(MAT_DATE_LOCALE) private locale: Locale) {}
|
constructor(@Inject(MAT_DATE_LOCALE) private locale: Locale) {}
|
||||||
public newMemberForm(member?: MemberDto): FormGroup<MemberForm> {
|
public newMemberForm(): MemberCreateForm {
|
||||||
const geburtsdatum = member?.geburtsdatum;
|
|
||||||
let enDate: Date | null = null;
|
|
||||||
if (geburtsdatum) {
|
|
||||||
enDate = parse(geburtsdatum, 'dd.MM.yyyy', new Date(), {
|
|
||||||
//TODO get dd.MM.yyyy from MAT_DATE_LOCALE or LOCALE_ID
|
|
||||||
locale: this.locale,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new FormGroup<MemberForm>({
|
|
||||||
id: new FormControl(member?.id),
|
|
||||||
vorname: new FormControl(member?.vorname),
|
|
||||||
nachname: new FormControl(member?.nachname),
|
|
||||||
geschlecht: new FormControl(member?.geschlecht),
|
|
||||||
geburtsdatum: new FormControl(
|
|
||||||
member?.geburtsdatum ? dateToUTC(member?.geburtsdatum) : null,
|
|
||||||
),
|
|
||||||
geburtsort: new FormControl(member?.geburtsort),
|
|
||||||
passnummer: new FormControl(member?.passnummer),
|
|
||||||
verein: new FormControl(member?.verein),
|
|
||||||
kontakt: new FormControl(member?.kontakt),
|
|
||||||
graduierung: new FormControl(member?.graduierung),
|
|
||||||
kuendigungzum: new FormControl(
|
|
||||||
member?.kuendigungzum ? dateToUTC(member?.kuendigungzum) : null,
|
|
||||||
),
|
|
||||||
letztePruefung: new FormControl(
|
|
||||||
member?.letztePruefung
|
|
||||||
? dateToUTC(member?.letztePruefung)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
marker: new FormControl(false),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
memberFromForm(form: FormGroup<MemberForm>): MemberDto {
|
|
||||||
return {
|
return {
|
||||||
id: form.controls.id.value ?? null,
|
vorname: '',
|
||||||
vorname: form.controls.vorname.value ?? null,
|
nachname: '',
|
||||||
nachname: form.controls.nachname.value ?? null,
|
geschlecht: '',
|
||||||
geschlecht: form.controls.geschlecht.value ?? null,
|
geburtsdatum: null,
|
||||||
geburtsdatum: form.controls.geburtsdatum.value
|
geburtsort: '',
|
||||||
? dateToLocal(form.controls.geburtsdatum.value).toISOString()
|
passnummer: '',
|
||||||
: null,
|
verein: false,
|
||||||
geburtsort: form.controls.geburtsort.value ?? null,
|
kontakt: '',
|
||||||
passnummer: form.controls.passnummer.value ?? null,
|
graduierung: '',
|
||||||
verein: form.controls.verein.value ?? null,
|
marker: false,
|
||||||
kontakt: form.controls.kontakt.value ?? null,
|
|
||||||
graduierung: form.controls.graduierung?.value ?? null,
|
|
||||||
kuendigungzum: form.controls.kuendigungzum.value
|
|
||||||
? dateToLocal(form.controls.kuendigungzum.value).toISOString()
|
|
||||||
: null,
|
|
||||||
letztePruefung: form.controls.letztePruefung.value
|
|
||||||
? dateToLocal(form.controls.letztePruefung.value).toISOString()
|
|
||||||
: null,
|
|
||||||
marker: form.controls.marker.value ?? false,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
newMemberFromForm(form: FormGroup<MemberForm>): MemberCreateArgs {
|
public newMemberFromSignal(sig: MemberCreateForm): MemberCreateArgs {
|
||||||
return {
|
return {
|
||||||
vorname: form.controls.vorname.value ?? null,
|
...sig,
|
||||||
nachname: form.controls.nachname.value ?? null,
|
geburtsdatum: sig.geburtsdatum
|
||||||
geschlecht: form.controls.geschlecht.value ?? null,
|
? dateToLocal(sig.geburtsdatum).toISOString()
|
||||||
geburtsdatum: form.controls.geburtsdatum.value
|
: '',
|
||||||
? dateToLocal(form.controls.geburtsdatum.value).toISOString()
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public editMemberForm(member: MemberDto): MemberEditForm {
|
||||||
|
return {
|
||||||
|
...member,
|
||||||
|
geburtsdatum: member.geburtsdatum
|
||||||
|
? dateToUTC(member.geburtsdatum)
|
||||||
: null,
|
: null,
|
||||||
geburtsort: form.controls.geburtsort.value ?? null,
|
kuendigungzum: member.kuendigungzum
|
||||||
passnummer: form.controls.passnummer.value ?? null,
|
? dateToUTC(member.kuendigungzum)
|
||||||
verein: form.controls.verein.value ?? null,
|
: null,
|
||||||
kontakt: form.controls.kontakt.value ?? null,
|
letztePruefung: member.letztePruefung
|
||||||
graduierung: form.controls.graduierung?.value ?? null,
|
? dateToUTC(member.letztePruefung)
|
||||||
kuendigungzum: null, //TODO not for new member
|
: null,
|
||||||
letztePruefung: null, //TODO not for new member
|
};
|
||||||
marker: form.controls.marker.value ?? false,
|
}
|
||||||
|
|
||||||
|
public memberFromSignal(sig: MemberEditForm): MemberDto {
|
||||||
|
return {
|
||||||
|
...sig,
|
||||||
|
geburtsdatum: sig.geburtsdatum
|
||||||
|
? dateToLocal(sig.geburtsdatum).toISOString()
|
||||||
|
: '',
|
||||||
|
kuendigungzum: sig.kuendigungzum
|
||||||
|
? dateToLocal(sig.kuendigungzum).toISOString()
|
||||||
|
: '',
|
||||||
|
letztePruefung: sig.letztePruefung
|
||||||
|
? dateToLocal(sig.letztePruefung).toISOString()
|
||||||
|
: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
<title>1.TC Chemnitz Mitgliederdatenbank</title>
|
<title>1.TC Chemnitz Mitgliederdatenbank</title>
|
||||||
<base href="/" />
|
<base href="/" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com/" />
|
||||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500&display=swap"
|
||||||
|
|||||||
@@ -102,8 +102,4 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
visibility: hidden;
|
visibility: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
:not(.no-print) {
|
|
||||||
visibility: visible;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
|
"rootDir": "./src",
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
|
|||||||