event subscriptions

This commit is contained in:
toni
2026-03-15 15:39:45 +01:00
parent 29c6828dd1
commit 2620538a2c
26 changed files with 1028 additions and 214 deletions
+4 -2
View File
@@ -18,18 +18,20 @@
"@types/node": "^25.3.5", "@types/node": "^25.3.5",
"@types/pg": "^8.18.0", "@types/pg": "^8.18.0",
"@types/web-push": "^3.6.4", "@types/web-push": "^3.6.4",
"prisma": "^7.4.2", "prisma": "^7.5.0",
"tsx": "^4.21.0", "tsx": "^4.21.0",
"typescript": "~5.9.3" "typescript": "~5.9.3"
}, },
"dependencies": { "dependencies": {
"@keycloak/keycloak-admin-client": "^26.5.5", "@keycloak/keycloak-admin-client": "^26.5.5",
"@prisma/adapter-pg": "^7.4.2", "@prisma/adapter-pg": "^7.4.2",
"@prisma/client": "^7.4.2", "@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.6", "cors": "^2.8.6",
"crawlee": "^3.16.0", "crawlee": "^3.16.0",
"cron": "^4.4.0",
"date-fns": "^4.1.0",
"dotenv": "^17.3.1", "dotenv": "^17.3.1",
"express": "^5.2.1", "express": "^5.2.1",
"express-session": "^1.19.0", "express-session": "^1.19.0",
@@ -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;
+3
View File
@@ -34,6 +34,7 @@ model session {
enum Topic { enum Topic {
TEST TEST
EVENTS
} }
model FoundEvents { model FoundEvents {
@@ -49,6 +50,7 @@ model FoundEvents {
model FoundEventAttachments { model FoundEventAttachments {
id String @id @default(dbgenerated("uuidv7()")) @db.Uuid id String @id @default(dbgenerated("uuidv7()")) @db.Uuid
title String @db.VarChar
url String @db.VarChar url String @db.VarChar
event FoundEvents @relation(fields: [eventId], references: [id]) event FoundEvents @relation(fields: [eventId], references: [id])
eventId String @db.Uuid eventId String @db.Uuid
@@ -67,6 +69,7 @@ model PushSubscription {
topic Topic topic Topic
topicConfiguration Json? @db.JsonB topicConfiguration Json? @db.JsonB
clientId String @db.Uuid clientId String @db.Uuid
lastNotified DateTime? @db.Date
} }
model User { model User {
@@ -1,15 +1,35 @@
import { Controller, Post, Route, SuccessResponse } from "tsoa"; import express from "express";
import {
Controller,
Get,
Post,
Request,
Route,
Security,
SuccessResponse,
} from "tsoa";
import type { FoundEventDto } from "../dtos/foundEvents.js";
import { inject } from "../infrastructure/di/index.js"; import { inject } from "../infrastructure/di/index.js";
import { FoundEventsService } from "../services/foundEvents/foundEventsService.js"; import { FoundEventsService } from "../services/foundEvents/foundEventsService.js";
import { KC_SECURITY_NAME } from "../services/keycloak/user/keycloak-user.js";
@Route("found-events") @Route("found-events")
export class FoundEventsController extends Controller { export class FoundEventsController extends Controller {
private readonly foundEventsService = inject(FoundEventsService); private readonly foundEventsService = inject(FoundEventsService);
@Post("crawl") @Post("crawl")
@Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK") @SuccessResponse("200", "OK")
//TODO add security public async crawl(): Promise<void> {
public async crawl() { await this.foundEventsService.crawl();
return await this.foundEventsService.crawl(); }
@Get("future")
@Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK")
public async getFutureEvents(
@Request() request: express.Request,
): Promise<FoundEventDto[]> {
return await this.foundEventsService.getFutureEvents(request);
} }
} }
+30 -10
View File
@@ -1,5 +1,14 @@
import express from "express"; import express from "express";
import { Body, Controller, Post, Query, Request, Route, Security, SuccessResponse } from "tsoa"; import {
Body,
Controller,
Post,
Query,
Request,
Route,
Security,
SuccessResponse,
} from "tsoa";
import type { PushSubscriptionCreateArgs as PushSubscriptionCreateArgsDto } from "../dtos/pushSubscription.js"; import type { PushSubscriptionCreateArgs as PushSubscriptionCreateArgsDto } from "../dtos/pushSubscription.js";
import { inject } from "../infrastructure/di/injector.js"; import { inject } from "../infrastructure/di/injector.js";
import { KC_SECURITY_NAME } from "../services/keycloak/user/keycloak-user.js"; import { KC_SECURITY_NAME } from "../services/keycloak/user/keycloak-user.js";
@@ -16,35 +25,46 @@ export class PushContoller extends Controller {
@Body() subscription: PushSubscriptionCreateArgsDto, @Body() subscription: PushSubscriptionCreateArgsDto,
@Request() request: express.Request, @Request() request: express.Request,
): Promise<void> { ): Promise<void> {
return await this.pushService.storeSubscription(subscription, request, request.res); return await this.pushService.storeSubscription(
subscription,
request,
request.res,
);
} }
@Post('reset') @Post("reset")
@Security(KC_SECURITY_NAME) @Security(KC_SECURITY_NAME)
@SuccessResponse('200', 'OK') @SuccessResponse("200", "OK")
public async resetSubscriptions( public async resetSubscriptions(
@Request() request: express.Request @Request() request: express.Request,
): Promise<void> { ): Promise<void> {
return await this.pushService.reset(request); return await this.pushService.reset(request);
} }
@Post('clearSingle') @Post("clearSingle")
@Security(KC_SECURITY_NAME) @Security(KC_SECURITY_NAME)
@SuccessResponse("200", 'OK') @SuccessResponse("200", "OK")
public async clearSubscription( public async clearSubscription(
@Request() request: express.Request, @Request() request: express.Request,
@Query() clientId: string @Query() clientId: string,
): Promise<void> { ): Promise<void> {
await this.pushService.clearSubscription(clientId, request); await this.pushService.clearSubscription(clientId, request);
} }
@Post('test-publish') //TODO remove
@Post("test-publish")
@Security(KC_SECURITY_NAME) @Security(KC_SECURITY_NAME)
@SuccessResponse("200", "OK") @SuccessResponse("200", "OK")
public async publishTestMessage( public async publishTestMessage(
@Request() request: express.Request @Request() request: express.Request,
): Promise<void> { ): Promise<void> {
this.pushService.sendtestNotification(request); this.pushService.sendtestNotification(request);
} }
//TODO remove
@Post("notify")
@SuccessResponse("200", "OK")
public async notifyEvents(): Promise<void> {
await this.pushService.sendEventNotifications();
}
} }
+22 -1
View File
@@ -1,4 +1,25 @@
import type { FoundEvents } from "../generated/prisma/client.js"; 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< export class FoundEventCreateArgs implements Omit<
FoundEvents, FoundEvents,
+3 -3
View File
@@ -1,4 +1,5 @@
import type { JsonValue } from "@prisma/client/runtime/client"; import type { JsonValue } from "@prisma/client/runtime/client";
import type { Topic } from "./topic.js";
export class PushSubscriptionDto { export class PushSubscriptionDto {
id!: string; id!: string;
@@ -16,9 +17,8 @@ export class PushSubscriptionDto{
export class PushSubscriptionCreateArgs /* implements Omit<PushSubscriptionDto, "id">*/ { export class PushSubscriptionCreateArgs /* implements Omit<PushSubscriptionDto, "id">*/ {
endpoint!: string; endpoint!: string;
expirationTime!: number | null; expirationTime!: number | null;
keys!: { p256dh: string; auth: string; }; keys!: { p256dh: string; auth: string };
topic!: "TEST"; topic!: Topic;
topicConfiguration!: string | null; topicConfiguration!: string | null;
clientId!: string; clientId!: string;
} }
+1 -3
View File
@@ -1,3 +1 @@
export enum Topic { export { type Topic } from "../generated/prisma/client.js";
test = "test",
}
+2 -14
View File
@@ -34,24 +34,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);
@@ -74,6 +62,7 @@ async function run() {
let port: number; let port: number;
if (isDev) { if (isDev) {
//TODO start with HTTPS in dev
// cert = fs.readFileSync("src/cert.pem"); // cert = fs.readFileSync("src/cert.pem");
// key = fs.readFileSync("src/key.pem"); // key = fs.readFileSync("src/key.pem");
@@ -85,7 +74,6 @@ async function run() {
console.log(`Server is running at http://localhost:${port}`); console.log(`Server is running at http://localhost:${port}`);
}); });
} else { } else {
//TODO start with HTTPS in production
port = 5443; 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"];
+10 -25
View File
@@ -5,10 +5,6 @@ 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,
@@ -27,27 +23,16 @@ function createExtendedClient(client: PrismaClient) {
return client.$extends({ return client.$extends({
client: { client: {
async ensureUserKnown(userId: string): Promise<UserModel> { async ensureUserKnown(userId: string): Promise<UserModel> {
let user = await client.user.findUnique({where: {id: userId}}); let user = await client.user.findUnique({
where: { id: userId },
});
if (user == null) { if (user == null) {
user = await client.user.create({ data: { id: userId } }); user = await client.user.create({ data: { id: userId } });
} }
return user return user;
} },
} },
}); });
// .$extends({
// name: "testExtension",
// model: {
// member: {
// withFullName(member: Member) {
// return {
// ...member,
// fullName: ` ${member.vorname} ${member.nachname}`,
// };
// },
// },
// },
// });
} }
@Injectable() @Injectable()
@@ -69,9 +54,9 @@ export class DatabaseService {
command: (prisma: SafeClient) => Promise<T>, command: (prisma: SafeClient) => Promise<T>,
request: Request | null, request: Request | null,
): Promise<T> { ): Promise<T> {
const transaction = const transaction: SafeClient | null = (request as any | null)
(request as any | null)?.transaction ?? ?.transaction;
(this.prismaClient as SafeClient); const effectiveClient = transaction ?? this.prismaClient;
return await command(transaction); return await command(effectiveClient);
} }
} }
@@ -1,4 +1,7 @@
import { PlaywrightCrawler } from "crawlee"; 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 { inject } from "../../infrastructure/di/index.js";
import { Injectable } from "../../infrastructure/di/injectable-decorator.js"; import { Injectable } from "../../infrastructure/di/injectable-decorator.js";
import { DatabaseService } from "../db/prisma.js"; import { DatabaseService } from "../db/prisma.js";
@@ -16,7 +19,9 @@ export class FoundEventsService {
const top = this; const top = this;
this.crawler = new PlaywrightCrawler({ this.crawler = new PlaywrightCrawler({
async requestHandler({ request, page, log }) { maxRequestsPerMinute: 100,
maxConcurrency: 2,
requestHandler: async ({ request, page, log }) => {
if (request.retryCount > 0) return; if (request.retryCount > 0) return;
const label = request.label; const label = request.label;
@@ -62,4 +67,39 @@ export class FoundEventsService {
})); }));
return await this.crawler.run(sources); 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;
});
}
} }
@@ -48,7 +48,7 @@ export async function parseTUS(
} }
export async function parseTUSDetail( export async function parseTUSDetail(
url: string, pageUrl: string,
page: Page, page: Page,
database: DatabaseService, database: DatabaseService,
): Promise<void> { ): Promise<void> {
@@ -61,9 +61,20 @@ export async function parseTUSDetail(
); );
const attachedFileUrls = ( const attachedFileUrls = (
await Promise.all( await Promise.all(
(await content.locator("a").all()).map(async locator => (await content.locator("a").all()).map(async locator => {
locator.getAttribute("href"), const url = await locator.getAttribute("href");
), const isAbsolute = url?.includes("://");
const aboluteUrl =
url != null
? isAbsolute
? new URL(url)
: new URL(url, pageUrl)
: "";
return {
url: aboluteUrl,
title: (await locator.textContent()) ?? "",
};
}),
) )
).filter(url => url != null); ).filter(url => url != null);
@@ -73,14 +84,15 @@ export async function parseTUSDetail(
title: title?.trim() ?? null, title: title?.trim() ?? null,
eventDate: dateString, eventDate: dateString,
source: FoundEventSource.TUS, source: FoundEventSource.TUS,
url: url, url: pageUrl,
description: description?.trim() ?? null, description: description?.trim() ?? null,
}, },
}); });
await prisma.foundEventAttachments.createMany({ await prisma.foundEventAttachments.createMany({
data: attachedFileUrls.map(url => ({ data: attachedFileUrls.map(attachment => ({
url: url, url: attachment.url,
title: attachment.title,
eventId: newEvent.id, eventId: newEvent.id,
})), })),
}); });
@@ -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[];
};
}
+300 -34
View File
@@ -1,13 +1,24 @@
import { DbNull } from "@prisma/client/runtime/client"; import { DbNull, type JsonValue } from "@prisma/client/runtime/client";
import { parse } from "date-fns";
import type { Request, Response } from "express"; import type { Request, Response } from "express";
import webpush from "web-push"; import webpush from "web-push";
import type { PushSubscriptionCreateArgs } from "../../dtos/pushSubscription.js"; import type { PushSubscriptionCreateArgs } from "../../dtos/pushSubscription.js";
import type {
FoundEvents,
PushSubscription,
} from "../../generated/prisma/client.js";
import { Topic } from "../../generated/prisma/enums.js"; import { Topic } from "../../generated/prisma/enums.js";
import { inject } from "../../infrastructure/di/index.js"; import { inject } from "../../infrastructure/di/index.js";
import { Injectable } from "../../infrastructure/di/injectable-decorator.js"; import { Injectable } from "../../infrastructure/di/injectable-decorator.js";
import { DatabaseService } from "../db/prisma.js"; import { DatabaseService } from "../db/prisma.js";
import { KeycloakUser } from "../keycloak/user/keycloak-user.js"; import { KeycloakUser } from "../keycloak/user/keycloak-user.js";
import VAPID from './vapid.json' with { type: "json" }; import {
NotificationActionOperation,
type ActionClickHandler,
type AngularPushPayload,
type NotificationAction,
} from "./angularPushPayload.js";
import VAPID from "./vapid.json" with { type: "json" };
@Injectable() @Injectable()
export class PushService { export class PushService {
@@ -15,10 +26,18 @@ export class PushService{
private readonly userService = inject(KeycloakUser); private readonly userService = inject(KeycloakUser);
public constructor() { public constructor() {
webpush.setVapidDetails("mailto:toniwalter.blue@gmail.com", VAPID.publicKey, VAPID.privateKey); webpush.setVapidDetails(
"mailto:toniwalter.blue@gmail.com",
VAPID.publicKey,
VAPID.privateKey,
);
} }
public async storeSubscription(subscription: PushSubscriptionCreateArgs, request: Request, response?: Response): Promise<void>{ public async storeSubscription(
subscription: PushSubscriptionCreateArgs,
request: Request,
response?: Response,
): Promise<void> {
const userId = await this.userService.getUid(request, response); const userId = await this.userService.getUid(request, response);
await this.db.doRequest(async prisma => { await this.db.doRequest(async prisma => {
await prisma.ensureUserKnown(userId); await prisma.ensureUserKnown(userId);
@@ -31,33 +50,190 @@ export class PushService{
userId: userId, userId: userId,
clientId: subscription.clientId, clientId: subscription.clientId,
topic: subscription.topic, topic: subscription.topic,
topicConfiguration: subscription.topicConfiguration ? JSON.parse(subscription.topicConfiguration) : DbNull, topicConfiguration: subscription.topicConfiguration
} ? JSON.parse(subscription.topicConfiguration)
: DbNull,
},
}); });
}, request); }, request);
} }
public async sendtestNotification(request: Request){ public async sendEventNotifications() {
const subscriptions = await this.db.doRequest(async prisma=>prisma.pushSubscription.findMany({where:{topic: "TEST"}}), request); const subscriptions = await this.db.doRequest(
async prisma =>
prisma.pushSubscription.findMany({
where: { topic: Topic.EVENTS },
}),
null,
);
const payload = { // 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 subscription of subscriptions) {
if (
subscription.lastNotified &&
subscription.lastNotified >= event.foundDate
) {
// this subscription has already seen this event
continue;
}
if (
!this.eventMatchesFilter(
event,
subscription.topicConfiguration,
)
) {
// this subscription does not care for this event
continue;
}
console.log(`sending ${subscriptions.length} test messages`);
for (const s of subscriptions) {
try {
let backoff = 500;
let retry: boolean;
do {
console.log("SENDING");
console.dir("payload");
retry = await this.sendNotification(
s,
payload,
backoff,
);
backoff *= 2;
} while (retry);
} catch (error) {
console.error("Cannot send notification: ", error);
throw error;
}
}
}
}
await this.db.doRequest(
async prisma =>
prisma.pushSubscription.updateMany({
where: {
id: {
in: subscriptions.map(s => s.id),
},
},
data: {
lastNotified: new Date(Date.now()),
},
}),
null,
);
}
public async sendtestNotification(request: Request) {
const subscriptions = await this.db.doRequest(
async prisma =>
prisma.pushSubscription.findMany({ where: { topic: "TEST" } }),
request,
);
const payload: AngularPushPayload = {
notification: { notification: {
title: "ATitle", title: "ATitle",
body: "DatBod",
icon: "assets/some-icon.png",
vibrate: [100, 50, 100],
data: {
dateOfArrival: Date.now(),
primaryKey: 1
},
actions: [ actions: [
{ {
action: "explode", action: "explode",
title: "Make Boom" title: "Make Boom",
} },
] ],
} body: "DatBod",
} data: {
onActionClick: {
default: {
operation: NotificationActionOperation.OPEN_WINDOW,
},
explode: {
operation: NotificationActionOperation.OPEN_WINDOW,
url: "explode.html",
},
},
},
icon: "https://taekwondo-chemnitz.toni714.de/favicon.ico",
lang: "de-DE",
renotify: true,
requireInteraction: true,
tag: "group-tag",
timestamp: Date.now(),
vibrate: [100, 50, 100],
},
};
console.log(`sending ${subscriptions.length} test messages`); console.log(`sending ${subscriptions.length} test messages`);
for (const s of subscriptions) { for (const s of subscriptions) {
@@ -65,14 +241,18 @@ export class PushService{
endpoint: s.endpoint, endpoint: s.endpoint,
keys: { keys: {
auth: s.auth, auth: s.auth,
p256dh: s.p256dh p256dh: s.p256dh,
}, },
expirationTime: s.expirationTime expirationTime: s.expirationTime,
}; };
try { try {
const result = await webpush.sendNotification(subscription, JSON.stringify(payload), { const result = await webpush.sendNotification(
subscription,
JSON.stringify(payload),
{
topic: Topic.TEST, topic: Topic.TEST,
}); },
);
console.log("success"); console.log("success");
console.dir(result); console.dir(result);
} catch (error) { } catch (error) {
@@ -84,23 +264,109 @@ export class PushService{
public async reset(request: Request): Promise<void> { public async reset(request: Request): Promise<void> {
const userId = await this.userService.getUid(request, request.res); const userId = await this.userService.getUid(request, request.res);
const subscriptions = await this.db.doRequest(async prisma=>await prisma.pushSubscription.deleteMany({ const subscriptions = await this.db.doRequest(
where:{userId: userId} async prisma =>
}), request); await prisma.pushSubscription.deleteMany({
where: { userId: userId },
}),
request,
);
console.log(`deleted ${subscriptions.count} subs for ${userId}`); console.log(`deleted ${subscriptions.count} subs for ${userId}`);
} }
public async clearSubscription(clientId: string, request: Request): Promise<number> { public async clearSubscription(
clientId: string,
request: Request,
): Promise<number> {
const userId = await this.userService.getUid(request, request.res); const userId = await this.userService.getUid(request, request.res);
const batchResult =await this.db.doRequest(async prisma=>await prisma.pushSubscription.deleteMany({ const batchResult = await this.db.doRequest(
async prisma =>
await prisma.pushSubscription.deleteMany({
where: { where: {
userId: userId, userId: userId,
AND: { AND: {
clientId: clientId clientId: clientId,
} },
} },
}), request); }),
request,
);
return batchResult.count; 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;
}
} }
+5
View File
@@ -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 },
]; ];
@@ -0,0 +1,65 @@
<h1>Gesammelte Veranstaltungen</h1>
<p>
<button matButton="filled" (click)="subscribe()">Abbonieren</button>
<button matButton="filled" class="secondary-btn" (click)="unsubscribe()">
Abmelden
</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,79 @@
import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
viewChild,
} from '@angular/core';
import { MatButtonModule } from '@angular/material/button';
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],
templateUrl: './events.html',
styleUrl: './events.scss',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class Events implements OnInit {
private 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() {
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"
@@ -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[];
};
}
@@ -2,9 +2,9 @@ import { inject, Injectable } from '@angular/core';
import { SwPush } from '@angular/service-worker'; import { SwPush } from '@angular/service-worker';
import { v7 as uuidv7 } from 'uuid'; import { v7 as uuidv7 } from 'uuid';
import * as api from '../../generated-api/api'; import * as api from '../../generated-api/api';
import VAPID from './vapid.json' with { type: "json" }; import VAPID from './vapid.json' with { type: 'json' };
const SUBSCRIPTION_CLIENT_ID="subscriptionClientId"; const SUBSCRIPTION_CLIENT_ID = 'subscriptionClientId';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class PushService { export class PushService {
@@ -26,16 +26,16 @@ export class PushService{
public async subscribeToEvents() { public async subscribeToEvents() {
try { try {
const subscription = await this.swPush.requestSubscription({ const subscription = await this.swPush.requestSubscription({
serverPublicKey: VAPID.publicKey serverPublicKey: VAPID.publicKey,
}); });
const decoder = new TextDecoder(); const previousCliendId = localStorage.getItem(
SUBSCRIPTION_CLIENT_ID,
const previousCliendId = localStorage.getItem(SUBSCRIPTION_CLIENT_ID); );
if (previousCliendId) { if (previousCliendId) {
try { try {
await api.clearSubscription(previousCliendId); await api.clearSubscription(previousCliendId);
console.log("previous subscription cleared"); console.log('previous subscription cleared');
} catch {} } catch {}
} }
@@ -45,17 +45,19 @@ export class PushService{
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
keys: { keys: {
auth: this.arrayBufferToString(subscription.getKey('auth')), auth: this.arrayBufferToString(subscription.getKey('auth')),
p256dh: this.arrayBufferToString(subscription.getKey('p256dh')), p256dh: this.arrayBufferToString(
subscription.getKey('p256dh'),
),
}, },
expirationTime: subscription.expirationTime, expirationTime: subscription.expirationTime,
topic: api.Topic.Test, topic: api.Topic.Events,
topicConfiguration: null, topicConfiguration: null,
clientId: newClientId, clientId: newClientId,
}); });
localStorage.setItem(SUBSCRIPTION_CLIENT_ID, newClientId); localStorage.setItem(SUBSCRIPTION_CLIENT_ID, newClientId);
} catch (error) { } catch (error) {
console.error("Subscribing failed: ", error); console.error('Subscribing failed: ', error);
} }
} }
+126 -57
View File
@@ -24,8 +24,8 @@ importers:
specifier: ^7.4.2 specifier: ^7.4.2
version: 7.4.2 version: 7.4.2
'@prisma/client': '@prisma/client':
specifier: ^7.4.2 specifier: ^7.5.0
version: 7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3) version: 7.5.0(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)
'@tsoa/runtime': '@tsoa/runtime':
specifier: ^6.6.0 specifier: ^6.6.0
version: 6.6.0 version: 6.6.0
@@ -38,6 +38,12 @@ importers:
crawlee: crawlee:
specifier: ^3.16.0 specifier: ^3.16.0
version: 3.16.0(@types/node@25.3.5)(playwright@1.58.2) version: 3.16.0(@types/node@25.3.5)(playwright@1.58.2)
cron:
specifier: ^4.4.0
version: 4.4.0
date-fns:
specifier: ^4.1.0
version: 4.1.0
dotenv: dotenv:
specifier: ^17.3.1 specifier: ^17.3.1
version: 17.3.1 version: 17.3.1
@@ -91,8 +97,8 @@ importers:
specifier: ^3.6.4 specifier: ^3.6.4
version: 3.6.4 version: 3.6.4
prisma: prisma:
specifier: ^7.4.2 specifier: ^7.5.0
version: 7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) version: 7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
tsx: tsx:
specifier: ^4.21.0 specifier: ^4.21.0
version: 4.21.0 version: 4.21.0
@@ -1273,42 +1279,49 @@ packages:
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@napi-rs/nice-linux-arm64-musl@1.1.1': '@napi-rs/nice-linux-arm64-musl@1.1.1':
resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==} resolution: {integrity: sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@napi-rs/nice-linux-ppc64-gnu@1.1.1': '@napi-rs/nice-linux-ppc64-gnu@1.1.1':
resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==} resolution: {integrity: sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@napi-rs/nice-linux-riscv64-gnu@1.1.1': '@napi-rs/nice-linux-riscv64-gnu@1.1.1':
resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==} resolution: {integrity: sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@napi-rs/nice-linux-s390x-gnu@1.1.1': '@napi-rs/nice-linux-s390x-gnu@1.1.1':
resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==} resolution: {integrity: sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@napi-rs/nice-linux-x64-gnu@1.1.1': '@napi-rs/nice-linux-x64-gnu@1.1.1':
resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==} resolution: {integrity: sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@napi-rs/nice-linux-x64-musl@1.1.1': '@napi-rs/nice-linux-x64-musl@1.1.1':
resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==} resolution: {integrity: sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw==}
engines: {node: '>= 10'} engines: {node: '>= 10'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@napi-rs/nice-openharmony-arm64@1.1.1': '@napi-rs/nice-openharmony-arm64@1.1.1':
resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==} resolution: {integrity: sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ==}
@@ -1416,36 +1429,42 @@ packages:
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.6': '@parcel/watcher-linux-arm-musl@2.5.6':
resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.6': '@parcel/watcher-linux-arm64-glibc@2.5.6':
resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.6': '@parcel/watcher-linux-arm64-musl@2.5.6':
resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.6': '@parcel/watcher-linux-x64-glibc@2.5.6':
resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.6': '@parcel/watcher-linux-x64-musl@2.5.6':
resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==}
engines: {node: '>= 10.0.0'} engines: {node: '>= 10.0.0'}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.6': '@parcel/watcher-win32-arm64@2.5.6':
resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==}
@@ -1476,11 +1495,11 @@ packages:
'@prisma/adapter-pg@7.4.2': '@prisma/adapter-pg@7.4.2':
resolution: {integrity: sha512-oUo2Zhe9Tf6YwVL8kLPuOLTK1Z2pwi/Ua77t2PuGyBan2w7shRKqHvYK+3XXmRH9RWhPJ4SMtHZKpNo6Ax/4bQ==} resolution: {integrity: sha512-oUo2Zhe9Tf6YwVL8kLPuOLTK1Z2pwi/Ua77t2PuGyBan2w7shRKqHvYK+3XXmRH9RWhPJ4SMtHZKpNo6Ax/4bQ==}
'@prisma/client-runtime-utils@7.4.2': '@prisma/client-runtime-utils@7.5.0':
resolution: {integrity: sha512-cID+rzOEb38VyMsx5LwJMEY4NGIrWCNpKu/0ImbeooQ2Px7TI+kOt7cm0NelxUzF2V41UVVXAmYjANZQtCu1/Q==} resolution: {integrity: sha512-KnJ2b4Si/pcWEtK68uM+h0h1oh80CZt2suhLTVuLaSKg4n58Q9jBF/A42Kw6Ma+aThy1yAhfDeTC0JvEmeZnFQ==}
'@prisma/client@7.4.2': '@prisma/client@7.5.0':
resolution: {integrity: sha512-ts2mu+cQHriAhSxngO3StcYubBGTWDtu/4juZhXCUKOwgh26l+s4KD3vT2kMUzFyrYnll9u/3qWrtzRv9CGWzA==} resolution: {integrity: sha512-h4hF9ctp+kSRs7ENHGsFQmHAgHcfkOCxbYt6Ti9Xi8x7D+kP4tTi9x51UKmiTH/OqdyJAO+8V+r+JA5AWdav7w==}
engines: {node: ^20.19 || ^22.12 || >=24.0} engines: {node: ^20.19 || ^22.12 || >=24.0}
peerDependencies: peerDependencies:
prisma: '*' prisma: '*'
@@ -1491,8 +1510,8 @@ packages:
typescript: typescript:
optional: true optional: true
'@prisma/config@7.4.2': '@prisma/config@7.5.0':
resolution: {integrity: sha512-CftBjWxav99lzY1Z4oDgomdb1gh9BJFAOmWF6P2v1xRfXqQb56DfBub+QKcERRdNoAzCb3HXy3Zii8Vb4AsXhg==} resolution: {integrity: sha512-1J/9YEX7A889xM46PYg9e8VAuSL1IUmXJW3tEhMv7XQHDWlfC9YSkIw9sTYRaq5GswGlxZ+GnnyiNsUZ9JJhSQ==}
'@prisma/debug@7.2.0': '@prisma/debug@7.2.0':
resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==}
@@ -1500,32 +1519,36 @@ packages:
'@prisma/debug@7.4.2': '@prisma/debug@7.4.2':
resolution: {integrity: sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w==} resolution: {integrity: sha512-aP7qzu+g/JnbF6U69LMwHoUkELiserKmWsE2shYuEpNUJ4GrtxBCvZwCyCBHFSH2kLTF2l1goBlBh4wuvRq62w==}
'@prisma/debug@7.5.0':
resolution: {integrity: sha512-163+nffny0JoPEkDhfNco0vcuT3ymIJc9+WX7MHSQhfkeKUmKe9/wqvGk5SjppT93DtBjVwr5HPJYlXbzm6qtg==}
'@prisma/dev@0.20.0': '@prisma/dev@0.20.0':
resolution: {integrity: sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==} resolution: {integrity: sha512-ovlBYwWor0OzG+yH4J3Ot+AneD818BttLA+Ii7wjbcLHUrnC4tbUPVGyNd3c/+71KETPKZfjhkTSpdS15dmXNQ==}
'@prisma/driver-adapter-utils@7.4.2': '@prisma/driver-adapter-utils@7.4.2':
resolution: {integrity: sha512-REdjFpT/ye9KdDs+CXAXPIbMQkVLhne9G5Pe97sNY4Ovx4r2DAbWM9hOFvvB1Oq8H8bOCdu0Ri3AoGALquQqVw==} resolution: {integrity: sha512-REdjFpT/ye9KdDs+CXAXPIbMQkVLhne9G5Pe97sNY4Ovx4r2DAbWM9hOFvvB1Oq8H8bOCdu0Ri3AoGALquQqVw==}
'@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': '@prisma/engines-version@7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e':
resolution: {integrity: sha512-5FIKY3KoYQlBuZC2yc16EXfVRQ8HY+fLqgxkYfWCtKhRb3ajCRzP/rPeoSx11+NueJDANdh4hjY36mdmrTcGSg==} resolution: {integrity: sha512-E+iRV/vbJLl8iGjVr6g/TEWokA+gjkV/doZkaQN1i/ULVdDwGnPJDfLUIFGS3BVwlG/m6L8T4x1x5isl8hGMxA==}
'@prisma/engines@7.4.2': '@prisma/engines@7.5.0':
resolution: {integrity: sha512-B+ZZhI4rXlzjVqRw/93AothEKOU5/x4oVyJFGo9RpHPnBwaPwk4Pi0Q4iGXipKxeXPs/dqljgNBjK0m8nocOJA==} resolution: {integrity: sha512-ondGRhzoaVpRWvFaQ5wH5zS1BIbhzbKqczKjCn6j3L0Zfe/LInjcEg8+xtB49AuZBX30qyx1ZtGoootUohz2pw==}
'@prisma/fetch-engine@7.4.2': '@prisma/fetch-engine@7.5.0':
resolution: {integrity: sha512-f/c/MwYpdJO7taLETU8rahEstLeXfYgQGlz5fycG7Fbmva3iPdzGmjiSWHeSWIgNnlXnelUdCJqyZnFocurZuA==} resolution: {integrity: sha512-kZCl2FV54qnyrVdnII8MI6qvt7HfU6Cbiz8dZ8PXz4f4lbSw45jEB9/gEMK2SGdiNhBKyk/Wv95uthoLhGMLYA==}
'@prisma/get-platform@7.2.0': '@prisma/get-platform@7.2.0':
resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==}
'@prisma/get-platform@7.4.2': '@prisma/get-platform@7.5.0':
resolution: {integrity: sha512-UTnChXRwiauzl/8wT4hhe7Xmixja9WE28oCnGpBtRejaHhvekx5kudr3R4Y9mLSA0kqGnAMeyTiKwDVMjaEVsw==} resolution: {integrity: sha512-7I+2y1nu/gkEKSiHHbcZ1HPe/euGdEqJZxEEMT0246q4De1+hla0ZzlTgvaT9dHcVCgLSuCG8v39db5qUUWNgw==}
'@prisma/query-plan-executor@7.2.0': '@prisma/query-plan-executor@7.2.0':
resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==}
'@prisma/studio-core@0.13.1': '@prisma/studio-core@0.21.1':
resolution: {integrity: sha512-agdqaPEePRHcQ7CexEfkX1RvSH9uWDb6pXrZnhCRykhDFAV0/0P3d07WtfiY8hZWb7oRU4v+NkT4cGFHkQJIPg==} resolution: {integrity: sha512-bOGqG/eMQtKC0XVvcVLRmhWWzm/I+0QUWqAEhEBtetpuS3k3V4IWqKGUONkAIT223DNXJMxMtZp36b1FmcdPeg==}
engines: {node: ^20.19 || ^22.12 || ^24.0, pnpm: '8'}
peerDependencies: peerDependencies:
'@types/react': ^18.0.0 || ^19.0.0 '@types/react': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0 react: ^18.0.0 || ^19.0.0
@@ -1566,24 +1589,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-rc.4': '@rolldown/binding-linux-arm64-musl@1.0.0-rc.4':
resolution: {integrity: sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==} resolution: {integrity: sha512-lU+6rgXXViO61B4EudxtVMXSOfiZONR29Sys5VGSetUY7X8mg9FCKIIjcPPj8xNDeYzKl+H8F/qSKOBVFJChCQ==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-rc.4': '@rolldown/binding-linux-x64-gnu@1.0.0-rc.4':
resolution: {integrity: sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==} resolution: {integrity: sha512-DZaN1f0PGp/bSvKhtw50pPsnln4T13ycDq1FrDWRiHmWt1JeW+UtYg9touPFf8yt993p8tS2QjybpzKNTxYEwg==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-rc.4': '@rolldown/binding-linux-x64-musl@1.0.0-rc.4':
resolution: {integrity: sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==} resolution: {integrity: sha512-RnGxwZLN7fhMMAItnD6dZ7lvy+TI7ba+2V54UF4dhaWa/p8I/ys1E73KO6HmPmgz92ZkfD8TXS1IMV8+uhbR9g==}
engines: {node: ^20.19.0 || >=22.12.0} engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-rc.4': '@rolldown/binding-openharmony-arm64@1.0.0-rc.4':
resolution: {integrity: sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==} resolution: {integrity: sha512-6lcI79+X8klGiGd8yHuTgQRjuuJYNggmEml+RsyN596P23l/zf9FVmJ7K0KVKkFAeYEdg0iMUKyIxiV5vebDNQ==}
@@ -1645,66 +1672,79 @@ packages:
resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm-musleabihf@4.59.0': '@rollup/rollup-linux-arm-musleabihf@4.59.0':
resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==}
cpu: [arm] cpu: [arm]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-arm64-gnu@4.59.0': '@rollup/rollup-linux-arm64-gnu@4.59.0':
resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-arm64-musl@4.59.0': '@rollup/rollup-linux-arm64-musl@4.59.0':
resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==}
cpu: [arm64] cpu: [arm64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-loong64-gnu@4.59.0': '@rollup/rollup-linux-loong64-gnu@4.59.0':
resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-loong64-musl@4.59.0': '@rollup/rollup-linux-loong64-musl@4.59.0':
resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==}
cpu: [loong64] cpu: [loong64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-ppc64-gnu@4.59.0': '@rollup/rollup-linux-ppc64-gnu@4.59.0':
resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-ppc64-musl@4.59.0': '@rollup/rollup-linux-ppc64-musl@4.59.0':
resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==}
cpu: [ppc64] cpu: [ppc64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-riscv64-gnu@4.59.0': '@rollup/rollup-linux-riscv64-gnu@4.59.0':
resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-riscv64-musl@4.59.0': '@rollup/rollup-linux-riscv64-musl@4.59.0':
resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==}
cpu: [riscv64] cpu: [riscv64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-linux-s390x-gnu@4.59.0': '@rollup/rollup-linux-s390x-gnu@4.59.0':
resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==}
cpu: [s390x] cpu: [s390x]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-gnu@4.59.0': '@rollup/rollup-linux-x64-gnu@4.59.0':
resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [glibc]
'@rollup/rollup-linux-x64-musl@4.59.0': '@rollup/rollup-linux-x64-musl@4.59.0':
resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==}
cpu: [x64] cpu: [x64]
os: [linux] os: [linux]
libc: [musl]
'@rollup/rollup-openbsd-x64@4.59.0': '@rollup/rollup-openbsd-x64@4.59.0':
resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==}
@@ -1889,6 +1929,9 @@ packages:
'@types/koa@2.15.0': '@types/koa@2.15.0':
resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==} resolution: {integrity: sha512-7QFsywoE5URbuVnG3loe03QXuGajrnotr3gQkXcEBShORai23MePfFYdhz90FEtBBpkyIYQbVD+evKtloCgX3g==}
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/multer@1.4.13': '@types/multer@1.4.13':
resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==} resolution: {integrity: sha512-bhhdtPw7JqCiEfC9Jimx5LqX9BDIPJEh2q/fQ4bqbBPtyEZYr3cvF22NwG0DmPZNYA0CAf2CnqDB4KIGGpJcaw==}
@@ -2221,8 +2264,8 @@ packages:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'} engines: {node: '>=18'}
chromedriver@146.0.2: chromedriver@146.0.3:
resolution: {integrity: sha512-/A6ht59pGGrV3bU6eC//yH6W+NRexVGXy/KEe+pNn1MP5Xb34krSA02bGlYuA5XCrfdXPsFI//slvsOBwH//4Q==} resolution: {integrity: sha512-RFhEoG3bmI+TaDm/9vDNntkxM76Sb/VIxvX3kHPzfQ3wvmE+PPSTw6gnD+Oa+dcJAc/kg/vQAwMrOUs8E1LdyQ==}
engines: {node: '>=20'} engines: {node: '>=20'}
hasBin: true hasBin: true
@@ -2363,6 +2406,10 @@ packages:
puppeteer: puppeteer:
optional: true optional: true
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
engines: {node: '>=18.x'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -3408,6 +3455,10 @@ packages:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==} resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22} engines: {node: 20 || >=22}
lru-cache@11.2.7:
resolution: {integrity: sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==}
engines: {node: 20 || >=22}
lru-cache@5.1.1: lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@@ -3419,6 +3470,10 @@ packages:
resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
luxon@3.7.2:
resolution: {integrity: sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==}
engines: {node: '>=12'}
magic-string@0.30.21: magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
@@ -3976,8 +4031,8 @@ packages:
resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==}
engines: {node: '>=12'} engines: {node: '>=12'}
prisma@7.4.2: prisma@7.5.0:
resolution: {integrity: sha512-2bP8Ruww3Q95Z2eH4Yqh4KAENRsj/SxbdknIVBfd6DmjPwmpsC4OVFMLOeHt6tM3Amh8ebjvstrUz3V/hOe1dA==} resolution: {integrity: sha512-n30qZpWehaYQzigLjmuPisyEsvOzHt7bZeRyg8gZ5DvJo9FGjD+gNaY59Ns3hlLD5/jZH5GBeftIss0jDbUoLg==}
engines: {node: ^20.19 || ^22.12 || >=24.0} engines: {node: ^20.19 || ^22.12 || >=24.0}
hasBin: true hasBin: true
peerDependencies: peerDependencies:
@@ -4015,8 +4070,9 @@ packages:
proxy-from-env@1.1.0: proxy-from-env@1.1.0:
resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==}
proxy-from-env@2.0.0: proxy-from-env@2.1.0:
resolution: {integrity: sha512-h2lD3OfRraP3R51rNFKIE8nX+qoLr1mE74X91YhVxtDbt+OD6ntoNZv56+JgI4RCdtwQ5eexsOk1KdOQDfvPCQ==} resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
pstree.remy@1.1.8: pstree.remy@1.1.8:
resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==} resolution: {integrity: sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w==}
@@ -4457,8 +4513,8 @@ packages:
tiny-typed-emitter@2.1.0: tiny-typed-emitter@2.1.0:
resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==} resolution: {integrity: sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==}
tinyexec@1.0.2: tinyexec@1.0.4:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==}
engines: {node: '>=18'} engines: {node: '>=18'}
tinyglobby@0.2.15: tinyglobby@0.2.15:
@@ -6196,7 +6252,7 @@ snapshots:
agent-base: 7.1.4 agent-base: 7.1.4
http-proxy-agent: 7.0.2 http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6 https-proxy-agent: 7.0.6
lru-cache: 11.2.6 lru-cache: 11.2.7
socks-proxy-agent: 8.0.5 socks-proxy-agent: 8.0.5
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
@@ -6329,16 +6385,16 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- pg-native - pg-native
'@prisma/client-runtime-utils@7.4.2': {} '@prisma/client-runtime-utils@7.5.0': {}
'@prisma/client@7.4.2(prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)': '@prisma/client@7.5.0(prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3))(typescript@5.9.3)':
dependencies: dependencies:
'@prisma/client-runtime-utils': 7.4.2 '@prisma/client-runtime-utils': 7.5.0
optionalDependencies: optionalDependencies:
prisma: 7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3) prisma: 7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
typescript: 5.9.3 typescript: 5.9.3
'@prisma/config@7.4.2': '@prisma/config@7.5.0':
dependencies: dependencies:
c12: 3.1.0 c12: 3.1.0
deepmerge-ts: 7.1.5 deepmerge-ts: 7.1.5
@@ -6351,6 +6407,8 @@ snapshots:
'@prisma/debug@7.4.2': {} '@prisma/debug@7.4.2': {}
'@prisma/debug@7.5.0': {}
'@prisma/dev@0.20.0(typescript@5.9.3)': '@prisma/dev@0.20.0(typescript@5.9.3)':
dependencies: dependencies:
'@electric-sql/pglite': 0.3.15 '@electric-sql/pglite': 0.3.15
@@ -6377,32 +6435,32 @@ snapshots:
dependencies: dependencies:
'@prisma/debug': 7.4.2 '@prisma/debug': 7.4.2
'@prisma/engines-version@7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919': {} '@prisma/engines-version@7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e': {}
'@prisma/engines@7.4.2': '@prisma/engines@7.5.0':
dependencies: dependencies:
'@prisma/debug': 7.4.2 '@prisma/debug': 7.5.0
'@prisma/engines-version': 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/engines-version': 7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e
'@prisma/fetch-engine': 7.4.2 '@prisma/fetch-engine': 7.5.0
'@prisma/get-platform': 7.4.2 '@prisma/get-platform': 7.5.0
'@prisma/fetch-engine@7.4.2': '@prisma/fetch-engine@7.5.0':
dependencies: dependencies:
'@prisma/debug': 7.4.2 '@prisma/debug': 7.5.0
'@prisma/engines-version': 7.5.0-10.94a226be1cf2967af2541cca5529f0f7ba866919 '@prisma/engines-version': 7.5.0-15.280c870be64f457428992c43c1f6d557fab6e29e
'@prisma/get-platform': 7.4.2 '@prisma/get-platform': 7.5.0
'@prisma/get-platform@7.2.0': '@prisma/get-platform@7.2.0':
dependencies: dependencies:
'@prisma/debug': 7.2.0 '@prisma/debug': 7.2.0
'@prisma/get-platform@7.4.2': '@prisma/get-platform@7.5.0':
dependencies: dependencies:
'@prisma/debug': 7.4.2 '@prisma/debug': 7.5.0
'@prisma/query-plan-executor@7.2.0': {} '@prisma/query-plan-executor@7.2.0': {}
'@prisma/studio-core@0.13.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': '@prisma/studio-core@0.21.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)':
dependencies: dependencies:
'@types/react': 19.2.14 '@types/react': 19.2.14
react: 19.2.4 react: 19.2.4
@@ -6728,6 +6786,8 @@ snapshots:
'@types/koa-compose': 3.2.9 '@types/koa-compose': 3.2.9
'@types/node': 25.3.5 '@types/node': 25.3.5
'@types/luxon@3.7.1': {}
'@types/multer@1.4.13': '@types/multer@1.4.13':
dependencies: dependencies:
'@types/express': 5.0.6 '@types/express': 5.0.6
@@ -7128,14 +7188,14 @@ snapshots:
chownr@3.0.0: {} chownr@3.0.0: {}
chromedriver@146.0.2: chromedriver@146.0.3:
dependencies: dependencies:
'@testim/chrome-version': 1.1.4 '@testim/chrome-version': 1.1.4
axios: 1.13.6 axios: 1.13.6
compare-versions: 6.1.1 compare-versions: 6.1.1
extract-zip: 2.0.1 extract-zip: 2.0.1
proxy-agent: 6.5.0 proxy-agent: 6.5.0
proxy-from-env: 2.0.0 proxy-from-env: 2.1.0
tcp-port-used: 1.0.2 tcp-port-used: 1.0.2
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
@@ -7276,6 +7336,11 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
cron@4.4.0:
dependencies:
'@types/luxon': 3.7.1
luxon: 3.7.2
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1
@@ -8452,7 +8517,7 @@ snapshots:
dependencies: dependencies:
jwk-to-pem: 2.0.7 jwk-to-pem: 2.0.7
optionalDependencies: optionalDependencies:
chromedriver: 146.0.2 chromedriver: 146.0.3
transitivePeerDependencies: transitivePeerDependencies:
- debug - debug
- supports-color - supports-color
@@ -8548,6 +8613,8 @@ snapshots:
lru-cache@11.2.6: {} lru-cache@11.2.6: {}
lru-cache@11.2.7: {}
lru-cache@5.1.1: lru-cache@5.1.1:
dependencies: dependencies:
yallist: 3.1.1 yallist: 3.1.1
@@ -8557,6 +8624,8 @@ snapshots:
lru.min@1.1.4: {} lru.min@1.1.4: {}
luxon@3.7.2: {}
magic-string@0.30.21: magic-string@0.30.21:
dependencies: dependencies:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
@@ -8862,7 +8931,7 @@ snapshots:
dependencies: dependencies:
citty: 0.2.1 citty: 0.2.1
pathe: 2.0.3 pathe: 2.0.3
tinyexec: 1.0.2 tinyexec: 1.0.4
oazapfts@7.4.2(@oazapfts/runtime@1.2.0)(openapi-types@12.1.3): oazapfts@7.4.2(@oazapfts/runtime@1.2.0)(openapi-types@12.1.3):
dependencies: dependencies:
@@ -9159,12 +9228,12 @@ snapshots:
postgres@3.4.7: {} postgres@3.4.7: {}
prisma@7.4.2(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3): prisma@7.5.0(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
dependencies: dependencies:
'@prisma/config': 7.4.2 '@prisma/config': 7.5.0
'@prisma/dev': 0.20.0(typescript@5.9.3) '@prisma/dev': 0.20.0(typescript@5.9.3)
'@prisma/engines': 7.4.2 '@prisma/engines': 7.5.0
'@prisma/studio-core': 0.13.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@prisma/studio-core': 0.21.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
mysql2: 3.15.3 mysql2: 3.15.3
postgres: 3.4.7 postgres: 3.4.7
optionalDependencies: optionalDependencies:
@@ -9218,7 +9287,7 @@ snapshots:
proxy-from-env@1.1.0: proxy-from-env@1.1.0:
optional: true optional: true
proxy-from-env@2.0.0: proxy-from-env@2.1.0:
optional: true optional: true
pstree.remy@1.1.8: {} pstree.remy@1.1.8: {}
@@ -9742,7 +9811,7 @@ snapshots:
tiny-typed-emitter@2.1.0: {} tiny-typed-emitter@2.1.0: {}
tinyexec@1.0.2: {} tinyexec@1.0.4: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
dependencies: dependencies: