diff --git a/backend/@types/express/index.d.ts b/backend/@types/express/index.d.ts index a321af40..082e4f09 100644 --- a/backend/@types/express/index.d.ts +++ b/backend/@types/express/index.d.ts @@ -10,6 +10,9 @@ import type { Bed, Room, File, + Newsletter, + NewsletterManager, + NewsletterSubscriber, } from '@prisma/client'; import type { ZodObject, z } from 'zod'; import type { JsonResource } from '#core/resource/JsonResource'; @@ -23,10 +26,13 @@ declare global { tableTemplate?: TableTemplate; message?: Message & { attachments: File[] }; messageTemplate?: MessageTemplate & { attachments: File[] }; - manager?: CampManager; + campManager?: CampManager; room?: Room & { beds: Bed[] }; bed?: Bed; file?: File; + newsletter?: Newsletter; + newsletterManager?: NewsletterManager; + subscriber?: NewsletterSubscriber; } interface AuthUser { diff --git a/backend/prisma/migrations/20260326143712_add_newsletter_tables/migration.sql b/backend/prisma/migrations/20260326143712_add_newsletter_tables/migration.sql new file mode 100644 index 00000000..f67c9ce4 --- /dev/null +++ b/backend/prisma/migrations/20260326143712_add_newsletter_tables/migration.sql @@ -0,0 +1,50 @@ +-- CreateTable +CREATE TABLE `newsletters` ( + `id` CHAR(26) NOT NULL, + `name` VARCHAR(255) NOT NULL, + `description` TEXT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` DATETIME(3) NULL, + + UNIQUE INDEX `newsletters_id_unique`(`id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `newsletter_managers` ( + `id` CHAR(26) NOT NULL, + `newsletter_id` CHAR(26) NOT NULL, + `user_id` CHAR(26) NOT NULL, + + UNIQUE INDEX `newsletter_managers_id_unique`(`id`), + UNIQUE INDEX `newsletter_managers_newsletter_id_user_id_unique`(`newsletter_id`, `user_id`), + INDEX `newsletter_managers_newsletter_id_index`(`newsletter_id`), + INDEX `newsletter_managers_user_id_index`(`user_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- CreateTable +CREATE TABLE `newsletter_subscribers` ( + `id` CHAR(26) NOT NULL, + `newsletter_id` CHAR(26) NOT NULL, + `email` VARCHAR(255) NOT NULL, + `name` VARCHAR(255) NULL, + `country` VARCHAR(5) NULL, + `unsubscribe_token` CHAR(64) NOT NULL, + `subscribed_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + UNIQUE INDEX `newsletter_subscribers_id_unique`(`id`), + UNIQUE INDEX `newsletter_subscribers_unsubscribe_token_unique`(`unsubscribe_token`), + UNIQUE INDEX `newsletter_subscribers_newsletter_id_email_unique`(`newsletter_id`, `email`), + INDEX `newsletter_subscribers_newsletter_id_index`(`newsletter_id`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +-- AddForeignKey +ALTER TABLE `newsletter_managers` ADD CONSTRAINT `newsletter_managers_newsletter_id_foreign` FOREIGN KEY (`newsletter_id`) REFERENCES `newsletters`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `newsletter_managers` ADD CONSTRAINT `newsletter_managers_user_id_foreign` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE `newsletter_subscribers` ADD CONSTRAINT `newsletter_subscribers_newsletter_id_foreign` FOREIGN KEY (`newsletter_id`) REFERENCES `newsletters`(`id`) ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 31f50f44..ae89f211 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -29,6 +29,7 @@ model User { campRoles CampManager[] tokens Token[] + newsletterManagers NewsletterManager[] @@map("users") } @@ -303,3 +304,46 @@ model JobRateLimit { @@map("job_rate_limits") } + +model Newsletter { + id String @id @unique(map: "newsletters_id_unique") @default(ulid()) @db.Char(26) + name String @db.VarChar(255) + description String? @db.Text + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime? @updatedAt @map("updated_at") + + managers NewsletterManager[] + subscribers NewsletterSubscriber[] + + @@map("newsletters") +} + +model NewsletterManager { + id String @id @unique(map: "newsletter_managers_id_unique") @default(ulid()) @db.Char(26) + newsletterId String @map("newsletter_id") @db.Char(26) + userId String @map("user_id") @db.Char(26) + + newsletter Newsletter @relation(fields: [newsletterId], references: [id], onDelete: Cascade, onUpdate: Cascade, map: "newsletter_managers_newsletter_id_foreign") + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade, map: "newsletter_managers_user_id_foreign") + + @@unique([newsletterId, userId], map: "newsletter_managers_newsletter_id_user_id_unique") + @@index([newsletterId], map: "newsletter_managers_newsletter_id_index") + @@index([userId], map: "newsletter_managers_user_id_index") + @@map("newsletter_managers") +} + +model NewsletterSubscriber { + id String @id @unique(map: "newsletter_subscribers_id_unique") @default(ulid()) @db.Char(26) + newsletterId String @map("newsletter_id") @db.Char(26) + email String @db.VarChar(255) + name String? @db.VarChar(255) + country String? @db.VarChar(5) + unsubscribeToken String @unique(map: "newsletter_subscribers_unsubscribe_token_unique") @map("unsubscribe_token") @db.Char(64) + subscribedAt DateTime @default(now()) @map("subscribed_at") + + newsletter Newsletter @relation(fields: [newsletterId], references: [id], onDelete: Cascade, onUpdate: Cascade, map: "newsletter_subscribers_newsletter_id_foreign") + + @@unique([newsletterId, email], map: "newsletter_subscribers_newsletter_id_email_unique") + @@index([newsletterId], map: "newsletter_subscribers_newsletter_id_index") + @@map("newsletter_subscribers") +} diff --git a/backend/prisma/seeders/camp-manager.seeder.ts b/backend/prisma/seeders/camp-manager.seeder.ts index 8f1408ac..76790dfa 100644 --- a/backend/prisma/seeders/camp-manager.seeder.ts +++ b/backend/prisma/seeders/camp-manager.seeder.ts @@ -3,7 +3,7 @@ import { CampManagerFactory } from '../factories'; class CampManagerSeeder extends BaseSeeder { name(): string { - return 'manager'; + return 'camp-manager'; } async run(): Promise { diff --git a/backend/src/app/auth/auth.controller.ts b/backend/src/app/auth/auth.controller.ts index 00a1a947..39a8a74d 100644 --- a/backend/src/app/auth/auth.controller.ts +++ b/backend/src/app/auth/auth.controller.ts @@ -6,7 +6,7 @@ import { type Request, type Response } from 'express'; import type { AuthTokensResponse } from '#types/response'; import type { AppConfig } from '#config/index'; import ApiError from '#utils/ApiError'; -import { ManagerService } from '#app/manager/manager.service'; +import { CampManagerService } from '#app/campManager/camp-manager.service.js'; import authResource from './auth.resource.js'; import validator from './auth.validation.js'; import { TotpService } from '#app/totp/totp.service'; @@ -24,7 +24,8 @@ export class AuthController extends BaseController { @Config() private readonly config: AppConfig, @inject(AuthService) private readonly authService: AuthService, @inject(UserService) private readonly userService: UserService, - @inject(ManagerService) private readonly managerService: ManagerService, + @inject(CampManagerService) + private readonly managerService: CampManagerService, @inject(TokenService) private readonly tokenService: TokenService, @inject(TotpService) private readonly totpService: TotpService, ) { diff --git a/backend/src/app/camp/camp.controller.ts b/backend/src/app/camp/camp.controller.ts index 1daabafa..2c3282e4 100644 --- a/backend/src/app/camp/camp.controller.ts +++ b/backend/src/app/camp/camp.controller.ts @@ -13,7 +13,7 @@ import validator from './camp.validation.js'; import type { Request, Response } from 'express'; import { BaseController } from '#core/base/BaseController'; import { MessageTemplateService } from '#app/messageTemplate/message-template.service'; -import { ManagerService } from '#app/manager/manager.service'; +import { CampManagerService } from '#app/campManager/camp-manager.service.js'; import ApiError from '#utils/ApiError'; import { inject, injectable } from 'inversify'; @@ -22,7 +22,8 @@ export class CampController extends BaseController { constructor( @inject(CampService) private readonly campService: CampService, @inject(FileService) private readonly fileService: FileService, - @inject(ManagerService) private readonly managerService: ManagerService, + @inject(CampManagerService) + private readonly managerService: CampManagerService, @inject(RegistrationService) private readonly registrationService: RegistrationService, @inject(TableTemplateService) diff --git a/backend/src/app/manager/manager.controller.ts b/backend/src/app/campManager/camp-manager.controller.ts similarity index 74% rename from backend/src/app/manager/manager.controller.ts rename to backend/src/app/campManager/camp-manager.controller.ts index 459690c6..f25e3987 100644 --- a/backend/src/app/manager/manager.controller.ts +++ b/backend/src/app/campManager/camp-manager.controller.ts @@ -1,18 +1,19 @@ import ApiError from '#utils/ApiError'; import httpStatus from 'http-status'; import { UserService } from '#app/user/user.service'; -import { ManagerService } from '#app/manager/manager.service'; -import { ManagerResource } from '#app/manager/manager.resource'; -import validator from '#app/manager/manager.validation'; +import { CampManagerService } from '#app/campManager/camp-manager.service.js'; +import { CampManagerResource } from '#app/campManager/camp-manager.resource.js'; +import validator from '#app/campManager/camp-manager.validation'; import { type Request, type Response } from 'express'; -import { ManagerInvitationMessage } from '#app/manager/manager.messages'; +import { CampManagerInvitationMessage } from '#app/campManager/camp-manager.messages'; import { BaseController } from '#core/base/BaseController'; import { inject, injectable } from 'inversify'; @injectable() -export class ManagerController extends BaseController { +export class CampManagerController extends BaseController { constructor( - @inject(ManagerService) private readonly managerService: ManagerService, + @inject(CampManagerService) + private readonly managerService: CampManagerService, @inject(UserService) private readonly userService: UserService, ) { super(); @@ -25,7 +26,7 @@ export class ManagerController extends BaseController { const managers = await this.managerService.getManagers(campId); - res.resource(ManagerResource.collection(managers)); + res.resource(CampManagerResource.collection(managers)); } async store(req: Request, res: Response) { @@ -57,16 +58,16 @@ export class ManagerController extends BaseController { ? await this.managerService.inviteManager(camp.id, email, data) : await this.managerService.addManager(camp.id, user.id, data); - await ManagerInvitationMessage.enqueue({ + await CampManagerInvitationMessage.enqueue({ camp, manager, }); - res.status(httpStatus.CREATED).resource(new ManagerResource(manager)); + res.status(httpStatus.CREATED).resource(new CampManagerResource(manager)); } async update(req: Request, res: Response) { - const manager = req.modelOrFail('manager'); + const manager = req.modelOrFail('message'); const { body: { role, expiresAt }, } = await req.validate(validator.update); @@ -79,7 +80,7 @@ export class ManagerController extends BaseController { }, ); - res.resource(new ManagerResource(updatedManager)); + res.resource(new CampManagerResource(updatedManager)); } async destroy(req: Request, res: Response) { diff --git a/backend/src/app/manager/manager.messages.ts b/backend/src/app/campManager/camp-manager.messages.ts similarity index 91% rename from backend/src/app/manager/manager.messages.ts rename to backend/src/app/campManager/camp-manager.messages.ts index d7a1bd6c..7271aa39 100644 --- a/backend/src/app/manager/manager.messages.ts +++ b/backend/src/app/campManager/camp-manager.messages.ts @@ -8,7 +8,7 @@ type CampManagerWithUserOrInvitation = CampManager & { user: User | null } & { invitation: Invitation | null; }; -abstract class ManagerMessage< +abstract class CampManagerMessage< T extends { manager: CampManagerWithUserOrInvitation }, > extends MailBase { protected to() { @@ -36,7 +36,7 @@ abstract class ManagerMessage< } } -export class ManagerInvitationMessage extends ManagerMessage<{ +export class CampManagerInvitationMessage extends CampManagerMessage<{ manager: CampManagerWithUserOrInvitation; camp: Camp; }> { @@ -71,7 +71,7 @@ export class ManagerInvitationMessage extends ManagerMessage<{ protected content() { const camp = this.payload.camp; const campName = translateObject(camp.name, this.locale()); - const url = generateUrl(['management', camp.id]); + const url = generateUrl(['management', 'camps', camp.id]); return { template: 'manager-invitation', diff --git a/backend/src/app/manager/manager.module.ts b/backend/src/app/campManager/camp-manager.module.ts similarity index 55% rename from backend/src/app/manager/manager.module.ts rename to backend/src/app/campManager/camp-manager.module.ts index 8081d4d1..3e260af2 100644 --- a/backend/src/app/manager/manager.module.ts +++ b/backend/src/app/campManager/camp-manager.module.ts @@ -5,26 +5,26 @@ import type { BindOptions, ModuleOptions, } from '#core/base/AppModule'; -import { ManagerRouter } from '#app/manager/manager.routes'; +import { CampManagerRouter } from '#app/campManager/camp-manager.routes'; import type { ManagerPermission } from '@camp-registration/common/permissions'; -import { ManagerController } from '#app/manager/manager.controller'; -import { ManagerService } from '#app/manager/manager.service'; +import { CampManagerController } from '#app/campManager/camp-manager.controller'; +import { CampManagerService } from '#app/campManager/camp-manager.service'; import { MailableRegistry } from '#app/mail/mail.registry'; -import { ManagerInvitationMessage } from '#app/manager/manager.messages'; +import { CampManagerInvitationMessage } from '#app/campManager/camp-manager.messages'; import { resolve } from '#core/ioc/container'; -export class ManagerModule implements AppModule { +export class CampManagerModule implements AppModule { bindContainers(options: BindOptions) { - options.bind(ManagerController).toSelf().inSingletonScope(); - options.bind(ManagerService).toSelf().inSingletonScope(); + options.bind(CampManagerController).toSelf().inSingletonScope(); + options.bind(CampManagerService).toSelf().inSingletonScope(); } configure(_options: ModuleOptions): Promise | void { - resolve(MailableRegistry).register(ManagerInvitationMessage); + resolve(MailableRegistry).register(CampManagerInvitationMessage); } registerRoutes(router: AppRouter): void { - router.useRouter('/camps/:campId/managers', new ManagerRouter()); + router.useRouter('/camps/:campId/managers', new CampManagerRouter()); } registerPermissions(): RoleToPermissions { diff --git a/backend/src/app/manager/manager.resource.ts b/backend/src/app/campManager/camp-manager.resource.ts similarity index 93% rename from backend/src/app/manager/manager.resource.ts rename to backend/src/app/campManager/camp-manager.resource.ts index a0b26f89..d40ae803 100644 --- a/backend/src/app/manager/manager.resource.ts +++ b/backend/src/app/campManager/camp-manager.resource.ts @@ -7,7 +7,7 @@ export interface ManagerWithRelationships extends CampManager { invitation: Invitation | null; } -export class ManagerResource extends JsonResource< +export class CampManagerResource extends JsonResource< ManagerWithRelationships, CampManagerData > { diff --git a/backend/src/app/manager/manager.routes.ts b/backend/src/app/campManager/camp-manager.routes.ts similarity index 73% rename from backend/src/app/manager/manager.routes.ts rename to backend/src/app/campManager/camp-manager.routes.ts index 68e258df..268c7816 100644 --- a/backend/src/app/manager/manager.routes.ts +++ b/backend/src/app/campManager/camp-manager.routes.ts @@ -1,22 +1,22 @@ import { auth, guard } from '#middlewares/index'; import { campManager } from '#guards/manager.guard'; -import { ManagerController } from './manager.controller.js'; +import { CampManagerController } from './camp-manager.controller.js'; import { controller } from '#utils/bindController'; import { ModuleRouter } from '#core/router/ModuleRouter'; -import { ManagerService } from '#app/manager/manager.service'; +import { CampManagerService } from '#app/campManager/camp-manager.service.js'; import { resolve } from '#core/ioc/container'; -export class ManagerRouter extends ModuleRouter { +export class CampManagerRouter extends ModuleRouter { protected registerBindings() { - const managerService = resolve(ManagerService); - this.bindModel('manager', (req, id) => { + const managerService = resolve(CampManagerService); + this.bindModel('campManager', (req, id) => { const camp = req.modelOrFail('camp'); return managerService.getManagerById(camp.id, id); }); } protected defineRoutes() { - const managerController = resolve(ManagerController); + const managerController = resolve(CampManagerController); this.router.use(auth()); diff --git a/backend/src/app/manager/manager.service.ts b/backend/src/app/campManager/camp-manager.service.ts similarity index 98% rename from backend/src/app/manager/manager.service.ts rename to backend/src/app/campManager/camp-manager.service.ts index 37e9a4a6..5ebbd5a8 100644 --- a/backend/src/app/manager/manager.service.ts +++ b/backend/src/app/campManager/camp-manager.service.ts @@ -13,7 +13,7 @@ type ManagerUpdateData = Pick< >; @injectable() -export class ManagerService extends BaseService { +export class CampManagerService extends BaseService { async campManagerExistsWithUserIdAndCampId(campId: string, userId: string) { return this.prisma.campManager .findFirst({ diff --git a/backend/src/app/manager/manager.validation.ts b/backend/src/app/campManager/camp-manager.validation.ts similarity index 100% rename from backend/src/app/manager/manager.validation.ts rename to backend/src/app/campManager/camp-manager.validation.ts diff --git a/backend/src/app/newsletter/newsletter.controller.ts b/backend/src/app/newsletter/newsletter.controller.ts new file mode 100644 index 00000000..af434998 --- /dev/null +++ b/backend/src/app/newsletter/newsletter.controller.ts @@ -0,0 +1,97 @@ +import httpStatus from 'http-status'; +import { NewsletterService } from './newsletter.service.js'; +import { NewsletterResource } from './newsletter.resource.js'; +import { NewsletterSubscriberService } from '#app/newsletterSubscriber/newsletter-subscriber.service'; +import { NewsletterMail } from './newsletter.mail.js'; +import validator from './newsletter.validation.js'; +import { type Request, type Response } from 'express'; +import { BaseController } from '#core/base/BaseController'; +import { inject, injectable } from 'inversify'; + +@injectable() +export class NewsletterController extends BaseController { + constructor( + @inject(NewsletterService) + private readonly newsletterService: NewsletterService, + @inject(NewsletterSubscriberService) + private readonly subscriberService: NewsletterSubscriberService, + ) { + super(); + } + + async index(req: Request, res: Response) { + const { query } = await req.validate(validator.index); + const userId = req.authUserId(); + + const newsletters = query?.showAll + ? await this.newsletterService.getAllNewsletters() + : await this.newsletterService.getNewslettersByUserId(userId); + + res.resource(NewsletterResource.collection(newsletters)); + } + + async show(req: Request, res: Response) { + await req.validate(validator.show); + const newsletter = req.modelOrFail('newsletter'); + + res.resource(new NewsletterResource(newsletter)); + } + + async store(req: Request, res: Response) { + const { body } = await req.validate(validator.store); + const userId = req.authUserId(); + + const newsletter = await this.newsletterService.createNewsletter(userId, { + name: body.name, + description: body.description, + }); + + res.status(httpStatus.CREATED).resource(new NewsletterResource(newsletter)); + } + + async update(req: Request, res: Response) { + const newsletter = req.modelOrFail('newsletter'); + const { body } = await req.validate(validator.update); + + const updated = await this.newsletterService.updateNewsletter( + newsletter.id, + { + name: body.name, + description: body.description, + }, + ); + + res.resource(new NewsletterResource(updated)); + } + + async destroy(req: Request, res: Response) { + const newsletter = req.modelOrFail('newsletter'); + await req.validate(validator.destroy); + + await this.newsletterService.deleteNewsletter(newsletter.id); + + res.sendStatus(httpStatus.NO_CONTENT); + } + + async send(req: Request, res: Response) { + const newsletter = req.modelOrFail('newsletter'); + const { body } = await req.validate(validator.send); + + const subscribers = await this.subscriberService.getSubscribers( + newsletter.id, + ); + + for (const subscriber of subscribers) { + await NewsletterMail.enqueue({ + to: subscriber.email, + name: subscriber.name, + subject: body.subject, + body: body.body, + newsletterId: newsletter.id, + unsubscribeToken: subscriber.unsubscribeToken, + }); + } + + res.json({ data: { queued: subscribers.length } }); + } +} diff --git a/backend/src/app/newsletter/newsletter.guard.ts b/backend/src/app/newsletter/newsletter.guard.ts new file mode 100644 index 00000000..41a36d6b --- /dev/null +++ b/backend/src/app/newsletter/newsletter.guard.ts @@ -0,0 +1,14 @@ +import { NewsletterManagerService } from './newsletter-manager.service.js'; +import type { Request } from 'express'; +import { resolve } from '#core/ioc/container'; + +export const newsletterManager = (req: Request): Promise => { + return (async () => { + const userId = req.authUserId(); + const newsletterId = req.modelOrFail('newsletter').id; + + const managerService = resolve(NewsletterManagerService); + + return await managerService.isNewsletterManager(newsletterId, userId); + })(); +}; diff --git a/backend/src/app/newsletter/newsletter.mail.ts b/backend/src/app/newsletter/newsletter.mail.ts new file mode 100644 index 00000000..d45e0070 --- /dev/null +++ b/backend/src/app/newsletter/newsletter.mail.ts @@ -0,0 +1,61 @@ +import { MailBase } from '#app/mail/mail.base'; +import { generateUrl } from '#utils/url'; + +export interface NewsletterMailPayload { + to: string; + name: string | null; + subject: string; + body: string; + newsletterId: string; + unsubscribeToken: string; +} + +export class NewsletterMail extends MailBase { + static readonly type = 'newsletter:send'; + + protected to() { + const { to, name } = this.payload; + + if (name) { + return { name, address: to }; + } + + return to; + } + + protected subject(): string { + return this.payload.subject; + } + + private getUnsubscribeUrl(): string { + return generateUrl( + `newsletters/unsubscribe/${this.payload.unsubscribeToken}`, + ); + } + + protected headers(): Record { + const unsubscribeUrl = this.getUnsubscribeUrl(); + + return { + 'List-Unsubscribe': `<${unsubscribeUrl}>`, + 'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click', + }; + } + + protected getTranslationOptions() { + return { + namespace: 'newsletter', + keyPrefix: 'email', + }; + } + + protected content() { + return { + template: 'newsletter', + context: { + body: this.payload.body, + unsubscribeUrl: this.getUnsubscribeUrl(), + }, + }; + } +} diff --git a/backend/src/app/newsletter/newsletter.module.ts b/backend/src/app/newsletter/newsletter.module.ts new file mode 100644 index 00000000..38aa220b --- /dev/null +++ b/backend/src/app/newsletter/newsletter.module.ts @@ -0,0 +1,23 @@ +import type { AppModule, AppRouter, BindOptions } from '#core/base/AppModule'; +import { NewsletterRouter } from './newsletter.routes.js'; +import { NewsletterService } from './newsletter.service.js'; +import { NewsletterController } from './newsletter.controller.js'; +import { MailableRegistry } from '#app/mail/mail.registry'; +import { NewsletterMail } from './newsletter.mail.js'; +import { resolve } from '#core/ioc/container'; + +export class NewsletterModule implements AppModule { + bindContainers(options: BindOptions) { + options.bind(NewsletterService).toSelf().inSingletonScope(); + options.bind(NewsletterController).toSelf().inSingletonScope(); + } + + configure() { + const mailableRegistry = resolve(MailableRegistry); + mailableRegistry.register(NewsletterMail); + } + + registerRoutes(router: AppRouter): void { + router.useRouter('/newsletters', new NewsletterRouter()); + } +} diff --git a/backend/src/app/newsletter/newsletter.resource.ts b/backend/src/app/newsletter/newsletter.resource.ts new file mode 100644 index 00000000..992eefe2 --- /dev/null +++ b/backend/src/app/newsletter/newsletter.resource.ts @@ -0,0 +1,15 @@ +import type { Newsletter } from '@prisma/client'; +import type { Newsletter as NewsletterData } from '@camp-registration/common/entities'; +import { JsonResource } from '#core/resource/JsonResource'; + +export class NewsletterResource extends JsonResource { + transform(): NewsletterData { + return { + id: this.data.id, + name: this.data.name, + description: this.data.description ?? null, + createdAt: this.data.createdAt.toISOString(), + updatedAt: this.data.updatedAt?.toISOString() ?? null, + }; + } +} diff --git a/backend/src/app/newsletter/newsletter.routes.ts b/backend/src/app/newsletter/newsletter.routes.ts new file mode 100644 index 00000000..2c0ca536 --- /dev/null +++ b/backend/src/app/newsletter/newsletter.routes.ts @@ -0,0 +1,53 @@ +import { auth, guard } from '#middlewares/index'; +import { ModuleRouter } from '#core/router/ModuleRouter'; +import { NewsletterController } from './newsletter.controller.js'; +import { NewsletterService } from './newsletter.service.js'; +import { controller } from '#utils/bindController'; +import { newsletterManager } from './newsletter.guard.js'; +import { resolve } from '#core/ioc/container'; + +export class NewsletterRouter extends ModuleRouter { + protected registerBindings() { + const newsletterService = resolve(NewsletterService); + this.bindModel('newsletter', (_req, id) => + newsletterService.getNewsletterById(id), + ); + } + + protected defineRoutes() { + const newsletterController = resolve(NewsletterController); + + this.router.get( + '/', + auth(), + guard((req) => (req.query as { showAll?: string }).showAll === undefined), + controller(newsletterController, 'index'), + ); + this.router.post('/', auth(), controller(newsletterController, 'store')); + + this.router.get( + '/:newsletterId', + auth(), + guard(newsletterManager), + controller(newsletterController, 'show'), + ); + this.router.patch( + '/:newsletterId', + auth(), + guard(newsletterManager), + controller(newsletterController, 'update'), + ); + this.router.delete( + '/:newsletterId', + auth(), + guard(newsletterManager), + controller(newsletterController, 'destroy'), + ); + this.router.post( + '/:newsletterId/send', + auth(), + guard(newsletterManager), + controller(newsletterController, 'send'), + ); + } +} diff --git a/backend/src/app/newsletter/newsletter.service.ts b/backend/src/app/newsletter/newsletter.service.ts new file mode 100644 index 00000000..8846fb29 --- /dev/null +++ b/backend/src/app/newsletter/newsletter.service.ts @@ -0,0 +1,58 @@ +import { BaseService } from '#core/base/BaseService'; +import { injectable } from 'inversify'; + +@injectable() +export class NewsletterService extends BaseService { + async getNewsletterById(id: string) { + return this.prisma.newsletter.findUnique({ where: { id } }); + } + + async getAllNewsletters() { + return this.prisma.newsletter.findMany({ + orderBy: { createdAt: 'desc' }, + }); + } + + async getNewslettersByUserId(userId: string) { + return this.prisma.newsletter.findMany({ + where: { + managers: { + some: { userId }, + }, + }, + orderBy: { createdAt: 'desc' }, + }); + } + + async createNewsletter( + userId: string, + data: { name: string; description?: string | null }, + ) { + return this.prisma.newsletter.create({ + data: { + name: data.name, + description: data.description, + managers: { + create: { userId }, + }, + }, + }); + } + + async updateNewsletter( + id: string, + data: { name?: string; description?: string | null }, + ) { + return this.prisma.newsletter.update({ + where: { id }, + data: { + name: data.name, + description: data.description, + }, + }); + } + + async deleteNewsletter(id: string) { + return this.prisma.newsletter.delete({ where: { id } }); + } +} diff --git a/backend/src/app/newsletter/newsletter.validation.ts b/backend/src/app/newsletter/newsletter.validation.ts new file mode 100644 index 00000000..12c707df --- /dev/null +++ b/backend/src/app/newsletter/newsletter.validation.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; + +const index = z.object({ + query: z + .object({ + showAll: z.coerce.boolean().optional(), + }) + .optional(), +}); + +const show = z.object({ + params: z.object({ + newsletterId: z.ulid(), + }), +}); + +const store = z.object({ + body: z.object({ + name: z.string().min(1).max(255), + description: z.string().max(5000).nullable().optional(), + }), +}); + +const update = z.object({ + params: z.object({ + newsletterId: z.ulid(), + }), + body: z + .object({ + name: z.string().min(1).max(255), + description: z.string().max(5000).nullable(), + }) + .partial(), +}); + +const destroy = z.object({ + params: z.object({ + newsletterId: z.ulid(), + }), +}); + +const send = z.object({ + params: z.object({ + newsletterId: z.ulid(), + }), + body: z.object({ + subject: z.string().min(1).max(255), + body: z.string().min(1), + }), +}); + +export default { + index, + show, + store, + update, + destroy, + send, +}; diff --git a/backend/src/app/newsletterManager/newsletter-manager.controller.ts b/backend/src/app/newsletterManager/newsletter-manager.controller.ts new file mode 100644 index 00000000..291b090d --- /dev/null +++ b/backend/src/app/newsletterManager/newsletter-manager.controller.ts @@ -0,0 +1,80 @@ +import ApiError from '#utils/ApiError'; +import httpStatus from 'http-status'; +import { NewsletterManagerService } from './newsletter-manager.service.js'; +import { NewsletterManagerResource } from './newsletter-manager.resource.js'; +import validator from './newsletter-manager.validation.js'; +import { UserService } from '#app/user/user.service'; +import { type Request, type Response } from 'express'; +import { BaseController } from '#core/base/BaseController'; +import { inject, injectable } from 'inversify'; + +@injectable() +export class NewsletterManagerController extends BaseController { + constructor( + @inject(NewsletterManagerService) + private readonly managerService: NewsletterManagerService, + @inject(UserService) private readonly userService: UserService, + ) { + super(); + } + + async index(req: Request, res: Response) { + const newsletter = req.modelOrFail('newsletter'); + await req.validate(validator.index); + + const managers = await this.managerService.getManagers(newsletter.id); + + res.resource(NewsletterManagerResource.collection(managers)); + } + + async store(req: Request, res: Response) { + const newsletter = req.modelOrFail('newsletter'); + const { body } = await req.validate(validator.store); + + const user = await this.userService.getUserByEmail(body.email); + if (!user) { + throw new ApiError( + httpStatus.BAD_REQUEST, + 'No user found with this email address.', + ); + } + + const existing = await this.managerService.getManagerByUserId( + newsletter.id, + user.id, + ); + if (existing) { + throw new ApiError( + httpStatus.BAD_REQUEST, + 'User is already a newsletter manager.', + ); + } + + const manager = await this.managerService.addManager( + newsletter.id, + user.id, + ); + + res.status(httpStatus.CREATED).resource(new NewsletterManagerResource(manager)); + } + + async destroy(req: Request, res: Response) { + const { + params: { newsletterManagerId }, + } = await req.validate(validator.destroy); + + const count = await this.managerService.countManagers( + req.modelOrFail('newsletter').id, + ); + if (count <= 1) { + throw new ApiError( + httpStatus.BAD_REQUEST, + 'The newsletter must always have at least one manager.', + ); + } + + await this.managerService.removeManager(newsletterManagerId); + + res.sendStatus(httpStatus.NO_CONTENT); + } +} diff --git a/backend/src/app/newsletterManager/newsletter-manager.module.ts b/backend/src/app/newsletterManager/newsletter-manager.module.ts new file mode 100644 index 00000000..5332d6ad --- /dev/null +++ b/backend/src/app/newsletterManager/newsletter-manager.module.ts @@ -0,0 +1,18 @@ +import type { AppModule, AppRouter, BindOptions } from '#core/base/AppModule'; +import { NewsletterManagerRouter } from './newsletter-manager.routes.js'; +import { NewsletterManagerService } from './newsletter-manager.service.js'; +import { NewsletterManagerController } from './newsletter-manager.controller.js'; + +export class NewsletterManagerModule implements AppModule { + bindContainers(options: BindOptions) { + options.bind(NewsletterManagerService).toSelf().inSingletonScope(); + options.bind(NewsletterManagerController).toSelf().inSingletonScope(); + } + + registerRoutes(router: AppRouter): void { + router.useRouter( + '/newsletters/:newsletterId/managers', + new NewsletterManagerRouter(), + ); + } +} diff --git a/backend/src/app/newsletterManager/newsletter-manager.resource.ts b/backend/src/app/newsletterManager/newsletter-manager.resource.ts new file mode 100644 index 00000000..23132562 --- /dev/null +++ b/backend/src/app/newsletterManager/newsletter-manager.resource.ts @@ -0,0 +1,20 @@ +import type { NewsletterManager, User } from '@prisma/client'; +import type { NewsletterManager as NewsletterManagerData } from '@camp-registration/common/entities'; +import { JsonResource } from '#core/resource/JsonResource'; + +export interface NewsletterManagerWithUser extends NewsletterManager { + user: User; +} + +export class NewsletterManagerResource extends JsonResource< + NewsletterManagerWithUser, + NewsletterManagerData +> { + transform(): NewsletterManagerData { + return { + id: this.data.id, + name: this.data.user.name, + email: this.data.user.email, + }; + } +} diff --git a/backend/src/app/newsletterManager/newsletter-manager.routes.ts b/backend/src/app/newsletterManager/newsletter-manager.routes.ts new file mode 100644 index 00000000..4385a033 --- /dev/null +++ b/backend/src/app/newsletterManager/newsletter-manager.routes.ts @@ -0,0 +1,35 @@ +import { auth, guard } from '#middlewares/index'; +import { ModuleRouter } from '#core/router/ModuleRouter'; +import { NewsletterManagerController } from './newsletter-manager.controller.js'; +import { NewsletterManagerService } from './newsletter-manager.service.js'; +import { controller } from '#utils/bindController'; +import { newsletterManager } from './newsletter.guard.js'; +import { resolve } from '#core/ioc/container'; + +export class NewsletterManagerRouter extends ModuleRouter { + constructor() { + super(false); + } + + protected registerBindings() { + const managerService = resolve(NewsletterManagerService); + this.bindModel('newsletterManager', (req, id) => { + const newsletter = req.modelOrFail('newsletter'); + return managerService.getManagerById(newsletter.id, id); + }); + } + + protected defineRoutes() { + const managerController = resolve(NewsletterManagerController); + + this.router.use(auth()); + this.router.use(guard(newsletterManager)); + + this.router.get('/', controller(managerController, 'index')); + this.router.post('/', controller(managerController, 'store')); + this.router.delete( + '/:newsletterManagerId', + controller(managerController, 'destroy'), + ); + } +} diff --git a/backend/src/app/newsletterManager/newsletter-manager.service.ts b/backend/src/app/newsletterManager/newsletter-manager.service.ts new file mode 100644 index 00000000..4a514a54 --- /dev/null +++ b/backend/src/app/newsletterManager/newsletter-manager.service.ts @@ -0,0 +1,51 @@ +import { BaseService } from '#core/base/BaseService'; +import { injectable } from 'inversify'; + +@injectable() +export class NewsletterManagerService extends BaseService { + async isNewsletterManager( + newsletterId: string, + userId: string, + ): Promise { + const manager = await this.prisma.newsletterManager.findFirst({ + where: { newsletterId, userId }, + }); + + return manager != null; + } + + async getManagers(newsletterId: string) { + return this.prisma.newsletterManager.findMany({ + where: { newsletterId }, + include: { user: true }, + orderBy: { user: { name: 'asc' } }, + }); + } + + async getManagerByUserId(newsletterId: string, userId: string) { + return this.prisma.newsletterManager.findFirst({ + where: { newsletterId, userId }, + }); + } + + async getManagerById(newsletterId: string, id: string) { + return this.prisma.newsletterManager.findFirst({ + where: { newsletterId, id }, + }); + } + + async addManager(newsletterId: string, userId: string) { + return this.prisma.newsletterManager.create({ + data: { newsletterId, userId }, + include: { user: true }, + }); + } + + async removeManager(id: string) { + return this.prisma.newsletterManager.delete({ where: { id } }); + } + + async countManagers(newsletterId: string) { + return this.prisma.newsletterManager.count({ where: { newsletterId } }); + } +} diff --git a/backend/src/app/newsletterManager/newsletter-manager.validation.ts b/backend/src/app/newsletterManager/newsletter-manager.validation.ts new file mode 100644 index 00000000..16ffa26e --- /dev/null +++ b/backend/src/app/newsletterManager/newsletter-manager.validation.ts @@ -0,0 +1,29 @@ +import { z } from 'zod'; + +const index = z.object({ + params: z.object({ + newsletterId: z.ulid(), + }), +}); + +const store = z.object({ + params: z.object({ + newsletterId: z.ulid(), + }), + body: z.object({ + email: z.email(), + }), +}); + +const destroy = z.object({ + params: z.object({ + newsletterId: z.ulid(), + newsletterManagerId: z.ulid(), + }), +}); + +export default { + index, + store, + destroy, +}; diff --git a/backend/src/app/newsletterSubscriber/newsletter-subscriber.controller.ts b/backend/src/app/newsletterSubscriber/newsletter-subscriber.controller.ts new file mode 100644 index 00000000..e00938ea --- /dev/null +++ b/backend/src/app/newsletterSubscriber/newsletter-subscriber.controller.ts @@ -0,0 +1,105 @@ +import ApiError from '#utils/ApiError'; +import httpStatus from 'http-status'; +import { NewsletterSubscriberService } from './newsletter-subscriber.service.js'; +import { NewsletterSubscriberResource } from './newsletter-subscriber.resource.js'; +import validator from './newsletter-subscriber.validation.js'; +import { type Request, type Response } from 'express'; +import { BaseController } from '#core/base/BaseController'; +import { inject, injectable } from 'inversify'; + +@injectable() +export class NewsletterSubscriberController extends BaseController { + constructor( + @inject(NewsletterSubscriberService) + private readonly subscriberService: NewsletterSubscriberService, + ) { + super(); + } + + async index(req: Request, res: Response) { + const newsletter = req.modelOrFail('newsletter'); + await req.validate(validator.index); + + const subscribers = await this.subscriberService.getSubscribers( + newsletter.id, + ); + + res.resource(NewsletterSubscriberResource.collection(subscribers)); + } + + async store(req: Request, res: Response) { + const newsletter = req.modelOrFail('newsletter'); + const { body } = await req.validate(validator.store); + + const existing = await this.subscriberService.getSubscriberByEmail( + newsletter.id, + body.email, + ); + + if (existing) { + throw new ApiError( + httpStatus.BAD_REQUEST, + 'This email address is already subscribed.', + ); + } + + const subscriber = await this.subscriberService.addSubscriber( + newsletter.id, + { + email: body.email, + name: body.name, + country: body.country, + }, + ); + + res + .status(httpStatus.CREATED) + .resource(new NewsletterSubscriberResource(subscriber)); + } + + async importFromCamp(req: Request, res: Response) { + const newsletter = req.modelOrFail('newsletter'); + const { body } = await req.validate(validator.importFromCamp); + + const result = await this.subscriberService.importSubscribersFromCamp( + newsletter.id, + body.campId, + body.country, + ); + + res.json({ data: result }); + } + + async destroy(req: Request, res: Response) { + const { + params: { subscriberId }, + } = await req.validate(validator.destroy); + + const subscriber = req.modelOrFail('subscriber'); + if (subscriber.id !== subscriberId) { + throw new ApiError(httpStatus.NOT_FOUND, 'Subscriber not found.'); + } + + await this.subscriberService.removeSubscriber(subscriberId); + + res.sendStatus(httpStatus.NO_CONTENT); + } + + async unsubscribe(req: Request, res: Response) { + const { + params: { token }, + } = await req.validate(validator.unsubscribe); + + const subscriber = await this.subscriberService.getSubscriberByToken(token); + if (!subscriber) { + throw new ApiError( + httpStatus.NOT_FOUND, + 'Invalid or expired unsubscribe link.', + ); + } + + await this.subscriberService.unsubscribeByToken(token); + + res.sendStatus(httpStatus.NO_CONTENT); + } +} diff --git a/backend/src/app/newsletterSubscriber/newsletter-subscriber.module.ts b/backend/src/app/newsletterSubscriber/newsletter-subscriber.module.ts new file mode 100644 index 00000000..1ca6d0e0 --- /dev/null +++ b/backend/src/app/newsletterSubscriber/newsletter-subscriber.module.ts @@ -0,0 +1,27 @@ +import type { AppModule, AppRouter, BindOptions } from '#core/base/AppModule'; +import { NewsletterSubscriberRouter } from './newsletter-subscriber.routes.js'; +import { NewsletterUnsubscribeRouter } from './newsletter-unsubscribe.routes.js'; +import { NewsletterSubscriberService } from './newsletter-subscriber.service.js'; +import { NewsletterSubscriberController } from './newsletter-subscriber.controller.js'; + +export class NewsletterSubscriberModule implements AppModule { + bindContainers(options: BindOptions) { + options.bind(NewsletterSubscriberService).toSelf().inSingletonScope(); + options.bind(NewsletterSubscriberController).toSelf().inSingletonScope(); + } + + configure() { + // TODO + } + + registerRoutes(router: AppRouter): void { + router.useRouter( + '/newsletters/unsubscribe', + new NewsletterUnsubscribeRouter(), + ); + router.useRouter( + '/newsletters/:newsletterId/subscribers', + new NewsletterSubscriberRouter(), + ); + } +} diff --git a/backend/src/app/newsletterSubscriber/newsletter-subscriber.resource.ts b/backend/src/app/newsletterSubscriber/newsletter-subscriber.resource.ts new file mode 100644 index 00000000..27b1d0a7 --- /dev/null +++ b/backend/src/app/newsletterSubscriber/newsletter-subscriber.resource.ts @@ -0,0 +1,18 @@ +import type { NewsletterSubscriber } from '@prisma/client'; +import type { NewsletterSubscriber as NewsletterSubscriberData } from '@camp-registration/common/entities'; +import { JsonResource } from '#core/resource/JsonResource'; + +export class NewsletterSubscriberResource extends JsonResource< + NewsletterSubscriber, + NewsletterSubscriberData +> { + transform(): NewsletterSubscriberData { + return { + id: this.data.id, + email: this.data.email, + name: this.data.name ?? null, + country: this.data.country ?? null, + subscribedAt: this.data.subscribedAt.toISOString(), + }; + } +} diff --git a/backend/src/app/newsletterSubscriber/newsletter-subscriber.routes.ts b/backend/src/app/newsletterSubscriber/newsletter-subscriber.routes.ts new file mode 100644 index 00000000..42c2b23b --- /dev/null +++ b/backend/src/app/newsletterSubscriber/newsletter-subscriber.routes.ts @@ -0,0 +1,39 @@ +import { auth, guard } from '#middlewares/index'; +import { ModuleRouter } from '#core/router/ModuleRouter'; +import { NewsletterSubscriberController } from './newsletter-subscriber.controller.js'; +import { NewsletterSubscriberService } from './newsletter-subscriber.service.js'; +import { controller } from '#utils/bindController'; +import { newsletterManager } from './newsletter.guard.js'; +import { resolve } from '#core/ioc/container'; + +export class NewsletterSubscriberRouter extends ModuleRouter { + constructor() { + super(false); + } + + protected registerBindings() { + const subscriberService = resolve(NewsletterSubscriberService); + this.bindModel('subscriber', (req, id) => { + const newsletter = req.modelOrFail('newsletter'); + return subscriberService.getSubscriberById(newsletter.id, id); + }); + } + + protected defineRoutes() { + const subscriberController = resolve(NewsletterSubscriberController); + + this.router.use(auth()); + this.router.use(guard(newsletterManager)); + + this.router.get('/', controller(subscriberController, 'index')); + this.router.post('/', controller(subscriberController, 'store')); + this.router.post( + '/import', + controller(subscriberController, 'importFromCamp'), + ); + this.router.delete( + '/:subscriberId', + controller(subscriberController, 'destroy'), + ); + } +} diff --git a/backend/src/app/newsletterSubscriber/newsletter-subscriber.service.ts b/backend/src/app/newsletterSubscriber/newsletter-subscriber.service.ts new file mode 100644 index 00000000..8399b01e --- /dev/null +++ b/backend/src/app/newsletterSubscriber/newsletter-subscriber.service.ts @@ -0,0 +1,132 @@ +import { BaseService } from '#core/base/BaseService'; +import { injectable } from 'inversify'; +import crypto from 'crypto'; + +function generateUnsubscribeToken(): string { + return crypto.randomBytes(32).toString('hex'); +} + +@injectable() +export class NewsletterSubscriberService extends BaseService { + async getSubscriberById(newsletterId: string, id: string) { + return this.prisma.newsletterSubscriber.findFirst({ + where: { id, newsletterId }, + }); + } + + async getSubscriberByToken(token: string) { + return this.prisma.newsletterSubscriber.findUnique({ + where: { unsubscribeToken: token }, + }); + } + + async getSubscriberByEmail(newsletterId: string, email: string) { + return this.prisma.newsletterSubscriber.findUnique({ + where: { newsletterId_email: { newsletterId, email } }, + }); + } + + async getSubscribers(newsletterId: string) { + return this.prisma.newsletterSubscriber.findMany({ + where: { newsletterId }, + orderBy: { subscribedAt: 'desc' }, + }); + } + + async addSubscriber( + newsletterId: string, + data: { email: string; name?: string | null; country?: string | null }, + ) { + return this.prisma.newsletterSubscriber.create({ + data: { + newsletterId, + email: data.email, + name: data.name ?? null, + country: data.country ?? null, + unsubscribeToken: generateUnsubscribeToken(), + }, + }); + } + + async upsertSubscriber( + newsletterId: string, + data: { email: string; name?: string | null; country?: string | null }, + ) { + const existing = await this.prisma.newsletterSubscriber.findUnique({ + where: { + newsletterId_email: { newsletterId, email: data.email }, + }, + }); + + if (existing) { + return existing; + } + + return this.addSubscriber(newsletterId, data); + } + + async importSubscribersFromCamp( + newsletterId: string, + campId: string, + country: string | null | undefined, + ): Promise<{ added: number; skipped: number }> { + const registrations = await this.prisma.registration.findMany({ + where: { + campId, + ...(country ? { country } : {}), + deletedAt: null, + }, + select: { emails: true, firstName: true, lastName: true, country: true }, + }); + + let added = 0; + let skipped = 0; + + for (const registration of registrations) { + const emails = registration.emails; + if (!emails || emails.length === 0) continue; + + const name = + [registration.firstName, registration.lastName] + .filter(Boolean) + .join(' ') || null; + + for (const email of emails) { + if (!email) continue; + const existing = await this.prisma.newsletterSubscriber.findUnique({ + where: { + newsletterId_email: { newsletterId, email }, + }, + }); + + if (existing) { + skipped++; + continue; + } + + await this.prisma.newsletterSubscriber.create({ + data: { + newsletterId, + email, + name, + country: registration.country ?? null, + unsubscribeToken: generateUnsubscribeToken(), + }, + }); + added++; + } + } + + return { added, skipped }; + } + + async removeSubscriber(id: string) { + return this.prisma.newsletterSubscriber.delete({ where: { id } }); + } + + async unsubscribeByToken(token: string) { + return this.prisma.newsletterSubscriber.delete({ + where: { unsubscribeToken: token }, + }); + } +} diff --git a/backend/src/app/newsletterSubscriber/newsletter-subscriber.validation.ts b/backend/src/app/newsletterSubscriber/newsletter-subscriber.validation.ts new file mode 100644 index 00000000..96a87630 --- /dev/null +++ b/backend/src/app/newsletterSubscriber/newsletter-subscriber.validation.ts @@ -0,0 +1,49 @@ +import { z } from 'zod'; + +const index = z.object({ + params: z.object({ + newsletterId: z.ulid(), + }), +}); + +const store = z.object({ + params: z.object({ + newsletterId: z.ulid(), + }), + body: z.object({ + email: z.email(), + name: z.string().max(255).nullable().optional(), + country: z.string().max(5).nullable().optional(), + }), +}); + +const importFromCamp = z.object({ + params: z.object({ + newsletterId: z.ulid(), + }), + body: z.object({ + campId: z.ulid(), + country: z.string().max(5).nullable().optional(), + }), +}); + +const destroy = z.object({ + params: z.object({ + newsletterId: z.ulid(), + subscriberId: z.ulid(), + }), +}); + +const unsubscribe = z.object({ + params: z.object({ + token: z.string().length(64), + }), +}); + +export default { + index, + store, + importFromCamp, + destroy, + unsubscribe, +}; diff --git a/backend/src/app/newsletterSubscriber/newsletter-unsubscribe.routes.ts b/backend/src/app/newsletterSubscriber/newsletter-unsubscribe.routes.ts new file mode 100644 index 00000000..977440d0 --- /dev/null +++ b/backend/src/app/newsletterSubscriber/newsletter-unsubscribe.routes.ts @@ -0,0 +1,24 @@ +import { ModuleRouter } from '#core/router/ModuleRouter'; +import { NewsletterSubscriberController } from './newsletter-subscriber.controller.js'; +import { controller } from '#utils/bindController'; +import { resolve } from '#core/ioc/container'; + +export class NewsletterUnsubscribeRouter extends ModuleRouter { + constructor() { + super(false); + } + + protected registerBindings() { + // No model bindings needed for the public unsubscribe endpoint + } + + protected defineRoutes() { + const subscriberController = resolve(NewsletterSubscriberController); + + // Public endpoint - no authentication required + this.router.delete( + '/:token', + controller(subscriberController, 'unsubscribe'), + ); + } +} diff --git a/backend/src/app/registration/registration.messages.ts b/backend/src/app/registration/registration.messages.ts index a0b0c19a..26d4a4f4 100644 --- a/backend/src/app/registration/registration.messages.ts +++ b/backend/src/app/registration/registration.messages.ts @@ -98,7 +98,7 @@ export class RegistrationNotifyMessage extends MailBase<{ const camp = this.payload.camp; const registration = this.payload.registration; - const url = generateUrl(['management', camp.id]); + const url = generateUrl(['management', 'camps', camp.id]); return { template: 'registration-manager-notification', diff --git a/backend/src/boot.ts b/backend/src/boot.ts index d1a7d85d..4adc8531 100644 --- a/backend/src/boot.ts +++ b/backend/src/boot.ts @@ -4,7 +4,7 @@ import { AuthModule } from '#app/auth/auth.module'; import { CampModule } from '#app/camp/camp.module'; import { RegistrationModule } from '#app/registration/registration.module'; import { TableTemplateModule } from '#app/tableTemplate/table-template.module'; -import { ManagerModule } from '#app/manager/manager.module'; +import { CampManagerModule } from '#app/campManager/camp-manager.module.js'; import { MessageTemplateModule } from '#app/messageTemplate/message-template.module'; import { MessageModule } from '#app/message/message.module'; import { RoomModule } from '#app/room/room.module'; @@ -17,6 +17,9 @@ import { FileModule } from '#app/file/file.module'; import { TokenModule } from '#app/token/token.module'; import { HealthModule } from '#app/health/health.module'; import { MailModule } from '#app/mail/mail.module'; +import { NewsletterModule } from '#app/newsletter/newsletter.module'; +import { NewsletterSubscriberModule } from '#app/newsletterSubscriber/newsletter-subscriber.module'; +import { NewsletterManagerModule } from '#app/newsletterManager/newsletter-manager.module'; import { permissionRegistry } from '#core/permission-registry'; import { initI18n } from '#core/i18n'; import { startJobs, stopJobs } from '#jobs/index'; @@ -40,12 +43,15 @@ const loadModules = () => new UserModule(), new RegistrationModule(), new TableTemplateModule(), - new ManagerModule(), + new CampManagerModule(), new MessageModule(), new MessageTemplateModule(), new RoomModule(), new BedModule(), new FeedbackModule(), + new NewsletterModule(), + new NewsletterSubscriberModule(), + new NewsletterManagerModule(), ]); export async function boot() { diff --git a/backend/src/guards/manager.guard.ts b/backend/src/guards/manager.guard.ts index 1c8b1060..21a09d0b 100644 --- a/backend/src/guards/manager.guard.ts +++ b/backend/src/guards/manager.guard.ts @@ -1,4 +1,4 @@ -import { ManagerService } from '#app/manager/manager.service'; +import { CampManagerService } from '#app/campManager/camp-manager.service.js'; import type { Request } from 'express'; import type { Permission } from '@camp-registration/common/permissions'; import { permissionRegistry } from '#core/permission-registry'; @@ -11,7 +11,7 @@ export const campManager = ( const userId = req.authUserId(); const campId = req.modelOrFail('camp').id; - const managerService = resolve(ManagerService); + const managerService = resolve(CampManagerService); const manager = await managerService.getManagerByUserId(campId, userId); if (manager === null) { return false; diff --git a/backend/src/i18n/cs-CZ/index.ts b/backend/src/i18n/cs-CZ/index.ts index c90581c7..3201b8e2 100644 --- a/backend/src/i18n/cs-CZ/index.ts +++ b/backend/src/i18n/cs-CZ/index.ts @@ -2,6 +2,7 @@ import auth from './auth/index.js'; import camp from './camp/index.js'; import email from './email/index.js'; import manager from './manager/index.js'; +import newsletter from './newsletter/index.js'; import registration from './registration/index.js'; export default { @@ -13,5 +14,6 @@ export default { camp, email, manager, + newsletter, registration, }; diff --git a/backend/src/i18n/cs-CZ/newsletter/index.ts b/backend/src/i18n/cs-CZ/newsletter/index.ts new file mode 100644 index 00000000..11abcc0b --- /dev/null +++ b/backend/src/i18n/cs-CZ/newsletter/index.ts @@ -0,0 +1,7 @@ +export default { + email: { + reason: + '$t(email:footer.cause) jste se přihlásili k odběru našeho newsletteru.', + unsubscribe: 'Odhlásit se', + }, +}; diff --git a/backend/src/i18n/de-DE/index.ts b/backend/src/i18n/de-DE/index.ts index c90581c7..3201b8e2 100644 --- a/backend/src/i18n/de-DE/index.ts +++ b/backend/src/i18n/de-DE/index.ts @@ -2,6 +2,7 @@ import auth from './auth/index.js'; import camp from './camp/index.js'; import email from './email/index.js'; import manager from './manager/index.js'; +import newsletter from './newsletter/index.js'; import registration from './registration/index.js'; export default { @@ -13,5 +14,6 @@ export default { camp, email, manager, + newsletter, registration, }; diff --git a/backend/src/i18n/de-DE/newsletter/index.ts b/backend/src/i18n/de-DE/newsletter/index.ts new file mode 100644 index 00000000..86122015 --- /dev/null +++ b/backend/src/i18n/de-DE/newsletter/index.ts @@ -0,0 +1,7 @@ +export default { + email: { + reason: + '$t(email:footer.cause) Sie unseren Newsletter abonniert haben.', + unsubscribe: 'Abmelden', + }, +}; diff --git a/backend/src/i18n/en-US/index.ts b/backend/src/i18n/en-US/index.ts index b639aae3..7773bc2e 100644 --- a/backend/src/i18n/en-US/index.ts +++ b/backend/src/i18n/en-US/index.ts @@ -2,6 +2,7 @@ import auth from './auth/index.js'; import camp from './camp/index.js'; import email from './email/index.js'; import manager from './manager/index.js'; +import newsletter from './newsletter/index.js'; import registration from './registration/index.js'; export default { @@ -13,5 +14,6 @@ export default { camp, email, manager, + newsletter, registration, }; diff --git a/backend/src/i18n/en-US/newsletter/index.ts b/backend/src/i18n/en-US/newsletter/index.ts new file mode 100644 index 00000000..37f0ab54 --- /dev/null +++ b/backend/src/i18n/en-US/newsletter/index.ts @@ -0,0 +1,7 @@ +export default { + email: { + reason: + '$t(email:footer.cause) you subscribed to our newsletter.', + unsubscribe: 'Unsubscribe', + }, +}; diff --git a/backend/src/i18n/fr-FR/index.ts b/backend/src/i18n/fr-FR/index.ts index 7f2d5699..8fdfd7be 100644 --- a/backend/src/i18n/fr-FR/index.ts +++ b/backend/src/i18n/fr-FR/index.ts @@ -2,6 +2,7 @@ import auth from './auth/index.js'; import camp from './/camp/index.js'; import email from './email/index.js'; import manager from './manager/index.js'; +import newsletter from './newsletter/index.js'; import registration from './registration/index.js'; export default { @@ -13,5 +14,6 @@ export default { camp, email, manager, + newsletter, registration, }; diff --git a/backend/src/i18n/fr-FR/newsletter/index.ts b/backend/src/i18n/fr-FR/newsletter/index.ts new file mode 100644 index 00000000..72865832 --- /dev/null +++ b/backend/src/i18n/fr-FR/newsletter/index.ts @@ -0,0 +1,7 @@ +export default { + email: { + reason: + '$t(email:footer.cause) vous vous êtes abonné(e) à notre newsletter.', + unsubscribe: 'Se désabonner', + }, +}; diff --git a/backend/src/i18n/pl-PL/index.ts b/backend/src/i18n/pl-PL/index.ts index c90581c7..3201b8e2 100644 --- a/backend/src/i18n/pl-PL/index.ts +++ b/backend/src/i18n/pl-PL/index.ts @@ -2,6 +2,7 @@ import auth from './auth/index.js'; import camp from './camp/index.js'; import email from './email/index.js'; import manager from './manager/index.js'; +import newsletter from './newsletter/index.js'; import registration from './registration/index.js'; export default { @@ -13,5 +14,6 @@ export default { camp, email, manager, + newsletter, registration, }; diff --git a/backend/src/i18n/pl-PL/newsletter/index.ts b/backend/src/i18n/pl-PL/newsletter/index.ts new file mode 100644 index 00000000..c1ae06ec --- /dev/null +++ b/backend/src/i18n/pl-PL/newsletter/index.ts @@ -0,0 +1,7 @@ +export default { + email: { + reason: + '$t(email:footer.cause) zapisałeś/-aś się na nasz newsletter.', + unsubscribe: 'Wypisz się', + }, +}; diff --git a/backend/src/views/emails/newsletter.hbs.mjml b/backend/src/views/emails/newsletter.hbs.mjml new file mode 100644 index 00000000..8ddafcc6 --- /dev/null +++ b/backend/src/views/emails/newsletter.hbs.mjml @@ -0,0 +1,43 @@ + + + + {{{ subject }}} + + + {{{ body }}} + + + + + + + + + + {{ appName }} + + + + + + + + + + + + {{{ body }}} + + + + + + + {{{tg 'email:footer.sentTo' this }}} +
+ {{{t 'reason' this }}} {{{t 'unsubscribe' this }}} +
+
+
+
+
diff --git a/common/src/entities/Newsletter.ts b/common/src/entities/Newsletter.ts new file mode 100644 index 00000000..1df8886c --- /dev/null +++ b/common/src/entities/Newsletter.ts @@ -0,0 +1,23 @@ +import type { Identifiable } from './Identifiable.js'; + +export interface Newsletter extends Identifiable { + name: string; + description: string | null; + createdAt: string; + updatedAt: string | null; +} + +export interface NewsletterCreateData { + name: string; + description?: string | null; +} + +export interface NewsletterUpdateData { + name?: string; + description?: string | null; +} + +export interface NewsletterSendData { + subject: string; + body: string; +} diff --git a/common/src/entities/NewsletterManager.ts b/common/src/entities/NewsletterManager.ts new file mode 100644 index 00000000..d9d745a1 --- /dev/null +++ b/common/src/entities/NewsletterManager.ts @@ -0,0 +1,10 @@ +import type { Identifiable } from './Identifiable.js'; + +export interface NewsletterManager extends Identifiable { + name: string | null; + email: string; +} + +export interface NewsletterManagerCreateData { + email: string; +} diff --git a/common/src/entities/NewsletterSubscriber.ts b/common/src/entities/NewsletterSubscriber.ts new file mode 100644 index 00000000..090a9886 --- /dev/null +++ b/common/src/entities/NewsletterSubscriber.ts @@ -0,0 +1,19 @@ +import type { Identifiable } from './Identifiable.js'; + +export interface NewsletterSubscriber extends Identifiable { + email: string; + name: string | null; + country: string | null; + subscribedAt: string; +} + +export interface NewsletterSubscriberCreateData { + email: string; + name?: string | null; + country?: string | null; +} + +export interface NewsletterSubscriberImportData { + campId: string; + country?: string | null; +} diff --git a/common/src/entities/index.ts b/common/src/entities/index.ts index 7dc2e00b..49236572 100644 --- a/common/src/entities/index.ts +++ b/common/src/entities/index.ts @@ -18,6 +18,10 @@ export * from './User.js'; export * from './Message.js'; export * from './MessageTemplate.js'; +export * from './Newsletter.js'; +export * from './NewsletterManager.js'; +export * from './NewsletterSubscriber.js'; + // Types export * from './AuthTokens.js'; export * from './Authentication.js'; diff --git a/frontend/package.json b/frontend/package.json index 8611ab98..fcb23bd8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -76,7 +76,7 @@ "eslint": "^9.39.2", "eslint-plugin-vue": "^10.6.2", "globals": "^16.2.0", - "happy-dom": "^20.0.5", + "happy-dom": "^20.8.8", "prettier": "^3.6.0", "typescript": "^5.9.3", "vite-plugin-checker": "^0.12.0", diff --git a/frontend/src/components/campManagement/index/ResultsItem.vue b/frontend/src/components/campManagement/index/ResultsItem.vue index 4d1bbd85..616e7c94 100644 --- a/frontend/src/components/campManagement/index/ResultsItem.vue +++ b/frontend/src/components/campManagement/index/ResultsItem.vue @@ -148,9 +148,9 @@ function can(...permissions: Tail>): boolean { function resultsAction() { void withLoading(resultLoading, async () => { await router.push({ - name: 'participants', + name: 'management.camp.participants', params: { - camp: camp.id, + campId: camp.id, }, }); }); @@ -162,7 +162,7 @@ function shareAction() { router.resolve({ name: 'camp', params: { - camp: camp.id, + campId: camp.id, }, }).href; @@ -185,9 +185,9 @@ function shareAction() { function editAction() { void withLoading(editLoading, async () => { await router.push({ - name: 'edit-camp', + name: 'management.camp.settings.edit', params: { - camp: camp.id, + campId: camp.id, }, }); }); diff --git a/frontend/src/components/common/ProfileMenu.vue b/frontend/src/components/common/ProfileMenu.vue index 59340030..f1cc1987 100644 --- a/frontend/src/components/common/ProfileMenu.vue +++ b/frontend/src/components/common/ProfileMenu.vue @@ -38,6 +38,21 @@ + + + + + + {{ t('newsletters') }} + + + + administration: 'Administration' +newsletters: 'Newsletters' camps: 'My Camps' administration: 'Verwaltung' +newsletters: 'Newsletter' camps: 'Meine Camps' administration: 'Administration' +newsletters: 'Newsletters' camps: 'Mes Camps' administration: 'Administracja' +newsletters: 'Newslettery' camps: 'Moje obozy' administration: 'Administrace' +newsletters: 'Newslettery' camps: 'Moje tábory' diff --git a/frontend/src/components/newsletter/NewsletterCreateDialog.vue b/frontend/src/components/newsletter/NewsletterCreateDialog.vue new file mode 100644 index 00000000..aeb57616 --- /dev/null +++ b/frontend/src/components/newsletter/NewsletterCreateDialog.vue @@ -0,0 +1,151 @@ + + + + + +title: 'Create Newsletter' +input: + name: + label: 'Name' + rule: + required: 'Name is required' + description: + label: 'Description (optional)' +action: + create: 'Create' + cancel: 'Cancel' + + + +title: 'Newsletter erstellen' +input: + name: + label: 'Name' + rule: + required: 'Name ist erforderlich' + description: + label: 'Beschreibung (optional)' +action: + create: 'Erstellen' + cancel: 'Abbrechen' + + + +title: 'Créer une newsletter' +input: + name: + label: 'Nom' + rule: + required: 'Le nom est requis' + description: + label: 'Description (optionnel)' +action: + create: 'Créer' + cancel: 'Annuler' + + + +title: 'Utwórz newsletter' +input: + name: + label: 'Nazwa' + rule: + required: 'Nazwa jest wymagana' + description: + label: 'Opis (opcjonalny)' +action: + create: 'Utwórz' + cancel: 'Anuluj' + + + +title: 'Vytvořit newsletter' +input: + name: + label: 'Název' + rule: + required: 'Název je povinný' + description: + label: 'Popis (volitelný)' +action: + create: 'Vytvořit' + cancel: 'Zrušit' + + + diff --git a/frontend/src/components/newsletter/NewsletterEditDialog.vue b/frontend/src/components/newsletter/NewsletterEditDialog.vue new file mode 100644 index 00000000..70cb3277 --- /dev/null +++ b/frontend/src/components/newsletter/NewsletterEditDialog.vue @@ -0,0 +1,156 @@ + + + + + +title: 'Edit Newsletter' +input: + name: + label: 'Name' + rule: + required: 'Name is required' + description: + label: 'Description (optional)' +action: + save: 'Save' + cancel: 'Cancel' + + + +title: 'Newsletter bearbeiten' +input: + name: + label: 'Name' + rule: + required: 'Name ist erforderlich' + description: + label: 'Beschreibung (optional)' +action: + save: 'Speichern' + cancel: 'Abbrechen' + + + +title: 'Modifier la newsletter' +input: + name: + label: 'Nom' + rule: + required: 'Le nom est requis' + description: + label: 'Description (optionnel)' +action: + save: 'Enregistrer' + cancel: 'Annuler' + + + +title: 'Edytuj newsletter' +input: + name: + label: 'Nazwa' + rule: + required: 'Nazwa jest wymagana' + description: + label: 'Opis (opcjonalny)' +action: + save: 'Zapisz' + cancel: 'Anuluj' + + + +title: 'Upravit newsletter' +input: + name: + label: 'Název' + rule: + required: 'Název je povinný' + description: + label: 'Popis (volitelný)' +action: + save: 'Uložit' + cancel: 'Zrušit' + + + diff --git a/frontend/src/components/newsletter/NewsletterManagerAddDialog.vue b/frontend/src/components/newsletter/NewsletterManagerAddDialog.vue new file mode 100644 index 00000000..b8b79460 --- /dev/null +++ b/frontend/src/components/newsletter/NewsletterManagerAddDialog.vue @@ -0,0 +1,132 @@ + + + + + +title: 'Add Manager' +input: + email: + label: 'Email' + rule: + required: 'Email is required' +action: + add: 'Add' + cancel: 'Cancel' + + + +title: 'Verwalter hinzufügen' +input: + email: + label: 'E-Mail' + rule: + required: 'E-Mail ist erforderlich' +action: + add: 'Hinzufügen' + cancel: 'Abbrechen' + + + +title: 'Ajouter un gestionnaire' +input: + email: + label: 'E-mail' + rule: + required: 'L''e-mail est requis' +action: + add: 'Ajouter' + cancel: 'Annuler' + + + +title: 'Dodaj zarządzającego' +input: + email: + label: 'E-mail' + rule: + required: 'E-mail jest wymagany' +action: + add: 'Dodaj' + cancel: 'Anuluj' + + + +title: 'Přidat správce' +input: + email: + label: 'E-mail' + rule: + required: 'E-mail je povinný' +action: + add: 'Přidat' + cancel: 'Zrušit' + + + diff --git a/frontend/src/components/newsletter/NewsletterSubscriberAddDialog.vue b/frontend/src/components/newsletter/NewsletterSubscriberAddDialog.vue new file mode 100644 index 00000000..fd1143a1 --- /dev/null +++ b/frontend/src/components/newsletter/NewsletterSubscriberAddDialog.vue @@ -0,0 +1,175 @@ + + + + + +title: 'Add Subscriber' +input: + email: + label: 'Email' + rule: + required: 'Email is required' + name: + label: 'Name (optional)' + country: + label: 'Country Code (optional)' + hint: 'e.g. DE, FR, PL' +action: + add: 'Add' + cancel: 'Cancel' + + + +title: 'Abonnent hinzufügen' +input: + email: + label: 'E-Mail' + rule: + required: 'E-Mail ist erforderlich' + name: + label: 'Name (optional)' + country: + label: 'Ländercode (optional)' + hint: 'z.B. DE, FR, PL' +action: + add: 'Hinzufügen' + cancel: 'Abbrechen' + + + +title: 'Ajouter un abonné' +input: + email: + label: 'E-mail' + rule: + required: 'L''e-mail est requis' + name: + label: 'Nom (optionnel)' + country: + label: 'Code pays (optionnel)' + hint: 'ex. DE, FR, PL' +action: + add: 'Ajouter' + cancel: 'Annuler' + + + +title: 'Dodaj subskrybenta' +input: + email: + label: 'E-mail' + rule: + required: 'E-mail jest wymagany' + name: + label: 'Imię i nazwisko (opcjonalne)' + country: + label: 'Kod kraju (opcjonalny)' + hint: 'np. DE, FR, PL' +action: + add: 'Dodaj' + cancel: 'Anuluj' + + + +title: 'Přidat odběratele' +input: + email: + label: 'E-mail' + rule: + required: 'E-mail je povinný' + name: + label: 'Jméno (volitelné)' + country: + label: 'Kód země (volitelný)' + hint: 'např. DE, FR, PL' +action: + add: 'Přidat' + cancel: 'Zrušit' + + + diff --git a/frontend/src/components/newsletter/NewsletterSubscriberImportDialog.vue b/frontend/src/components/newsletter/NewsletterSubscriberImportDialog.vue new file mode 100644 index 00000000..583608cd --- /dev/null +++ b/frontend/src/components/newsletter/NewsletterSubscriberImportDialog.vue @@ -0,0 +1,202 @@ + + + + + +title: 'Import Subscribers from Camp' +input: + camp: + label: 'Camp' + hint: 'Select the camp to import subscribers from' + rule: + required: 'Camp is required' + country: + label: 'Filter by Country (optional)' + hint: 'Leave empty to import all, or enter a country code (e.g. DE, FR)' +notice: 'Only registrations with an email address will be imported. Existing subscribers will be skipped.' +action: + import: 'Import' + cancel: 'Cancel' + + + +title: 'Abonnenten aus Lager importieren' +input: + camp: + label: 'Lager' + hint: 'Wählen Sie das Lager aus, aus dem Abonnenten importiert werden sollen' + rule: + required: 'Lager ist erforderlich' + country: + label: 'Nach Land filtern (optional)' + hint: 'Leer lassen für alle, oder Ländercode eingeben (z.B. DE, FR)' +notice: 'Es werden nur Anmeldungen mit E-Mail-Adresse importiert. Bestehende Abonnenten werden übersprungen.' +action: + import: 'Importieren' + cancel: 'Abbrechen' + + + +title: 'Importer des abonnés depuis un camp' +input: + camp: + label: 'Camp' + hint: 'Sélectionnez le camp depuis lequel importer les abonnés' + rule: + required: 'Le camp est requis' + country: + label: 'Filtrer par pays (optionnel)' + hint: 'Laisser vide pour tout importer, ou saisir un code pays (ex. DE, FR)' +notice: 'Seules les inscriptions avec une adresse e-mail seront importées. Les abonnés existants seront ignorés.' +action: + import: 'Importer' + cancel: 'Annuler' + + + +title: 'Importuj subskrybentów z obozu' +input: + camp: + label: 'Obóz' + hint: 'Wybierz obóz, z którego mają być importowani subskrybenci' + rule: + required: 'Obóz jest wymagany' + country: + label: 'Filtruj według kraju (opcjonalnie)' + hint: 'Pozostaw puste, aby importować wszystkich, lub wprowadź kod kraju (np. DE, FR)' +notice: 'Importowane będą tylko zgłoszenia z adresem e-mail. Istniejący subskrybenci zostaną pominięci.' +action: + import: 'Importuj' + cancel: 'Anuluj' + + + +title: 'Importovat odběratele z tábora' +input: + camp: + label: 'Tábor' + hint: 'Vyberte tábor, ze kterého se mají importovat odběratelé' + rule: + required: 'Tábor je povinný' + country: + label: 'Filtrovat podle země (volitelné)' + hint: 'Nechte prázdné pro import všech, nebo zadejte kód země (např. DE, FR)' +notice: 'Importovány budou pouze registrace s e-mailovou adresou. Stávající odběratelé budou přeskočeni.' +action: + import: 'Importovat' + cancel: 'Zrušit' + + + diff --git a/frontend/src/layouts/AdministrationLayout.vue b/frontend/src/layouts/AdministrationLayout.vue index ff357a27..406cf7c5 100644 --- a/frontend/src/layouts/AdministrationLayout.vue +++ b/frontend/src/layouts/AdministrationLayout.vue @@ -142,6 +142,12 @@ const items: NavigationItemProps[] = [ icon: 'home', to: { name: 'administration.camps' }, }, + { + name: 'newsletters', + label: t('newsletters'), + icon: 'mail', + to: { name: 'administration.newsletters' }, + }, { name: 'settings', label: t('settings'), @@ -175,6 +181,7 @@ const dev = computed(() => { title: 'Administration' camps: 'Camps' +newsletters: 'Newsletters' users: 'Users' settings: 'Settings' @@ -183,6 +190,7 @@ settings: 'Settings' title: 'Verwaltung' camps: 'Camps' +newsletters: 'Newsletter' users: 'Benutzer' settings: 'Einstellungen' @@ -191,10 +199,29 @@ settings: 'Einstellungen' title: 'Administration' camps: 'Camps' +newsletters: 'Newsletters' users: 'Utilisateurs' settings: 'Paramètres' + +title: 'Administracja' + +camps: 'Obozy' +newsletters: 'Newslettery' +users: 'Użytkownicy' +settings: 'Ustawienia' + + + +title: 'Administrace' + +camps: 'Tábory' +newsletters: 'Newslettery' +users: 'Uživatelé' +settings: 'Nastavení' + + diff --git a/frontend/src/pages/newsletter/NewsletterPage.vue b/frontend/src/pages/newsletter/NewsletterPage.vue new file mode 100644 index 00000000..c892a106 --- /dev/null +++ b/frontend/src/pages/newsletter/NewsletterPage.vue @@ -0,0 +1,575 @@ + + + + + +tab: + subscribers: 'Subscribers' + send: 'Send' + managers: 'Managers' + +subscribers: + title: 'Subscribers' + action: + add: 'Add Subscriber' + import: 'Import from Camp' + importResult: 'Imported {added} new subscribers, {skipped} already subscribed.' + dialog: + delete: + title: 'Remove Subscriber' + message: 'Are you sure you want to remove this subscriber?' + +send: + title: 'Send Newsletter' + subject: 'Subject' + body: 'Message Body (HTML supported)' + action: 'Send to {count} Subscribers' + gdprNotice: 'Every email will include an unsubscribe link as required by EU law (GDPR). Subscribers can unsubscribe at any time.' + success: 'Newsletter queued for {count} recipients.' + error: 'Failed to send newsletter.' + dialog: + title: 'Confirm Send' + message: 'Are you sure you want to send this newsletter to {count} subscribers?' + +managers: + title: 'Managers' + action: + add: 'Add Manager' + dialog: + delete: + title: 'Remove Manager' + message: 'Are you sure you want to remove this manager?' + + + +tab: + subscribers: 'Abonnenten' + send: 'Senden' + managers: 'Verwalter' + +subscribers: + title: 'Abonnenten' + action: + add: 'Abonnent hinzufügen' + import: 'Aus Lager importieren' + importResult: '{added} neue Abonnenten importiert, {skipped} bereits abonniert.' + dialog: + delete: + title: 'Abonnent entfernen' + message: 'Möchten Sie diesen Abonnenten wirklich entfernen?' + +send: + title: 'Newsletter versenden' + subject: 'Betreff' + body: 'Nachrichtentext (HTML unterstützt)' + action: 'An {count} Abonnenten senden' + gdprNotice: 'Jede E-Mail enthält einen Abmeldelink gemäß EU-Recht (DSGVO). Abonnenten können sich jederzeit abmelden.' + success: 'Newsletter für {count} Empfänger in die Warteschlange gestellt.' + error: 'Newsletter konnte nicht gesendet werden.' + dialog: + title: 'Versand bestätigen' + message: 'Möchten Sie diesen Newsletter wirklich an {count} Abonnenten senden?' + +managers: + title: 'Verwalter' + action: + add: 'Verwalter hinzufügen' + dialog: + delete: + title: 'Verwalter entfernen' + message: 'Möchten Sie diesen Verwalter wirklich entfernen?' + + + +tab: + subscribers: 'Abonnés' + send: 'Envoyer' + managers: 'Gestionnaires' + +subscribers: + title: 'Abonnés' + action: + add: 'Ajouter un abonné' + import: 'Importer depuis un camp' + importResult: '{added} nouveaux abonnés importés, {skipped} déjà abonnés.' + dialog: + delete: + title: 'Supprimer un abonné' + message: 'Voulez-vous vraiment supprimer cet abonné ?' + +send: + title: 'Envoyer la newsletter' + subject: 'Sujet' + body: 'Corps du message (HTML pris en charge)' + action: 'Envoyer à {count} abonnés' + gdprNotice: 'Chaque e-mail contiendra un lien de désinscription conformément à la législation européenne (RGPD). Les abonnés peuvent se désabonner à tout moment.' + success: 'Newsletter mise en file d''attente pour {count} destinataires.' + error: 'Échec de l''envoi de la newsletter.' + dialog: + title: 'Confirmer l''envoi' + message: 'Voulez-vous vraiment envoyer cette newsletter à {count} abonnés ?' + +managers: + title: 'Gestionnaires' + action: + add: 'Ajouter un gestionnaire' + dialog: + delete: + title: 'Supprimer un gestionnaire' + message: 'Voulez-vous vraiment supprimer ce gestionnaire ?' + + + +tab: + subscribers: 'Subskrybenci' + send: 'Wyślij' + managers: 'Zarządzający' + +subscribers: + title: 'Subskrybenci' + action: + add: 'Dodaj subskrybenta' + import: 'Importuj z obozu' + importResult: 'Zaimportowano {added} nowych subskrybentów, {skipped} już zapisanych.' + dialog: + delete: + title: 'Usuń subskrybenta' + message: 'Czy na pewno chcesz usunąć tego subskrybenta?' + +send: + title: 'Wyślij newsletter' + subject: 'Temat' + body: 'Treść wiadomości (obsługiwany HTML)' + action: 'Wyślij do {count} subskrybentów' + gdprNotice: 'Każda wiadomość e-mail będzie zawierać link do rezygnacji z subskrypcji zgodnie z prawem UE (RODO). Subskrybenci mogą zrezygnować w dowolnym momencie.' + success: 'Newsletter dodany do kolejki dla {count} odbiorców.' + error: 'Nie udało się wysłać newslettera.' + dialog: + title: 'Potwierdź wysyłkę' + message: 'Czy na pewno chcesz wysłać ten newsletter do {count} subskrybentów?' + +managers: + title: 'Zarządzający' + action: + add: 'Dodaj zarządzającego' + dialog: + delete: + title: 'Usuń zarządzającego' + message: 'Czy na pewno chcesz usunąć tego zarządzającego?' + + + +tab: + subscribers: 'Odběratelé' + send: 'Odeslat' + managers: 'Správci' + +subscribers: + title: 'Odběratelé' + action: + add: 'Přidat odběratele' + import: 'Importovat z tábora' + importResult: 'Importováno {added} nových odběratelů, {skipped} již přihlášených.' + dialog: + delete: + title: 'Odstranit odběratele' + message: 'Opravdu chcete odstranit tohoto odběratele?' + +send: + title: 'Odeslat newsletter' + subject: 'Předmět' + body: 'Tělo zprávy (podporuje HTML)' + action: 'Odeslat {count} odběratelům' + gdprNotice: 'Každý e-mail bude obsahovat odkaz pro odhlášení v souladu s právem EU (GDPR). Odběratelé se mohou kdykoli odhlásit.' + success: 'Newsletter zařazen do fronty pro {count} příjemců.' + error: 'Odeslání newsletteru se nezdařilo.' + dialog: + title: 'Potvrdit odeslání' + message: 'Opravdu chcete odeslat tento newsletter {count} odběratelům?' + +managers: + title: 'Správci' + action: + add: 'Přidat správce' + dialog: + delete: + title: 'Odstranit správce' + message: 'Opravdu chcete odstranit tohoto správce?' + + + diff --git a/frontend/src/pages/newsletter/NewsletterUnsubscribePage.vue b/frontend/src/pages/newsletter/NewsletterUnsubscribePage.vue new file mode 100644 index 00000000..e47668c5 --- /dev/null +++ b/frontend/src/pages/newsletter/NewsletterUnsubscribePage.vue @@ -0,0 +1,112 @@ + + + + + +success: + title: 'Successfully Unsubscribed' + message: 'You have been successfully removed from the newsletter. You will no longer receive emails from this newsletter.' +error: + title: 'Unsubscribe Failed' + message: 'The unsubscribe link is invalid or has already been used. You may have already been unsubscribed.' + + + +success: + title: 'Erfolgreich abgemeldet' + message: 'Sie wurden erfolgreich vom Newsletter abgemeldet. Sie erhalten keine weiteren E-Mails von diesem Newsletter.' +error: + title: 'Abmeldung fehlgeschlagen' + message: 'Der Abmelde-Link ist ungültig oder wurde bereits verwendet. Möglicherweise sind Sie bereits abgemeldet.' + + + +success: + title: 'Désinscription réussie' + message: 'Vous avez été désinscrit(e) avec succès de la newsletter. Vous ne recevrez plus d''e-mails de cette newsletter.' +error: + title: 'Échec de la désinscription' + message: 'Le lien de désinscription est invalide ou a déjà été utilisé. Vous êtes peut-être déjà désinscrit(e).' + + + +success: + title: 'Pomyślnie wypisano' + message: 'Zostałeś/-aś pomyślnie wypisany/-a z newslettera. Nie będziesz już otrzymywać wiadomości z tego newslettera.' +error: + title: 'Wypisanie nie powiodło się' + message: 'Link do wypisania jest nieprawidłowy lub został już użyty. Możliwe, że jesteś już wypisany/-a.' + + + +success: + title: 'Odhlášení proběhlo úspěšně' + message: 'Byli jste úspěšně odhlášeni z odběru newsletteru. Již nebudete dostávat e-maily z tohoto newsletteru.' +error: + title: 'Odhlášení se nezdařilo' + message: 'Odkaz pro odhlášení je neplatný nebo již byl použit. Možná jste již odhlášeni.' + + + diff --git a/frontend/src/router/routes.ts b/frontend/src/router/routes.ts index c325a2c2..8d7048b4 100644 --- a/frontend/src/router/routes.ts +++ b/frontend/src/router/routes.ts @@ -110,21 +110,35 @@ const routes: RouteRecordRaw[] = [ import('pages/campManagement/CampManagementIndexPage.vue'), }, { - path: ':camp', - redirect: { - name: 'dashboard', + path: 'newsletters', + meta: { + hideDrawer: true, }, children: [ { - path: 'dashboard', - name: 'dashboard', - redirect: { - name: 'participants', - }, + path: '', + name: 'management.newsletters', + component: () => + import('pages/newsletter/NewsletterIndexPage.vue'), }, + { + path: ':newsletterId', + name: 'management.newsletter', + component: () => + import('pages/newsletter/NewsletterPage.vue'), + }, + ], + }, + { + path: 'camps/:campId', + name: 'management.camp', + redirect: { + name: 'management.camp.participants', + }, + children: [ { path: 'participants', - name: 'participants', + name: 'management.camp.participants', component: () => import('pages/campManagement/ParticipantsIndexPage.vue'), }, @@ -135,42 +149,42 @@ const routes: RouteRecordRaw[] = [ }, { path: 'room-planner', - name: 'room-planner', + name: 'management.camp.room-planner', component: () => import('pages/campManagement/RoomPlannerPage.vue'), }, { path: 'settings', - name: 'management.settings', + name: 'management.camp.settings', component: () => import('pages/campManagement/settings/SettingsPage.vue'), children: [ { path: 'access', - name: 'access', + name: 'management.camp.settings.access', component: () => import('pages/campManagement/settings/AccessPage.vue'), }, { path: 'edit', - name: 'edit-camp', + name: 'management.camp.settings.edit', component: () => import('pages/campManagement/settings/EditCampPage.vue'), }, { path: 'emails', - name: 'edit-email-templates', + name: 'management.camp.settings.emails', component: () => import('pages/campManagement/settings/MessageTemplateEditPage.vue'), }, { path: 'files', - name: 'edit-files', + name: 'management.camp.settings.files', component: () => import('pages/campManagement/settings/FileSettingsPage.vue'), }, { path: 'form', - name: 'edit-form', + name: 'management.camp.settings.form', component: () => import('pages/campManagement/settings/FormEditPage.vue'), }, @@ -201,6 +215,12 @@ const routes: RouteRecordRaw[] = [ name: 'administration.camps', component: () => import('pages/administration/CampIndexPage.vue'), }, + { + path: 'newsletters', + name: 'administration.newsletters', + component: () => + import('pages/administration/NewsletterIndexPage.vue'), + }, { path: 'users', name: 'administration.users', @@ -237,6 +257,18 @@ const routes: RouteRecordRaw[] = [ }, ], }, + { + path: '/newsletters/unsubscribe/:token', + component: () => import('layouts/AuthenticationLayout.vue'), + children: [ + { + path: '', + name: 'newsletter.unsubscribe', + component: () => + import('pages/newsletter/NewsletterUnsubscribePage.vue'), + }, + ], + }, { path: '/print', component: () => import('layouts/PrintLayout.vue'), diff --git a/frontend/src/services/APIService.ts b/frontend/src/services/APIService.ts index be6bb20c..71530ee2 100644 --- a/frontend/src/services/APIService.ts +++ b/frontend/src/services/APIService.ts @@ -12,6 +12,9 @@ import { useProfileService } from 'src/services/ProfileService'; import { useTotpService } from 'src/services/TotpService'; import { useMessageTemplateService } from 'src/services/MessageTemplateService'; import { useMessageService } from 'src/services/MessageService'; +import { useNewsletterService } from 'src/services/NewsletterService'; +import { useNewsletterManagerService } from 'src/services/NewsletterManagerService'; +import { useNewsletterSubscriberService } from 'src/services/NewsletterSubscriberService'; export function useAPIService() { return { @@ -28,6 +31,9 @@ export function useAPIService() { ...useTotpService(), ...useMessageService(), ...useMessageTemplateService(), + ...useNewsletterService(), + ...useNewsletterManagerService(), + ...useNewsletterSubscriberService(), }; } diff --git a/frontend/src/services/NewsletterManagerService.ts b/frontend/src/services/NewsletterManagerService.ts new file mode 100644 index 00000000..f3607498 --- /dev/null +++ b/frontend/src/services/NewsletterManagerService.ts @@ -0,0 +1,38 @@ +import { api } from 'boot/axios'; +import type { + NewsletterManager, + NewsletterManagerCreateData, +} from '@camp-registration/common/entities'; + +export function useNewsletterManagerService() { + async function fetchNewsletterManagers( + newsletterId: string, + ): Promise { + const response = await api.get(`newsletters/${newsletterId}/managers/`); + return response?.data?.data; + } + + async function createNewsletterManager( + newsletterId: string, + data: NewsletterManagerCreateData, + ): Promise { + const response = await api.post( + `newsletters/${newsletterId}/managers/`, + data, + ); + return response?.data?.data; + } + + async function deleteNewsletterManager( + newsletterId: string, + managerId: string, + ): Promise { + await api.delete(`newsletters/${newsletterId}/managers/${managerId}/`); + } + + return { + fetchNewsletterManagers, + createNewsletterManager, + deleteNewsletterManager, + }; +} diff --git a/frontend/src/services/NewsletterService.ts b/frontend/src/services/NewsletterService.ts new file mode 100644 index 00000000..87b22a5d --- /dev/null +++ b/frontend/src/services/NewsletterService.ts @@ -0,0 +1,58 @@ +import { api } from 'boot/axios'; +import type { + Newsletter, + NewsletterCreateData, + NewsletterSendData, + NewsletterUpdateData, +} from '@camp-registration/common/entities'; + +export function useNewsletterService() { + async function fetchNewsletters(options?: { + showAll?: boolean; + }): Promise { + const params = options?.showAll ? { showAll: true } : undefined; + const response = await api.get('newsletters/', { params }); + return response?.data?.data; + } + + async function fetchNewsletter(id: string): Promise { + const response = await api.get(`newsletters/${id}/`); + return response?.data?.data; + } + + async function createNewsletter( + data: NewsletterCreateData, + ): Promise { + const response = await api.post('newsletters/', data); + return response?.data?.data; + } + + async function updateNewsletter( + id: string, + data: NewsletterUpdateData, + ): Promise { + const response = await api.patch(`newsletters/${id}/`, data); + return response?.data?.data; + } + + async function deleteNewsletter(id: string): Promise { + await api.delete(`newsletters/${id}/`); + } + + async function sendNewsletter( + id: string, + data: NewsletterSendData, + ): Promise<{ queued: number }> { + const response = await api.post(`newsletters/${id}/send/`, data); + return response?.data?.data; + } + + return { + fetchNewsletters, + fetchNewsletter, + createNewsletter, + updateNewsletter, + deleteNewsletter, + sendNewsletter, + }; +} diff --git a/frontend/src/services/NewsletterSubscriberService.ts b/frontend/src/services/NewsletterSubscriberService.ts new file mode 100644 index 00000000..fe0ace8b --- /dev/null +++ b/frontend/src/services/NewsletterSubscriberService.ts @@ -0,0 +1,58 @@ +import { api } from 'boot/axios'; +import type { + NewsletterSubscriber, + NewsletterSubscriberCreateData, + NewsletterSubscriberImportData, +} from '@camp-registration/common/entities'; + +export function useNewsletterSubscriberService() { + async function fetchNewsletterSubscribers( + newsletterId: string, + ): Promise { + const response = await api.get(`newsletters/${newsletterId}/subscribers/`); + return response?.data?.data; + } + + async function createNewsletterSubscriber( + newsletterId: string, + data: NewsletterSubscriberCreateData, + ): Promise { + const response = await api.post( + `newsletters/${newsletterId}/subscribers/`, + data, + ); + return response?.data?.data; + } + + async function importNewsletterSubscribers( + newsletterId: string, + data: NewsletterSubscriberImportData, + ): Promise<{ added: number; skipped: number }> { + const response = await api.post( + `newsletters/${newsletterId}/subscribers/import/`, + data, + ); + return response?.data?.data; + } + + async function deleteNewsletterSubscriber( + newsletterId: string, + subscriberId: string, + ): Promise { + await api.delete( + `newsletters/${newsletterId}/subscribers/${subscriberId}/`, + ); + } + + async function unsubscribeByToken(token: string): Promise { + await api.delete(`newsletters/unsubscribe/${token}/`); + } + + return { + fetchNewsletterSubscribers, + createNewsletterSubscriber, + importNewsletterSubscribers, + deleteNewsletterSubscriber, + unsubscribeByToken, + }; +} diff --git a/frontend/src/stores/camp-details-store.ts b/frontend/src/stores/camp-details-store.ts index 25da1a19..df43d70b 100644 --- a/frontend/src/stores/camp-details-store.ts +++ b/frontend/src/stores/camp-details-store.ts @@ -50,7 +50,7 @@ export const useCampDetailsStore = defineStore('campDetails', () => { }); router.beforeEach(async (to, from) => { - if (to.params.camp === undefined) { + if (to.params.campId === undefined) { if (data.value !== undefined) { reset(); bus.emit('change', undefined); @@ -58,15 +58,15 @@ export const useCampDetailsStore = defineStore('campDetails', () => { return; } - if (data.value === undefined || to.params.camp !== from.params.camp) { - const campId = to.params.camp as string; + if (data.value === undefined || to.params.campId !== from.params.campId) { + const campId = to.params.campId as string; invalidate(); await fetchData(campId); } }); async function fetchData(id?: string) { - const campId = id ?? (route.params.camp as string); + const campId = id ?? (route.params.campId as string); const cid = checkNotNullWithError(campId); await lazyFetch(async () => { @@ -82,7 +82,7 @@ export const useCampDetailsStore = defineStore('campDetails', () => { notificationType: 'progress' | 'result' | 'error' | 'none' = 'progress', ): Promise { const campId = - newData.id ?? data.value?.id ?? (route.params.camp as string); + newData.id ?? data.value?.id ?? (route.params.campId as string); checkNotNullWithError(campId); diff --git a/frontend/src/stores/camp-manager-store.ts b/frontend/src/stores/camp-manager-store.ts index e6226848..236f34a1 100644 --- a/frontend/src/stores/camp-manager-store.ts +++ b/frontend/src/stores/camp-manager-store.ts @@ -35,7 +35,7 @@ export const useCampManagerStore = defineStore('campManager', () => { }); async function fetchData(campId?: string) { - campId ??= route.params.camp as string; + campId ??= route.params.campId as string; const cid = checkNotNullWithError(campId); await lazyFetch(async () => { @@ -44,7 +44,7 @@ export const useCampManagerStore = defineStore('campManager', () => { } async function createData(newData: CampManagerCreateData) { - const campId = route.params.camp as string; + const campId = route.params.campId as string; checkNotNullWithError(campId); @@ -59,7 +59,7 @@ export const useCampManagerStore = defineStore('campManager', () => { managerId: string, updateData: CampManagerUpdateData, ) { - const campId = route.params.camp as string; + const campId = route.params.campId as string; checkNotNullWithError(campId); checkNotNullWithNotification(managerId); @@ -78,7 +78,7 @@ export const useCampManagerStore = defineStore('campManager', () => { } async function deleteData(managerId: string) { - const campId = route.params.camp as string; + const campId = route.params.campId as string; checkNotNullWithError(campId); checkNotNullWithNotification(managerId); diff --git a/frontend/src/stores/newsletter-manager-store.ts b/frontend/src/stores/newsletter-manager-store.ts new file mode 100644 index 00000000..7bb7c93c --- /dev/null +++ b/frontend/src/stores/newsletter-manager-store.ts @@ -0,0 +1,72 @@ +import { defineStore } from 'pinia'; +import { useAPIService } from 'src/services/APIService'; +import { useServiceHandler } from 'src/composables/serviceHandler'; +import { useAuthBus } from 'src/composables/bus'; +import type { + NewsletterManager, + NewsletterManagerCreateData, +} from '@camp-registration/common/entities'; + +export const useNewsletterManagerStore = defineStore( + 'newsletterManager', + () => { + const api = useAPIService(); + const authBus = useAuthBus(); + const { + data, + isLoading, + error, + reset, + invalidate, + withProgressNotification, + lazyFetch, + checkNotNullWithError, + checkNotNullWithNotification, + } = useServiceHandler('newsletterManager'); + + authBus.on('logout', () => { + reset(); + }); + + async function fetchData(newsletterId: string) { + const nid = checkNotNullWithError(newsletterId); + await lazyFetch(async () => { + return await api.fetchNewsletterManagers(nid); + }); + } + + async function createData( + newsletterId: string, + newData: NewsletterManagerCreateData, + ) { + checkNotNullWithError(newsletterId); + await withProgressNotification('create', async () => { + const manager = await api.createNewsletterManager( + newsletterId, + newData, + ); + data.value?.push(manager); + }); + } + + async function deleteData(newsletterId: string, managerId: string) { + checkNotNullWithError(newsletterId); + checkNotNullWithNotification(managerId); + await withProgressNotification('delete', async () => { + await api.deleteNewsletterManager(newsletterId, managerId); + data.value = data.value?.filter((m) => m.id !== managerId); + }); + } + + return { + reset, + invalidate, + data, + isLoading, + error, + fetchData, + createData, + deleteData, + }; + }, +); diff --git a/frontend/src/stores/newsletter-store.ts b/frontend/src/stores/newsletter-store.ts new file mode 100644 index 00000000..ca6092f2 --- /dev/null +++ b/frontend/src/stores/newsletter-store.ts @@ -0,0 +1,70 @@ +import { defineStore } from 'pinia'; +import { useAPIService } from 'src/services/APIService'; +import { useServiceHandler } from 'src/composables/serviceHandler'; +import { useAuthBus } from 'src/composables/bus'; +import type { + Newsletter, + NewsletterCreateData, + NewsletterUpdateData, +} from '@camp-registration/common/entities'; + +export const useNewsletterStore = defineStore('newsletter', () => { + const api = useAPIService(); + const authBus = useAuthBus(); + const { + data, + isLoading, + error, + reset, + invalidate, + withProgressNotification, + lazyFetch, + checkNotNullWithError, + checkNotNullWithNotification, + } = useServiceHandler('newsletter'); + + authBus.on('logout', () => { + reset(); + }); + + async function fetchData() { + await lazyFetch(async () => { + return await api.fetchNewsletters(); + }); + } + + async function createData(newData: NewsletterCreateData) { + await withProgressNotification('create', async () => { + const newsletter = await api.createNewsletter(newData); + data.value?.push(newsletter); + }); + } + + async function updateData(id: string, updateData: NewsletterUpdateData) { + checkNotNullWithError(id); + await withProgressNotification('update', async () => { + const newsletter = await api.updateNewsletter(id, updateData); + data.value = data.value?.map((n) => (n.id === newsletter.id ? newsletter : n)); + }); + } + + async function deleteData(id: string) { + checkNotNullWithNotification(id); + await withProgressNotification('delete', async () => { + await api.deleteNewsletter(id); + data.value = data.value?.filter((n) => n.id !== id); + }); + } + + return { + reset, + invalidate, + data, + isLoading, + error, + fetchData, + createData, + updateData, + deleteData, + }; +}); diff --git a/frontend/src/stores/newsletter-subscriber-store.ts b/frontend/src/stores/newsletter-subscriber-store.ts new file mode 100644 index 00000000..0f0a700c --- /dev/null +++ b/frontend/src/stores/newsletter-subscriber-store.ts @@ -0,0 +1,91 @@ +import { defineStore } from 'pinia'; +import { useAPIService } from 'src/services/APIService'; +import { useServiceHandler } from 'src/composables/serviceHandler'; +import { useAuthBus } from 'src/composables/bus'; +import type { + NewsletterSubscriber, + NewsletterSubscriberCreateData, + NewsletterSubscriberImportData, +} from '@camp-registration/common/entities'; + +export const useNewsletterSubscriberStore = defineStore( + 'newsletterSubscriber', + () => { + const api = useAPIService(); + const authBus = useAuthBus(); + const { + data, + isLoading, + error, + reset, + invalidate, + withProgressNotification, + lazyFetch, + checkNotNullWithError, + checkNotNullWithNotification, + } = useServiceHandler('newsletterSubscriber'); + + authBus.on('logout', () => { + reset(); + }); + + async function fetchData(newsletterId: string) { + const nid = checkNotNullWithError(newsletterId); + await lazyFetch(async () => { + return await api.fetchNewsletterSubscribers(nid); + }); + } + + async function createData( + newsletterId: string, + newData: NewsletterSubscriberCreateData, + ) { + checkNotNullWithError(newsletterId); + await withProgressNotification('create', async () => { + const subscriber = await api.createNewsletterSubscriber( + newsletterId, + newData, + ); + data.value?.push(subscriber); + }); + } + + async function importFromCamp( + newsletterId: string, + importData: NewsletterSubscriberImportData, + ): Promise<{ added: number; skipped: number }> { + checkNotNullWithError(newsletterId); + let result = { added: 0, skipped: 0 }; + await withProgressNotification('create', async () => { + result = await api.importNewsletterSubscribers( + newsletterId, + importData, + ); + // Invalidate so it reloads on next fetch + invalidate(); + }); + return result; + } + + async function deleteData(newsletterId: string, subscriberId: string) { + checkNotNullWithError(newsletterId); + checkNotNullWithNotification(subscriberId); + await withProgressNotification('delete', async () => { + await api.deleteNewsletterSubscriber(newsletterId, subscriberId); + data.value = data.value?.filter((s) => s.id !== subscriberId); + }); + } + + return { + reset, + invalidate, + data, + isLoading, + error, + fetchData, + createData, + importFromCamp, + deleteData, + }; + }, +); diff --git a/frontend/src/stores/registration-store.ts b/frontend/src/stores/registration-store.ts index 03e5bd77..b528a4ae 100644 --- a/frontend/src/stores/registration-store.ts +++ b/frontend/src/stores/registration-store.ts @@ -42,7 +42,7 @@ export const useRegistrationsStore = defineStore('registrations', () => { }); async function fetchData(campId?: string) { - const cid: string = campId ?? (route.params.camp as string); + const cid: string = campId ?? (route.params.campId as string); checkNotNullWithError(cid); await lazyFetch(async () => { @@ -64,7 +64,7 @@ export const useRegistrationsStore = defineStore('registrations', () => { updateData: RegistrationUpdateData, params?: RegistrationUpdateQuery, ) { - const campId = route.params.camp as string; + const campId = route.params.campId as string; const cid = checkNotNullWithError(campId); const rid = checkNotNullWithNotification(registrationId); @@ -89,7 +89,7 @@ export const useRegistrationsStore = defineStore('registrations', () => { registrationId?: string, params?: RegistrationDeleteQuery, ) { - const campId = route.params.camp as string; + const campId = route.params.campId as string; const cid = checkNotNullWithError(campId); const rid = checkNotNullWithNotification(registrationId); diff --git a/frontend/src/stores/template-store.ts b/frontend/src/stores/template-store.ts index be6ae923..37766dd9 100644 --- a/frontend/src/stores/template-store.ts +++ b/frontend/src/stores/template-store.ts @@ -41,7 +41,7 @@ export const useTemplateStore = defineStore('templates', () => { } async function fetchData(campId?: string) { - campId = campId ?? (route.params.camp as string | undefined); + campId = campId ?? (route.params.campId as string | undefined); const cid = checkNotNullWithError(campId); await lazyFetch(async () => { @@ -55,7 +55,7 @@ export const useTemplateStore = defineStore('templates', () => { async function updateCollection(templates: TableTemplate[]) { const currentTemplates = data.value; - const campId = route.params.camp as string | undefined; + const campId = route.params.campId as string | undefined; const cid = checkNotNullWithError(campId); @@ -116,7 +116,7 @@ export const useTemplateStore = defineStore('templates', () => { template: TableTemplateCreateData, campId?: string, ) { - campId = campId ?? (route.params.camp as string | undefined); + campId = campId ?? (route.params.campId as string | undefined); const cid = checkNotNullWithError(campId); return await withProgressNotification('create', async () => { @@ -133,7 +133,7 @@ export const useTemplateStore = defineStore('templates', () => { templateId: string, template: TableTemplateUpdateData, ) { - const campId = route.params.camp as string | undefined; + const campId = route.params.campId as string | undefined; const cid = checkNotNullWithError(campId); return await withProgressNotification('update', async () => { @@ -153,7 +153,7 @@ export const useTemplateStore = defineStore('templates', () => { } async function deleteEntry(id: string) { - const campId = route.params.camp as string | undefined; + const campId = route.params.campId as string | undefined; const cid = checkNotNullWithError(campId); const tid = checkNotNullWithNotification(id); diff --git a/package-lock.json b/package-lock.json index f563024f..973f9c78 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,9 @@ "frontend", "e2e" ], + "dependencies": { + "happy-dom": "^20.8.9" + }, "devDependencies": { "cross-env": "^7.0.3", "npm-run-all": "^4.1.5", @@ -117,6 +120,182 @@ "node": ">=22 <25" } }, + "backend/node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "backend/node_modules/@vitest/ui": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.2.tgz", + "integrity": "sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.2" + } + }, + "backend/node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "backend/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "backend/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "backend/node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "backend/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "common": { "name": "@camp-registration/common", "version": "0.0.1", @@ -139,6 +318,182 @@ "survey-core": "2.5.4" } }, + "common/node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "common/node_modules/@vitest/ui": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.2.tgz", + "integrity": "sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.2" + } + }, + "common/node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "common/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "common/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "common/node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "common/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "e2e": { "name": "@camp-registration/e2e", "version": "0.0.1", @@ -222,7 +577,7 @@ "eslint": "^9.39.2", "eslint-plugin-vue": "^10.6.2", "globals": "^16.2.0", - "happy-dom": "^20.0.5", + "happy-dom": "^20.8.8", "prettier": "^3.6.0", "typescript": "^5.9.3", "vite-plugin-checker": "^0.12.0", @@ -233,6 +588,91 @@ "node": ">=22 <25" } }, + "frontend/node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "frontend/node_modules/@vitest/ui": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.1.2.tgz", + "integrity": "sha512-/irhyeAcKS2u6Zokagf9tqZJ0t8S6kMZq4ZG9BHZv7I+fkRrYfQX4w7geYeC2r6obThz39PDxvXQzZX+qXqGeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.2", + "fflate": "^0.8.2", + "flatted": "^3.4.2", + "pathe": "^2.0.3", + "sirv": "^3.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "vitest": "4.1.2" + } + }, + "frontend/node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "frontend/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "frontend/node_modules/happy-dom": { + "version": "20.8.8", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.8.tgz", + "integrity": "sha512-5/F8wxkNxYtsN0bXfMwIyNLZ9WYsoOYPbmoluqVJqv8KBUbcyKZawJ7uYK4WTX8IHBLYv+VXIwfeNDPy1oKMwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "frontend/node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, "frontend/node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", @@ -247,6 +687,115 @@ "node": ">=14.17" } }, + "frontend/node_modules/vitest": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.2.tgz", + "integrity": "sha512-xjR1dMTVHlFLh98JE3i/f/WePqJsah4A0FK9cc8Ehp9Udk0AZk6ccpIZhh1qJ/yxVWRZ+Q54ocnD8TXmkhspGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.2", + "@vitest/mocker": "4.1.2", + "@vitest/pretty-format": "4.1.2", + "@vitest/runner": "4.1.2", + "@vitest/snapshot": "4.1.2", + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.2", + "@vitest/browser-preview": "4.1.2", + "@vitest/browser-webdriverio": "4.1.2", + "@vitest/ui": "4.1.2", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "frontend/node_modules/vitest/node_modules/@vitest/mocker": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.2.tgz", + "integrity": "sha512-Ize4iQtEALHDttPRCmN+FKqOl2vxTiNUhzobQFFt/BM1lRUTG7zRCLOykG/6Vo4E4hnUdfVLo5/eqKPukcWW7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.2", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -2885,7 +3434,6 @@ "integrity": "sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=18.18" }, @@ -4229,6 +4777,34 @@ } } }, + "node_modules/@quasar/quasar-app-extension-testing-unit-vitest/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@quasar/quasar-app-extension-testing-unit-vitest/node_modules/happy-dom": { + "version": "15.11.7", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-15.11.7.tgz", + "integrity": "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^4.5.0", + "webidl-conversions": "^7.0.0", + "whatwg-mimetype": "^3.0.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@quasar/render-ssr-error": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@quasar/render-ssr-error/-/render-ssr-error-1.0.3.tgz", @@ -5374,7 +5950,6 @@ "resolved": "https://registry.npmjs.org/@svgdotjs/svg.js/-/svg.js-3.2.5.tgz", "integrity": "sha512-/VNHWYhNu+BS7ktbYoVGrCmsXDh+chFMaONMwGNdIBcFHrWqk2jY8fNyr3DLdtQUIalvkPfM554ZSFa3dm3nxQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/Fuzzyma" @@ -5398,7 +5973,6 @@ "resolved": "https://registry.npmjs.org/@svgdotjs/svg.select.js/-/svg.select.js-4.0.3.tgz", "integrity": "sha512-qkMgso1sd2hXKd1FZ1weO7ANq12sNmQJeGDjs46QwDVsxSRcHmvWKL2NDF7Yimpwf3sl5esOLkPqtV2bQ3v/Jg==", "license": "MIT", - "peer": true, "engines": { "node": ">= 14.18" }, @@ -5424,7 +5998,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz", "integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5752,7 +6325,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz", "integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -5792,7 +6364,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz", "integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -5991,7 +6562,6 @@ "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^5.0.0", @@ -6212,7 +6782,6 @@ "version": "24.10.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.9.tgz", "integrity": "sha512-ne4A0IpG3+2ETuREInjPNhUGis1SFjv1d5asp8MzEAGtOZeTeHVDOYqOgqfhvseqg/iXty2hjBf1zAOb7RNiNw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.16.0" @@ -6406,14 +6975,12 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", - "dev": true, "license": "MIT" }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -6475,7 +7042,6 @@ "integrity": "sha512-nm3cvFN9SqZGXjmw5bZ6cGmvJSyJPn0wU9gHAZZHDnZl2wF9PhHv78Xf06E0MaNk4zLVHL8hb2/c32XvyJOLQg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.53.1", "@typescript-eslint/types": "8.53.1", @@ -6719,7 +7285,6 @@ "integrity": "sha512-TlGPkLFLVOY3T7fZrwdvKpjprR3s4fxRln0ORDo1VQ7HHyxJwTlrjKU3kpVWTlaAjIEuCTokmjkZnr8Tpc925w==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.53" }, @@ -6763,40 +7328,69 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.17.tgz", - "integrity": "sha512-mEoqP3RqhKlbmUmntNDDCJeTDavDR+fVYkSOw8qRwJFaW/0/5zA9zFeTrHqNtcmwh6j26yMmwx2PqUDPzt5ZAQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.2.tgz", + "integrity": "sha512-gbu+7B0YgUJ2nkdsRJrFFW6X7NTP44WlhiclHniUhxADQJH5Szt9mZ9hWnJPJ8YwOK5zUOSSlSvyzRf0u1DSBQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.2", + "@vitest/utils": "4.1.2", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.17.tgz", - "integrity": "sha512-+ZtQhLA3lDh1tI2wxe3yMsGzbp7uuJSWBM1iTIKCbppWTSBN09PUC+L+fyNlQApQoR+Ps8twt2pbSSXg2fQVEQ==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@vitest/spy": "4.0.17", + "@vitest/spy": "3.2.4", "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" + "magic-string": "^0.30.17" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "peerDependenciesMeta": { "msw": { @@ -6807,12 +7401,27 @@ } } }, + "node_modules/@vitest/mocker/node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/mocker/node_modules/estree-walker": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/estree": "^1.0.0" } @@ -6831,27 +7440,56 @@ } }, "node_modules/@vitest/runner": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.17.tgz", - "integrity": "sha512-JmuQyf8aMWoo/LmNFppdpkfRVHJcsgzkbCA+/Bk7VfNH7RE6Ut2qxegeyx2j3ojtJtKIbIGy3h+KxGfYfk28YQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.2.tgz", + "integrity": "sha512-Gr+FQan34CdiYAwpGJmQG8PgkyFVmARK8/xSijia3eTFgVfpcpztWLuP6FttGNfPLJhaZVP/euvujeNYar36OQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", + "@vitest/utils": "4.1.2", "pathe": "^2.0.3" }, "funding": { "url": "https://opencollective.com/vitest" } }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/snapshot": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.17.tgz", - "integrity": "sha512-npPelD7oyL+YQM2gbIYvlavlMVWUfNNGZPcu0aEUQXt7FXTuqhmgiYupPnAanhKvyP6Srs2pIbWo30K0RbDtRQ==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.2.tgz", + "integrity": "sha512-g7yfUmxYS4mNxk31qbOYsSt2F4m1E02LFqO53Xpzg3zKMhLAPZAjjfyl9e6z7HrW6LvUdTwAQR3HHfLjpko16A==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.17", + "@vitest/pretty-format": "4.1.2", + "@vitest/utils": "4.1.2", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -6859,36 +7497,42 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/spy": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.17.tgz", - "integrity": "sha512-I1bQo8QaP6tZlTomQNWKJE6ym4SHf3oLS7ceNjozxxgzavRAgZDc06T7kD8gb9bXKEgcLNt00Z+kZO6KaJ62Ew==", + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.2.tgz", + "integrity": "sha512-dwQga8aejqeuB+TvXCMzSQemvV9hNEtDDpgUKDzOmNQayl2OG241PSWeJwKRH3CiC+sESrmoFd49rfnq7T4RnA==", "dev": true, "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, "funding": { "url": "https://opencollective.com/vitest" } }, - "node_modules/@vitest/ui": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/@vitest/ui/-/ui-4.0.17.tgz", - "integrity": "sha512-hRDjg6dlDz7JlZAvjbiCdAJ3SDG+NH8tjZe21vjxfvT2ssYAn72SRXMge3dKKABm3bIJ3C+3wdunIdur8PHEAw==", + "node_modules/@vitest/snapshot/node_modules/@vitest/utils": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.2.tgz", + "integrity": "sha512-xw2/TiX82lQHA06cgbqRKFb5lCAy3axQ4H4SoUFhUsg+wztiet+co86IAMDtF6Vm1hc7J6j09oh/rgDn+JdKIQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.17", - "fflate": "^0.8.2", - "flatted": "^3.3.3", - "pathe": "^2.0.3", - "sirv": "^3.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.2", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "vitest": "4.0.17" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.2.tgz", + "integrity": "sha512-DU4fBnbVCJGNBwVA6xSToNXrkZNSiw59H8tcuUspVMsBDBST4nfvsPsEHDHGtWRRnqBERBQu7TrTKskmjqTXKA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/utils": { @@ -7129,7 +7773,6 @@ "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "js-beautify": "^1.14.9", "vue-component-type-helpers": "^2.0.0" @@ -7219,7 +7862,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -7376,7 +8018,6 @@ "resolved": "https://registry.npmjs.org/apexcharts/-/apexcharts-4.7.0.tgz", "integrity": "sha512-iZSrrBGvVlL+nt2B1NpqfDuBZ9jX61X9I2+XV0hlYXHtTwhwLTHDKGXjNXAgFBDLuvSYCB/rq2nPWVPRv2DrGA==", "license": "MIT", - "peer": true, "dependencies": { "@svgdotjs/svg.draggable.js": "^3.0.4", "@svgdotjs/svg.filter.js": "^3.0.8", @@ -8188,7 +8829,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -8313,6 +8953,17 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, "node_modules/cacheable-lookup": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", @@ -8519,6 +9170,17 @@ "dev": true, "license": "MIT" }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 16" + } + }, "node_modules/check-more-types": { "version": "2.24.0", "resolved": "https://registry.npmjs.org/check-more-types/-/check-more-types-2.24.0.tgz", @@ -9221,6 +9883,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -9504,21 +10173,6 @@ "url": "https://github.com/sponsors/fb55" } }, - "node_modules/css-tree": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", - "integrity": "sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "mdn-data": "2.0.30", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/css-what": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", @@ -9551,7 +10205,6 @@ "integrity": "sha512-HYOPBsNvoiFeR1eghKD5C3ASm64v9YVyJB4Ivnl2gqKoQYvjjN/G0rztvKQq8OxocUtC6sjqY8jwYngIB4AByA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssnano-preset-default": "^7.0.10", "lilconfig": "^3.1.3" @@ -9989,6 +10642,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6" + } + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -10619,7 +11283,6 @@ "integrity": "sha512-rRqJg/6gd538VHvR3PSrdRBb/1Vy2YfzHqzvbhGIQpDRKIa4FgV/54b5Q1xYSxOOwKvjXweS26E0Q+nAMwp2pQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -10748,9 +11411,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", "dev": true, "license": "MIT" }, @@ -10907,7 +11570,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -10967,7 +11629,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", "license": "MIT", - "peer": true, "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -11030,7 +11691,6 @@ "integrity": "sha512-r2XFCK4qlo1sxEoAMIoTTX0PZAdla0JJDt1fmYiworZUX67WeEGqm+JbyAg3M+pGiJ5U6Mp5WQbontXWtIW7TA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.4.0", "natural-compare": "^1.4.0", @@ -11941,9 +12601,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "license": "ISC" }, "node_modules/fn.name": { @@ -12532,16 +13192,15 @@ } }, "node_modules/happy-dom": { - "version": "20.3.4", - "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.3.4.tgz", - "integrity": "sha512-rfbiwB6OKxZFIFQ7SRnCPB2WL9WhyXsFoTfecYgeCeFSOBxvkWLaXsdv5ehzJrfqwXQmDephAKWLRQoFoJwrew==", - "dev": true, + "version": "20.8.9", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.9.tgz", + "integrity": "sha512-Tz23LR9T9jOGVZm2x1EPdXqwA37G/owYMxRwU0E4miurAtFsPMQ1d2Jc2okUaSjZqAFz2oEn3FLXC5a0a+siyA==", "license": "MIT", "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", - "entities": "^4.5.0", + "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.18.3" }, @@ -12549,19 +13208,6 @@ "node": ">=20.0.0" } }, - "node_modules/happy-dom/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -14984,6 +15630,14 @@ "node": ">= 12.0.0" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", @@ -15038,9 +15692,8 @@ "version": "0.5.1", "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.1.tgz", "integrity": "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", @@ -15149,14 +15802,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.0.30", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.30.tgz", - "integrity": "sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==", - "dev": true, - "license": "CC0-1.0", - "optional": true - }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -16193,7 +16838,6 @@ "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz", "integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==", "license": "MIT-0", - "peer": true, "engines": { "node": ">=6.0.0" } @@ -17121,6 +17765,17 @@ "devOptional": true, "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14.16" + } + }, "node_modules/pause": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/pause/-/pause-0.0.1.tgz", @@ -17215,7 +17870,6 @@ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/devtools-api": "^7.7.7" }, @@ -17310,7 +17964,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -17989,7 +18642,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", "license": "MIT", - "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -18032,7 +18684,6 @@ "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@prisma/config": "6.19.2", "@prisma/engines": "6.19.2" @@ -18245,7 +18896,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -18275,7 +18925,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -18336,7 +18985,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz", "integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -18620,7 +19268,6 @@ "resolved": "https://registry.npmjs.org/quasar/-/quasar-2.18.6.tgz", "integrity": "sha512-ZlK+vJXOBPSFDCNQDBDNwSI+AHoqaFPxK8ve6mhsYLhMKWI5b8zsGY9VU1xYjngO2aBvU4fvGWXy4tTbzrBk8Q==", "license": "MIT", - "peer": true, "engines": { "node": ">= 10.18.1", "npm": ">= 6.13.4", @@ -18894,8 +19541,7 @@ "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -18986,7 +19632,6 @@ "integrity": "sha512-G08Dxvm4iDN3MLM0EsP62EDV9IuhXPR6blNz6Utcp7zyV3tr4HVNINt6MpaRWbxoOHT3Q7YN2P+jaHX8vUbgog==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">= 0.10" } @@ -19143,7 +19788,6 @@ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -20868,6 +21512,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, "node_modules/strnum": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", @@ -20986,15 +21644,13 @@ "version": "2.5.4", "resolved": "https://registry.npmjs.org/survey-core/-/survey-core-2.5.4.tgz", "integrity": "sha512-4FXrOPGGDwWhyN5+BfEfEMPiYcm/lA+CCVI+6p9BvysMZzt5zpar+NJxtU+eyWKJV0ixtOs2bt8lXYiELKRoVw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/survey-creator-core": { "version": "2.5.4", "resolved": "https://registry.npmjs.org/survey-creator-core/-/survey-creator-core-2.5.4.tgz", "integrity": "sha512-SO5W5Vztb1k3C/itc1e2zBU9+IQsYz0do2fVcq9N9pjrVnqmKn+AgZdZPWNw7mwssMano5LGmRNvoJUoPhcCfA==", "license": "SEE LICENSE IN LICENSE", - "peer": true, "engines": { "node": ">=0.10.0" }, @@ -21025,61 +21681,11 @@ "resolved": "https://registry.npmjs.org/survey-vue3-ui/-/survey-vue3-ui-2.5.4.tgz", "integrity": "sha512-LJPXRiXxrqTO3QzB3PTEHsHcNjSHBEnO1Vsoo2yjCXqQsq8emvH0HveqbfJnyNMv1hylrnf/uQI9xwZpZOxkIw==", "license": "MIT", - "peer": true, "peerDependencies": { "survey-core": "2.5.4", "vue": "^3.4.0" } }, - "node_modules/svgo": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-3.3.3.tgz", - "integrity": "sha512-+wn7I4p7YgJhHs38k2TNjy1vCfPIfLIJWR5MnCStsN8WuuTcBnRKcMHQLMM2ijxGZmDoZwNv8ipl5aTTen62ng==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "commander": "^7.2.0", - "css-select": "^5.1.0", - "css-tree": "^2.3.1", - "css-what": "^6.1.0", - "csso": "^5.0.5", - "picocolors": "^1.0.0", - "sax": "^1.5.0" - }, - "bin": { - "svgo": "bin/svgo" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, - "node_modules/svgo/node_modules/commander": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz", - "integrity": "sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/svgo/node_modules/sax": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.5.0.tgz", - "integrity": "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA==", - "dev": true, - "license": "BlueOak-1.0.0", - "optional": true, - "engines": { - "node": ">=11.0.0" - } - }, "node_modules/sync-child-process": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/sync-child-process/-/sync-child-process-1.0.2.tgz", @@ -21163,7 +21769,6 @@ "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -21267,12 +21872,34 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=14.0.0" } @@ -22179,7 +22806,6 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -22290,7 +22916,6 @@ "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true, "license": "MIT" }, "node_modules/unicorn-magic": { @@ -22556,7 +23181,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -22626,6 +23250,38 @@ } } }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-node/node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT", + "peer": true + }, "node_modules/vite-plugin-checker": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/vite-plugin-checker/-/vite-plugin-checker-0.12.0.tgz", @@ -23256,51 +23912,52 @@ } }, "node_modules/vitest": { - "version": "4.0.17", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.17.tgz", - "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", "peer": true, "dependencies": { - "@vitest/expect": "4.0.17", - "@vitest/mocker": "4.0.17", - "@vitest/pretty-format": "4.0.17", - "@vitest/runner": "4.0.17", - "@vitest/snapshot": "4.0.17", - "@vitest/spy": "4.0.17", - "@vitest/utils": "4.0.17", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", - "magic-string": "^0.30.21", - "obug": "^2.1.1", + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "bin": { "vitest": "vitest.mjs" }, "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.17", - "@vitest/browser-preview": "4.0.17", - "@vitest/browser-webdriverio": "4.0.17", - "@vitest/ui": "4.0.17", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, @@ -23308,19 +23965,13 @@ "@edge-runtime/vm": { "optional": true }, - "@opentelemetry/api": { + "@types/debug": { "optional": true }, "@types/node": { "optional": true }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { + "@vitest/browser": { "optional": true }, "@vitest/ui": { @@ -23363,6 +24014,137 @@ } } }, + "node_modules/vitest/node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/vitest/node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -23375,7 +24157,6 @@ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.27.tgz", "integrity": "sha512-aJ/UtoEyFySPBGarREmN4z6qNKpbEguYHMmXSiOGk69czc+zhs0NF6tEFrY8TZKAl8N/LYAkd4JHVd5E/AsSmw==", "license": "MIT", - "peer": true, "dependencies": { "@vue/compiler-dom": "3.5.27", "@vue/compiler-sfc": "3.5.27", @@ -23502,7 +24283,6 @@ "integrity": "sha512-r9YSia/VgGwmbbfC06hDdAatH634XJ9nVl6Zrnz1iK4ucp8Wu78kawplXnIDa3MSu1XdQQePTHLXYwPDWn+nyQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@volar/typescript": "2.4.27", "@vue/language-core": "3.2.2" @@ -23623,6 +24403,16 @@ "entities": "^4.5.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/webpack-merge": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", @@ -23676,7 +24466,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -24113,7 +24902,6 @@ "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -24315,7 +25103,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 387ae66f..6039c749 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "vitest": "^4.0.8", "@vitest/coverage-v8": "^4.0.8", "@vitest/ui": "^4.0.8", - "happy-dom": "^20.0.10" + "happy-dom": "^20.8.8" }, "mailparser": { "nodemailer": "^7.0.11" @@ -47,5 +47,8 @@ "smtp-tester": { "nodemailer": "^7.0.11" } + }, + "dependencies": { + "happy-dom": "^20.8.9" } }